From 87b12b0dee529861f8871c566381ff26337025b2 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 3 Nov 2026 13:32:11 -0500 Subject: [PATCH] feat(cli): Bootstrap account credentials from setup codes --- packages/cli/DEVELOPMENT.md | 1 + packages/cli/src/lib/env-registry.ts | 6 + packages/cli/src/lib/setup-code.ts | 133 ++++++++++- packages/cli/test/lib/bootstrap-auth.test.ts | 227 +++++++++++++++++++ 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/lib/bootstrap-auth.test.ts diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index a8f1131ef..eb0c62afa 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -85,6 +85,7 @@ The table below lists the most common development variables. For the complete re | `SENTRY_HOST` | Sentry instance URL (for self-hosted, takes precedence) | `https://sentry.io` | | `SENTRY_URL` | Alias for `SENTRY_HOST` | `https://sentry.io` | | `SENTRY_CLIENT_ID` | Sentry OAuth app client ID | Public sentry.io client ID | +| `SENTRY_SETUP_CODE` | One-time setup code for headless authentication | — | | `SENTRY_CONFIG_DIR` | Override credentials/cache directory (XDG default: ~/.config/sentry/) | `~/.config/sentry/` | | `SENTRY_LOG_LEVEL` | Diagnostic log level (`error`, `warn`, `log`, `info`, `debug`, `trace`) | `info` | | `SENTRY_CLI_NO_TELEMETRY` | Disable CLI telemetry (error tracking) | — | diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 9243d9fc1..f3eaf9db4 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -137,6 +137,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ selfHosted: true, devGuide: "Sentry OAuth app client ID", }, + { + name: "SENTRY_SETUP_CODE", + description: + "Private, short-lived code for headless OAuth authentication with sentry auth bootstrap or the installer. Each code can be redeemed once.", + devGuide: "One-time setup code for headless authentication", + }, // -- Custom headers -- { name: "SENTRY_CUSTOM_HEADERS", diff --git a/packages/cli/src/lib/setup-code.ts b/packages/cli/src/lib/setup-code.ts index 3ffb90532..b59f9b46d 100644 --- a/packages/cli/src/lib/setup-code.ts +++ b/packages/cli/src/lib/setup-code.ts @@ -1,6 +1,35 @@ /** Read the issuing Sentry instance from a private setup code. */ -import { looseObject, safeParse, string } from "valibot"; +import { + boolean, + description, + type InferOutput, + literal, + looseObject, + number, + object, + optional, + pipe, + safeParse, + string, +} from "valibot"; +import { pointDefaultUrlAtAccount } from "./account-selection.js"; +import { normalizeAuthToken } from "./auth-header.js"; +import { + getAccountIdToReplace, + hasStoredAuthCredentials, + setAuthToken, +} from "./db/auth.js"; +import { getDbPath } from "./db/index.js"; +import { clearUserInfo, setUserInfo } from "./db/user.js"; +import { getEnv, withEnv } from "./env.js"; +import { + getBootConfiguredSentryUrl, + inheritEnvTokenHost, +} from "./env-token-host.js"; +import { toLoginUser } from "./interactive-login.js"; +import { exchangeSetupCode } from "./oauth.js"; +import { registerLoginTrustAnchor } from "./token-host.js"; import { ValidationError } from "./errors.js"; import { normalizeHttpOrigin } from "./sentry-urls.js"; @@ -34,3 +63,105 @@ export function getSetupCodeHost(code: string): string { return host; } + +/** Safe authentication result exposed to command output and field discovery. */ +export const BootstrapResultSchema = object({ + method: literal("oauth"), + configPath: pipe(string(), description("Path to the stored CLI credentials")), + user: optional( + object({ + id: optional(string()), + name: optional(string()), + email: optional(string()), + username: optional(string()), + }), + ), + expiresIn: optional(number()), + refreshEnabled: boolean(), +}); + +/** Public result of successful setup-code authentication. */ +export type BootstrapResult = InferOutput; + +/** Redeem a private setup code, replacing only the issuing server's selected login. */ +export async function bootstrapAuthentication( + options: { + /** Explicit consent to replace an existing stored login. */ + force?: boolean; + /** Explicit issuing instance URL, required for HTTP without a boot-time URL. */ + url?: string; + } = {}, +): Promise { + const code = getEnv().SENTRY_SETUP_CODE; + + if (!code) { + throw new ValidationError( + "Set SENTRY_SETUP_CODE to the private code provided by Sentry.", + "setup-code", + ); + } + + const host = getSetupCodeHost(code); + + if (options.url && normalizeHttpOrigin(options.url) !== host) { + throw new ValidationError( + "The setup code belongs to a different Sentry instance.", + "url", + ); + } + + if ( + !host.startsWith("https://") && + !options.url && + getBootConfiguredSentryUrl() !== host + ) { + throw new ValidationError( + "HTTP setup requires an explicit --url or SENTRY_URL for the issuing instance.", + "url", + ); + } + + if ( + (getAccountIdToReplace(host) !== undefined || hasStoredAuthCredentials()) && + !options.force + ) { + throw new ValidationError( + "A stored login already exists. To replace it, run:\nSENTRY_SETUP_CODE='' sentry auth bootstrap --force", + "force", + ); + } + + if (options.url || getBootConfiguredSentryUrl() === host) { + registerLoginTrustAnchor(host); + } + + const env = { ...getEnv(), SENTRY_HOST: host, SENTRY_URL: host }; + inheritEnvTokenHost(env); + + return await withEnv(env, async () => { + const token = await exchangeSetupCode(code, host); + const accessToken = normalizeAuthToken(token.access_token); + setAuthToken(accessToken, token.expires_in, token.refresh_token, { + host, + }); + pointDefaultUrlAtAccount(host); + + if (token.user) { + setUserInfo({ + userId: token.user.id, + email: token.user.email ?? undefined, + name: token.user.name ?? undefined, + }); + } else { + clearUserInfo(); + } + + return { + method: "oauth", + configPath: getDbPath(), + expiresIn: token.expires_in, + refreshEnabled: Boolean(token.refresh_token), + ...(token.user ? { user: toLoginUser(token.user) } : {}), + }; + }); +} diff --git a/packages/cli/test/lib/bootstrap-auth.test.ts b/packages/cli/test/lib/bootstrap-auth.test.ts new file mode 100644 index 000000000..cf9c03ea3 --- /dev/null +++ b/packages/cli/test/lib/bootstrap-auth.test.ts @@ -0,0 +1,227 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { getAccountRow } from "../../src/lib/db/accounts.js"; +import { getAuthConfig, setAuthToken } from "../../src/lib/db/auth.js"; +import { getDatabase } from "../../src/lib/db/index.js"; +import { setUserInfo } from "../../src/lib/db/user.js"; +import { + captureEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../src/lib/env-token-host.js"; +import { bootstrapAuthentication } from "../../src/lib/setup-code.js"; +import { resetLoginTrustAnchorForTesting } from "../../src/lib/token-host.js"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("bootstrap-"); +useEnvSandbox([ + "SENTRY_URL", + "SENTRY_HOST", + "SENTRY_SETUP_CODE", + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_CUSTOM_HEADERS", +]); +const host = "https://sentry.example.com"; +const codeFor = (url: string) => + `sntryc_${Buffer.from(JSON.stringify({ url })).toString("base64")}_${"a".repeat(43)}`; +const token = { + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "bearer", + expires_in: 3600, + user: { id: "42", name: "Example", email: "user@example.com" }, +}; + +afterEach(() => { + resetEnvTokenHostForTesting(); + resetLoginTrustAnchorForTesting(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +test("stores refreshable credentials and reports safe account identity", async () => { + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(token))); + const result = await bootstrapAuthentication(); + expect(getAuthConfig()).toMatchObject({ + token: token.access_token, + refreshToken: token.refresh_token, + }); + expect(result.user?.id).toBe("42"); + expect(JSON.stringify(result)).not.toContain(token.access_token); +}); + +test.each([token.user, undefined])( + "forced setup preserves other accounts and identities", + async (user) => { + const target = setAuthToken("old-target", undefined, undefined, { host }); + setUserInfo({ userId: "old-target-user" }); + const other = setAuthToken("other-token", undefined, undefined, { + host: "https://other.example.com", + newAccount: true, + }); + setUserInfo({ userId: "other-user" }); + vi.stubEnv("SENTRY_HOST", "https://other.example.com"); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ ...token, user })), + ); + await bootstrapAuthentication({ force: true, url: host }); + expect(getAccountRow(target)).toMatchObject({ + token: token.access_token, + user_id: user?.id ?? null, + is_active: 1, + }); + expect(getAccountRow(other)).toMatchObject({ + token: "other-token", + user_id: "other-user", + }); + }, +); + +test.each([ + Response.json({ error: "invalid_grant" }, { status: 400 }), + Response.json({ ...token, access_token: "" }), +])( + "preserves an existing login when forced exchange fails", + async (response) => { + setAuthToken("existing-token", undefined, undefined, { host }); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + const fetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetch); + await expect(bootstrapAuthentication({ force: true })).rejects.toThrow(); + expect(getAuthConfig()?.token).toBe("existing-token"); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +test("requires explicit consent before replacing stored authentication", async () => { + setAuthToken("existing-token"); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(bootstrapAuthentication()).rejects.toThrow(/--force/); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("uses explicit proxy headers for a new server while preserving the old login on failure", async () => { + setAuthToken("old-server-token", undefined, undefined, { + host: "https://sentry.io", + }); + vi.stubEnv("SENTRY_HOST", "https://sentry.io"); + captureEnvTokenHost(); + vi.stubEnv("SENTRY_CUSTOM_HEADERS", "X-IAP-Token: proxy-secret"); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ error: "invalid_grant" }, { status: 400 }), + ); + vi.stubGlobal("fetch", fetch); + await expect( + bootstrapAuthentication({ force: true, url: host }), + ).rejects.toThrow(/Setup-code/); + expect(fetch.mock.calls[0]![1].headers.get("X-IAP-Token")).toBe( + "proxy-secret", + ); + expect(getAuthConfig()?.token).toBe("old-server-token"); +}); + +test("trusts the boot-time installer URL while rejecting post-boot HTTP URL changes", async () => { + const local = "http://localhost:8000"; + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(local)); + captureEnvTokenHost(); + vi.stubEnv("SENTRY_URL", local); + const fetch = vi.fn().mockResolvedValue(Response.json(token)); + vi.stubGlobal("fetch", fetch); + await expect(bootstrapAuthentication()).rejects.toThrow(/explicit --url/); + await expect(bootstrapAuthentication({ url: host })).rejects.toThrow( + /different Sentry instance/, + ); + expect(fetch).not.toHaveBeenCalled(); + resetEnvTokenHostForTesting(); + captureEnvTokenHost(); + await bootstrapAuthentication(); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test.each([undefined, "https://old.example.com"])( + "forced setup preserves a legacy account scoped to boot host %s", + async (bootHost) => { + if (bootHost) { + vi.stubEnv("SENTRY_HOST", bootHost); + } + captureEnvTokenHost(); + getDatabase() + .query( + "INSERT INTO accounts (account_id, token, host, is_active, updated_at) VALUES ('legacy', 'legacy-token', NULL, 1, ?)", + ) + .run(Date.now()); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(token))); + + await bootstrapAuthentication({ force: true, url: host }); + + expect(getAccountRow("legacy")).toMatchObject({ + token: "legacy-token", + host: bootHost ?? "https://sentry.io", + }); + }, +); + +test.each([ + ["https://sentry.io", undefined, false], + ["https://sentry.io", host, true], + [host, undefined, true], +])( + "scopes proxy headers with boot host %s and explicit URL %s", + async (bootHost, url, trusted) => { + vi.stubEnv("SENTRY_HOST", bootHost); + vi.stubEnv("SENTRY_AUTH_TOKEN", "opaque-env-token"); + vi.stubEnv("SENTRY_CUSTOM_HEADERS", "X-IAP-Token: proxy-secret"); + captureEnvTokenHost(); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ error: "invalid_grant" }, { status: 400 }), + ); + vi.stubGlobal("fetch", fetch); + + await expect(bootstrapAuthentication({ url })).rejects.toThrow( + /Setup-code/, + ); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0]![1].headers.get("X-IAP-Token")).toBe( + trusted ? "proxy-secret" : null, + ); + }, +); + +test.each([false, true])( + "protects the target account when the active login is expired and force=%s", + async (force) => { + const target = setAuthToken("target-token", undefined, undefined, { host }); + const other = setAuthToken("expired-token", -1, undefined, { + host: "https://other.example.com", + newAccount: true, + }); + vi.stubEnv("SENTRY_HOST", "https://other.example.com"); + vi.stubEnv("SENTRY_SETUP_CODE", codeFor(host)); + const fetch = vi.fn().mockResolvedValue(Response.json(token)); + vi.stubGlobal("fetch", fetch); + + if (force) { + await bootstrapAuthentication({ force }); + expect(getAccountRow(target)?.token).toBe(token.access_token); + expect(fetch).toHaveBeenCalledOnce(); + } else { + await expect(bootstrapAuthentication()).rejects.toThrow(/--force/); + expect(getAccountRow(target)?.token).toBe("target-token"); + expect(fetch).not.toHaveBeenCalled(); + } + expect(getAccountRow(other)?.token).toBe("expired-token"); + }, +);