Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | — |
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/env-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 132 additions & 1 deletion packages/cli/src/lib/setup-code.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<typeof BootstrapResultSchema>;

/** 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<BootstrapResult> {
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='<setup-code>' sentry auth bootstrap --force",
"force",
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

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) } : {}),
};
});
}
227 changes: 227 additions & 0 deletions packages/cli/test/lib/bootstrap-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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");
},
);
Loading