diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 02d63d4a9..6668e02e2 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -325,7 +325,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value ## Common Mistakes - **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. -- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` if you need to switch accounts. +- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account; use `sentry auth switch` to change which stored account is used. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project. diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 2bb95787d..2a1153874 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -57,7 +57,7 @@ toolkit/ │ │ ├── commands/ # CLI commands │ │ │ ├── agent-conversation/# list, view │ │ │ ├── alert/ # create, delete, edit, list, view -│ │ │ ├── auth/ # login, logout, refresh, status, token, whoami +│ │ │ ├── auth/ # list, login, logout, refresh, status, switch, token, whoami │ │ │ ├── build/ # download, upload │ │ │ ├── cli/ # completion, defaults, feedback, fix, import, setup, uninstall, upgrade │ │ │ ├── code-mappings/# upload diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index bc2f3afaf..1fff71d7a 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -59,10 +59,58 @@ sentry auth --token YOUR_TOKEN --url https://sentry.example.com See [Self-Hosted Sentry](../self-hosted/) for details. +### Multiple accounts + +The CLI can store several accounts at once — for example a work and a personal +account, or a SaaS and a self-hosted login. When you're already logged in, +`sentry auth login` asks whether to add another account and makes it active; +logging in again as the same user on the same server replaces that account's +credentials instead of adding a duplicate. `--force` skips the prompt and +replaces the active account's login instead. + +```bash +# Add (or log in again to) a specific account without prompting +sentry auth login jane@example.com +sentry auth login jane@example.com --url https://sentry.example.com + +# Show stored accounts; * marks the active one +sentry auth list + +# Switch the account commands use (pick from a searchable list without an argument) +sentry auth switch jane@example.com +sentry auth switch +``` + +Sentry approves a device-flow login as whoever is signed in to the browser, so +`sentry auth login ` checks the approved account and refuses (saving +nothing) if it belongs to someone else. + +Select an account by email, username, user ID, or account ID (from +`sentry auth list --json`). `sentry auth switch` also accepts a close match — +`sentry auth switch jane` picks `jane@example.com` with a warning when it's the +only candidate. If the same email is stored on several servers, add `--url`: +`sentry auth switch jane@example.com --url https://sentry.example.com`. + +Switching also points the default URL (`sentry cli defaults url`) at the +account's server. `SENTRY_HOST`/`SENTRY_URL` and `SENTRY_AUTH_TOKEN` (with +`SENTRY_FORCE_ENV_TOKEN=1`) still take precedence over the active account. +Default organization and project settings are shared by all accounts. + ### Logout ```bash +# Log out of the active account (the most recently used other account becomes active) sentry auth logout + +# Log out of specific accounts (space- or comma-separated) +sentry auth logout jane@example.com +sentry auth logout jane@example.com,jane@personal.dev + +# Pick the account on one server when the same email is stored twice +sentry auth logout jane@example.com --url https://sentry.example.com + +# Log out of every stored account +sentry auth logout all ``` ### Refresh the OAuth access token diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 3c5208756..355da0ded 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -337,7 +337,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value ### Common Mistakes - **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. -- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` if you need to switch accounts. +- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account; use `sentry auth switch` to change which stored account is used. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project. @@ -379,10 +379,12 @@ sentry auth logout Authenticate with Sentry -- `sentry auth login` — Authenticate with Sentry -- `sentry auth logout` — Log out of Sentry +- `sentry auth list` — List stored accounts +- `sentry auth login []` — Authenticate with Sentry +- `sentry auth logout ` — Log out of Sentry - `sentry auth refresh` — Refresh your OAuth access token - `sentry auth status` — View authentication status +- `sentry auth switch []` — Switch the active account - `sentry auth token` — Print the stored authentication token - `sentry auth whoami` — Show the currently authenticated identity diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md index 43fbe9eee..66362a61a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -11,14 +11,18 @@ requires: Authenticate with Sentry -### `sentry auth login` +### `sentry auth list` + +List stored accounts + +### `sentry auth login []` Authenticate with Sentry **Flags:** - `--token - Authenticate using an API token instead of OAuth` - `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` -- `--force - Re-authenticate without prompting` +- `--force - Replace the active account's login without prompting, instead of adding another account` - `--url - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).` - `--read-only - Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.` - `-s, --scope ... - Request specific OAuth scopes (repeatable, comma-separated). E.g. --scope project:read --scope org:read. Overrides the default scope set.` @@ -41,14 +45,28 @@ SENTRY_URL=https://sentry.example.com sentry auth sentry auth --token YOUR_TOKEN --url https://sentry.example.com ``` -### `sentry auth logout` +### `sentry auth logout ` Log out of Sentry +**Flags:** +- `--url - Only match accounts on this server (disambiguates the same email on several servers)` + **Examples:** ```bash +# Log out of the active account (the most recently used other account becomes active) sentry auth logout + +# Log out of specific accounts (space- or comma-separated) +sentry auth logout jane@example.com +sentry auth logout jane@example.com,jane@personal.dev + +# Pick the account on one server when the same email is stored twice +sentry auth logout jane@example.com --url https://sentry.example.com + +# Log out of every stored account +sentry auth logout all ``` ### `sentry auth refresh` @@ -92,6 +110,28 @@ sentry auth status --show-token sentry auth whoami ``` +### `sentry auth switch []` + +Switch the active account + +**Flags:** +- `--url - Only consider accounts on this server (disambiguates the same email on several servers)` + +**Examples:** + +```bash +# Add (or log in again to) a specific account without prompting +sentry auth login jane@example.com +sentry auth login jane@example.com --url https://sentry.example.com + +# Show stored accounts; * marks the active one +sentry auth list + +# Switch the account commands use (pick from a searchable list without an argument) +sentry auth switch jane@example.com +sentry auth switch +``` + ### `sentry auth token` Print the stored authentication token diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index 56fb3ed49..b1a8f394e 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,9 +1,11 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { authDefaultCommand } from "./default.js"; +import { listCommand } from "./list.js"; import { loginCommand } from "./login.js"; import { logoutCommand } from "./logout.js"; import { refreshCommand } from "./refresh.js"; import { statusCommand } from "./status.js"; +import { switchCommand } from "./switch.js"; import { tokenCommand } from "./token.js"; import { whoamiCommand } from "./whoami.js"; @@ -11,10 +13,12 @@ export const authRoute = buildRouteMap({ routes: { // Hidden smart default: login when logged out, status when logged in. default: authDefaultCommand, + list: listCommand, login: loginCommand, logout: logoutCommand, refresh: refreshCommand, status: statusCommand, + switch: switchCommand, token: tokenCommand, whoami: whoamiCommand, }, @@ -24,8 +28,12 @@ export const authRoute = buildRouteMap({ fullDescription: "Manage authentication with Sentry. Use 'sentry auth' to log in when logged out " + "or show status when logged in. Explicit subcommands: 'sentry auth login', " + - "'sentry auth logout', 'sentry auth refresh', 'sentry auth status', " + - "'sentry auth whoami', and 'sentry auth token'.", + "'sentry auth logout', 'sentry auth list', 'sentry auth switch', " + + "'sentry auth refresh', 'sentry auth status', 'sentry auth whoami', " + + "and 'sentry auth token'.\n\n" + + "Several accounts (including on different servers) can be stored at once: " + + "each 'sentry auth login' adds one, 'sentry auth list' shows them, and " + + "'sentry auth switch' changes which one commands use.", hideRoute: { default: true, }, diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts new file mode 100644 index 000000000..9c007d016 --- /dev/null +++ b/packages/cli/src/commands/auth/list.ts @@ -0,0 +1,51 @@ +/** + * sentry auth list + * + * List stored Sentry accounts. + */ + +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { listAccounts } from "../../lib/db/accounts.js"; +import { getActiveEnvVarName, getAuthConfig } from "../../lib/db/auth.js"; +import { + type AccountListResult, + formatAccountList, +} from "../../lib/formatters/accounts.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; + +export const listCommand = buildCommand({ + auth: false, + skipRcUrlCheck: true, + docs: { + brief: "List stored accounts", + fullDescription: + "List every Sentry account stored by 'sentry auth login', across servers.\n\n" + + "The active account (marked with *) is used for commands. Log in again to add\n" + + "another account, and use 'sentry auth switch' to change the active one.", + }, + output: { + human: formatAccountList, + jsonTransform: (data: AccountListResult) => data.accounts, + }, + parameters: { + flags: {}, + }, + async *func(this: SentryContext) { + const accounts = listAccounts(); + yield new CommandOutput({ accounts }); + + if (getAuthConfig()?.source.startsWith("env:")) { + return { + hint: `${getActiveEnvVarName()} is in use and overrides stored accounts.`, + }; + } + if (accounts.length > 1) { + return { hint: "Switch accounts with 'sentry auth switch '." }; + } + if (accounts.length === 0) { + return { hint: "Log in with 'sentry auth login'." }; + } + return { hint: "Add another account with 'sentry auth login'." }; + }, +}); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8b911319c..1d2fcacee 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,6 +1,7 @@ import { isatty } from "node:tty"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; +import { pointDefaultUrlAtAccount } from "../../lib/account-selection.js"; import { getCurrentUser, getUserRegions, @@ -10,14 +11,16 @@ import { normalizeAuthToken } from "../../lib/auth-header.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { normalizeUrl } from "../../lib/constants.js"; import { - clearAuth, + getActiveAccountId, getActiveEnvVarName, hasStoredAuthCredentials, isAuthenticated, isEnvTokenActive, + removeAccount, + setActiveAccount, setAuthToken, } from "../../lib/db/auth.js"; -import { setDefaultUrl } from "../../lib/db/defaults.js"; +import { getAccountRow, isAccountRowUsable } from "../../lib/db/accounts.js"; import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo, setUserInfo } from "../../lib/db/user.js"; import { getEnv } from "../../lib/env.js"; @@ -35,6 +38,7 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import type { LoginResult } from "../../lib/interactive-login.js"; import { + assertExpectedLoginEmail, runInteractiveLogin, toLoginUser, } from "../../lib/interactive-login.js"; @@ -148,6 +152,23 @@ function resolveLoginScope(flags: LoginFlags): string | undefined { * and full URLs; returns the normalized origin. */ /** @internal exported for testing */ +/** + * Parse the optional `email` argument of `sentry auth login`. + * + * @throws {ValidationError} when the value is not an email address + */ +export function parseLoginEmail(raw: string): string { + const email = raw.trim(); + const at = email.indexOf("@"); + if (at <= 0 || at === email.length - 1 || /\s/.test(email)) { + throw new ValidationError( + `Expected an email address to log in as, got '${raw}'.`, + "email", + ); + } + return email; +} + export function parseLoginUrl(raw: string): string { const prefixed = normalizeUrl(raw); if (!prefixed) { @@ -270,20 +291,22 @@ export function rcTokenHint( } /** - * Persist a non-SaaS `--url` host as the stored default so subsequent CLI - * invocations route correctly without requiring `SENTRY_HOST`. Only writes - * when `--url` was explicitly passed; env/rc-sourced values persist - * through those channels. Non-fatal on DB failure. + * Persist an explicit `--url` host as the stored default so subsequent CLI + * invocations route to the account just logged in to without requiring + * `SENTRY_HOST`: a self-hosted host is stored, SaaS clears a previous + * self-hosted default. Only writes when `--url` was explicitly passed; + * env/rc-sourced values persist through those channels. Non-fatal on DB + * failure. */ function persistLoginUrlAsDefault( flagUrl: string | undefined, effectiveHost: string, ): void { - if (!flagUrl || isSaaSTrustOrigin(effectiveHost)) { + if (!flagUrl) { return; } try { - setDefaultUrl(effectiveHost); + pointDefaultUrlAtAccount(effectiveHost); } catch { log.debug( `Could not persist default URL to DB; host is recorded on the stored token. Set SENTRY_HOST or run 'sentry cli defaults url ${effectiveHost}' if subsequent commands route incorrectly.`, @@ -318,12 +341,15 @@ export function applyLoginUrl(url: string | undefined): string { /** * Handle the case where the user is already authenticated. * - * Returns `true` if the login flow should proceed (credentials cleared), - * or `false` if the command should exit early. + * Returns `true` if the login flow should proceed, or `false` if the + * command should exit early. Without `--force` the new login is added + * alongside the current account and becomes active (logging in again as + * the same user replaces that user's old account); with `--force` it + * replaces the active account's credentials. * - * - Env-var auth: always blocks re-auth (user must unset the var). - * - `--force`: clears auth silently and proceeds. - * - Interactive TTY: prompts user to confirm re-authentication. + * - Env-var auth only: proceeds (stored OAuth is kept separately). + * - `--force`: proceeds without prompting. + * - Interactive TTY: prompts user to confirm adding another account. * - Non-interactive without `--force`: prints a message and blocks. */ async function handleExistingAuth(force: boolean): Promise { @@ -344,7 +370,8 @@ async function handleExistingAuth(force: boolean): Promise { // Non-interactive (piped, CI): print message and block if (!isatty(0)) { log.info( - "You are already authenticated. Use '--force' or 'sentry auth logout' first to re-authenticate.", + "You are already authenticated. Use 'sentry auth login ' to add another account, " + + "or '--force' to replace the current login.", ); return false; } @@ -353,7 +380,8 @@ async function handleExistingAuth(force: boolean): Promise { const userInfo = getUserInfo(); const identity = userInfo ? formatUserIdentity(userInfo) : "current user"; const confirmed = await log.prompt( - `Already authenticated as ${identity}. Re-authenticate?`, + `Already authenticated as ${identity}. Add another account? ` + + "(The current account stays saved; see 'sentry auth list'. Use --force to replace it.)", { type: "confirm", initial: false }, ); @@ -363,37 +391,74 @@ async function handleExistingAuth(force: boolean): Promise { } } - // Clear existing credentials and caches before re-authenticating - await clearAuth(); return true; } +/** + * Drop a `--token` login that failed validation and make the previously + * active account active again. The token is always stored as its own + * account until it checks out, so `previousAccountId` is still intact here. + */ +async function discardTokenLogin( + accountId: string, + previousAccountId: string | undefined, +): Promise { + await removeAccount(accountId); + if (previousAccountId && previousAccountId !== accountId) { + setActiveAccount(previousAccountId); + } +} + /** * Handle a failure while validating a user-supplied `--token` via * {@link getUserRegions}. A 401/403 means the token itself is rejected (invalid - * or insufficiently scoped) — clear it and raise a user-facing {@link AuthError}. - * Any other failure (network, 5xx, parse) is NOT a token problem, so the token - * is kept and the original error is re-thrown: surfacing "Invalid API token" - * would mislead the user and discard the real cause, and clearing a - * possibly-valid token on a transient blip is hostile (CLI-19). + * or insufficiently scoped) — remove the account it was stored as, restore the + * previously active account, and raise a user-facing {@link AuthError}. + * Any other failure (network, 5xx, parse) is NOT a token problem, so the + * original error is re-thrown: surfacing "Invalid API token" would mislead the + * user and discard the real cause. The unconfirmed token must not take over + * the session either: when the previously active account still has usable + * credentials, the token is dropped and that account restored (the user can + * retry). Otherwise (no other account, or its credentials expired or were + * rejected) the token is kept, since discarding a possibly-valid token on a + * transient blip would leave the user logged out (CLI-19). * - * @throws {AuthError} for a 401/403 (after clearing the stored token) + * @throws {AuthError} for a 401/403 (after removing the stored token) * @throws the original error for any other failure */ -async function handleTokenValidationError(error: unknown): Promise { +async function handleTokenValidationError( + error: unknown, + accountId: string, + previousAccountId: string | undefined, +): Promise { if ( error instanceof ApiError && (error.status === 401 || error.status === 403) ) { - await clearAuth(); + await discardTokenLogin(accountId, previousAccountId); throw new AuthError( "invalid", "Invalid API token. Please check your token and try again.", ); } + if (hasUsablePreviousAccount(accountId, previousAccountId)) { + await discardTokenLogin(accountId, previousAccountId); + } throw error; } +/** Whether the account active before this login can still authenticate. */ +function hasUsablePreviousAccount( + accountId: string, + previousAccountId: string | undefined, +): previousAccountId is string { + if (!previousAccountId || previousAccountId === accountId) { + return false; + } + const row = getAccountRow(previousAccountId); + return row !== undefined && isAccountRowUsable(row); +} + export const loginCommand = buildCommand({ auth: false, skipRcUrlCheck: true, @@ -406,7 +471,17 @@ export const loginCommand = buildCommand({ "For self-hosted Sentry, pass --url to authenticate against that\n" + "instance. This is the ONLY way to trust a new Sentry host — URL\n" + "arguments and config files are refused when they don't match the\n" + - "currently-authenticated host.", + "currently-authenticated host.\n\n" + + "When already logged in, the new login is stored as another account\n" + + "(see 'sentry auth list' / 'sentry auth switch'); --force instead\n" + + "replaces the active account's login. Pass an email to add or log in\n" + + "again to that account without prompting: Sentry approves the request\n" + + "as whoever is signed in to the browser, so a login approved as another\n" + + "user is refused and nothing is saved.\n\n" + + "Examples:\n" + + " sentry auth login\n" + + " sentry auth login jane@acme.com\n" + + " sentry auth login jane@acme.com --url https://sentry.acme.com", }, parameters: { flags: { @@ -425,7 +500,8 @@ export const loginCommand = buildCommand({ }, force: { kind: "boolean", - brief: "Re-authenticate without prompting", + brief: + "Replace the active account's login without prompting, instead of adding another account", default: false, }, url: { @@ -454,9 +530,21 @@ export const loginCommand = buildCommand({ }, }, aliases: { s: "scope" }, + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "email", + brief: + "Email of the account to add or log in to again; the login is refused if you approve it as someone else", + parse: parseLoginEmail, + optional: true, + }, + ], + }, }, output: { human: formatLoginResult }, - async *func(this: SentryContext, flags: LoginFlags) { + async *func(this: SentryContext, flags: LoginFlags, email?: string) { // Resolve OAuth scopes up front so conflicting flag combinations // (--token + --read-only/--scope, --read-only + --scope) and invalid // scope values fail fast before any network or DB work. @@ -481,8 +569,10 @@ export const loginCommand = buildCommand({ refuseLoginToUntrustedHost(flags, effectiveHost, urlFromRc); - // Check if already authenticated and handle re-authentication - if (isAuthenticated()) { + // Check if already authenticated and handle re-authentication. Naming + // the account to log in to is an explicit request to add (or refresh) + // that account, so it skips the prompt. + if (email === undefined && isAuthenticated()) { const shouldProceed = await handleExistingAuth(flags.force); if (!shouldProceed) { return; @@ -496,38 +586,70 @@ export const loginCommand = buildCommand({ // Non-fatal: cache directory may not exist } + // `--force` replaces the active account; otherwise the login is stored + // as another account (see handleExistingAuth). + const newAccount = email !== undefined || !flags.force; + if (token !== undefined) { - // Save token first (with host scope), then validate by fetching user regions - await setAuthToken(token, undefined, undefined, { + // Save token first (with host scope), then validate by fetching user + // regions. It is always stored as its own account so a rejected token + // never touches the stored ones; with `--force` the previously active + // account is replaced only once the new token checks out. + const previousAccountId = getActiveAccountId(); + const accountId = setAuthToken(token, undefined, undefined, { host: effectiveHost, + newAccount: true, }); try { await getUserRegions(); } catch (error) { - await handleTokenValidationError(error); + await handleTokenValidationError(error, accountId, previousAccountId); } - persistLoginUrlAsDefault(flags.url, effectiveHost); - // Fetch and cache user info via /auth/ (works with all token types). // A transient failure here must not block login — the token is already valid. const result: LoginResult = { method: "token", configPath: getDbPath(), }; - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. + let user: Awaited> | undefined; try { - const user = await getCurrentUser(); - setUserInfo({ - userId: user.id, - email: user.email ?? undefined, - username: user.username ?? undefined, - name: user.name ?? undefined, - }); - result.user = toLoginUser(user); - } catch { + user = await getCurrentUser(); + } catch (error) { // Non-fatal: user info is supplementary. Token remains stored and valid. + log.debug("Could not fetch user info after token login", error); + } + if (email !== undefined) { + try { + assertExpectedLoginEmail(email, user?.email); + } catch (error) { + await discardTokenLogin(accountId, previousAccountId); + throw error; + } + } + // Only now is the login kept: a rejected email above restores the + // previous account, so the default URL must not have moved yet. + persistLoginUrlAsDefault(flags.url, effectiveHost); + if (user) { + try { + setUserInfo({ + userId: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + name: user.name ?? undefined, + }); + } catch (error) { + // Non-fatal: user info is supplementary. Token remains stored and valid. + log.debug("Could not store user info after token login", error); + } + result.user = toLoginUser(user); + } + // Replace the previous account only now. After setUserInfo, so a + // re-login as the same user folds that account in (keeping a known + // username) instead of losing it; then this is a no-op. + if (!newAccount && previousAccountId && previousAccountId !== accountId) { + await removeAccount(previousAccountId); } // Warm the org + region cache so the first real command is fast. @@ -540,6 +662,8 @@ export const loginCommand = buildCommand({ const result = await runInteractiveLogin({ timeout: flags.timeout * 1000, scope: oauthScope, + newAccount, + expectedEmail: email, }); if (result) { diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 59d679cd7..afb27f0df 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,22 +1,35 @@ /** - * sentry auth logout + * sentry auth logout [account...|all] * - * Clear stored authentication credentials. + * Remove stored authentication credentials. */ import type { SentryContext } from "../../context.js"; +import { + pointDefaultUrlAtAccount, + resolveAccountSelector, +} from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; +import { + type AccountSummary, + getAccountRow, + isAccountOnServer, + listAccounts, + toAccountSummary, +} from "../../lib/db/accounts.js"; import { clearAuth, ENV_SOURCE_PREFIX, + getActiveAccountId, getActiveEnvVarName, getAuthConfig, - isAuthenticated, + removeAccount, } from "../../lib/db/auth.js"; import { getDbPath } from "../../lib/db/index.js"; import { AuthError } from "../../lib/errors.js"; import { formatLogoutResult } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { parseLoginUrl } from "./login.js"; /** Structured result of the logout operation */ export type LogoutResult = { @@ -26,44 +39,180 @@ export type LogoutResult = { message?: string; /** Path where credentials were stored (when loggedOut is true) */ configPath?: string; + /** Accounts that were removed */ + removed?: AccountSummary[]; + /** Account that is active after logout, if any remain */ + activeAccount?: AccountSummary; + /** Set when `all` was requested */ + all?: boolean; +}; + +type LogoutFlags = { + readonly url?: string; + readonly json: boolean; + readonly fields?: string[]; }; +const COMMAND = "sentry auth logout"; + +/** Selector that logs out of every stored account. */ +const ALL_ACCOUNTS = "all"; + +/** Split `a,b c` style arguments into individual, non-empty selectors. */ +export function parseLogoutSelectors(args: readonly string[]): string[] { + return args + .flatMap((arg) => arg.split(",")) + .map((selector) => selector.trim()) + .filter(Boolean); +} + +/** Refuse when the only credential in play comes from an env var. */ +function assertNotEnvOnly(): void { + const auth = getAuthConfig(); + if (auth?.source.startsWith(ENV_SOURCE_PREFIX)) { + const envVar = getActiveEnvVarName(); + throw new AuthError( + "invalid", + `Authentication is provided via ${envVar} environment variable. ` + + `Unset ${envVar} to log out.`, + ); + } +} + export const logoutCommand = buildCommand({ auth: false, skipRcUrlCheck: true, docs: { brief: "Log out of Sentry", fullDescription: - "Remove stored authentication credentials from the local database.", + "Remove stored authentication credentials from the local database.\n\n" + + "Without arguments, logs out of the active account; if other accounts are\n" + + "stored, the most recently used one becomes active. Pass one or more\n" + + "accounts (email, username, user ID, or account ID; space- or\n" + + "comma-separated) to remove those instead, or 'all' to remove every\n" + + "stored account. When the same email is stored for several servers, add\n" + + "--url to pick the one on that server.\n\n" + + "Examples:\n" + + " sentry auth logout\n" + + " sentry auth logout jane@acme.com,jane@personal.dev\n" + + " sentry auth logout jane@acme.com --url https://sentry.acme.com\n" + + " sentry auth logout all", }, output: { human: formatLogoutResult }, parameters: { - flags: {}, + flags: { + url: { + kind: "parsed", + parse: parseLoginUrl, + brief: + "Only match accounts on this server (disambiguates the same email on several servers)", + optional: true, + }, + }, + positional: { + kind: "array", + parameter: { + placeholder: "account", + brief: + "Accounts to log out of (email, username, user ID, or account ID; comma-separated), or 'all'", + parse: String, + }, + }, }, - async *func(this: SentryContext) { - if (!isAuthenticated()) { - return yield new CommandOutput({ + async *func( + this: SentryContext, + flags: LogoutFlags, + ...args: readonly string[] + ) { + const accounts = listAccounts(); + if (accounts.length === 0) { + assertNotEnvOnly(); + return yield new CommandOutput({ loggedOut: false, message: "Not currently authenticated.", }); } - const auth = getAuthConfig(); - if (auth?.source.startsWith(ENV_SOURCE_PREFIX)) { - const envVar = getActiveEnvVarName(); - throw new AuthError( - "invalid", - `Authentication is provided via ${envVar} environment variable. ` + - `Unset ${envVar} to log out.`, - ); + const configPath = getDbPath(); + const selectors = parseLogoutSelectors(args); + + if (selectors.some((sel) => sel.toLowerCase() === ALL_ACCOUNTS)) { + const { url } = flags; + const removed = url + ? accounts.filter((a) => isAccountOnServer(a, url)) + : accounts; + if (removed.length === accounts.length) { + await clearAuth(); + } else { + await removeAccounts(removed); + } + return yield* finishLogout(removed, configPath, true); } - const configPath = getDbPath(); - await clearAuth(); + let targets: AccountSummary[]; + if (selectors.length > 0) { + targets = uniqueAccounts( + selectors.map((sel) => + resolveAccountSelector(sel, COMMAND, { url: flags.url }), + ), + ); + } else { + const active = accounts.find((a) => a.id === getActiveAccountId()); + targets = active ? [active] : []; + } + if (targets.length === 0) { + return yield new CommandOutput({ + loggedOut: false, + message: + "No account is active. Run 'sentry auth list' and pass an account to log out of, or 'all'.", + }); + } - return yield new CommandOutput({ - loggedOut: true, - configPath, - }); + await removeAccounts(targets); + return yield* finishLogout(targets, configPath, false); }, }); + +/** + * Remove accounts, inactive ones first so promotion after removing the + * active one never picks an account that is about to be removed too. + */ +async function removeAccounts(accounts: AccountSummary[]): Promise { + const ordered = [...accounts].sort( + (a, b) => Number(a.active) - Number(b.active), + ); + for (const account of ordered) { + await removeAccount(account.id, { promoteNext: true }); + } +} + +function uniqueAccounts(accounts: AccountSummary[]): AccountSummary[] { + return accounts.filter( + (account, index) => + accounts.findIndex((a) => a.id === account.id) === index, + ); +} + +/** Report the removal and point the default URL at the new active account. */ +async function* finishLogout( + removed: AccountSummary[], + configPath: string, + all: boolean, +): AsyncGenerator, void, undefined> { + const nextActiveId = getActiveAccountId(); + const nextActiveRow = nextActiveId ? getAccountRow(nextActiveId) : undefined; + const activeAccount = nextActiveRow + ? toAccountSummary(nextActiveRow) + : undefined; + if (removed.some((a) => a.active) && activeAccount) { + pointDefaultUrlAtAccount(activeAccount.host); + } + yield new CommandOutput({ + loggedOut: removed.length > 0, + ...(removed.length === 0 ? { message: "No stored account matched." } : {}), + configPath, + removed, + activeAccount, + ...(all ? { all: true } : {}), + }); +} diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 619f6a268..83538aefc 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -11,12 +11,14 @@ import { type AuthConfig, type AuthSource, ENV_SOURCE_PREFIX, + getActiveAuthHost, getActiveEnvVarName, getAuthConfig, getAuthToken, getRawEnvToken, isAuthenticated, } from "../../lib/db/auth.js"; +import { listAccounts } from "../../lib/db/accounts.js"; import { getDefaultOrganization, getDefaultProject, @@ -58,6 +60,10 @@ export type AuthStatusData = { configPath?: string; /** User identity from cached user info */ user?: { name?: string; email?: string; username?: string }; + /** Server the active stored account belongs to (only for non-env tokens) */ + host?: string; + /** Number of stored accounts (only for non-env tokens) */ + accounts?: number; /** Token display and metadata */ token?: { /** Masked or full token string depending on --show-token */ @@ -209,6 +215,8 @@ export const statusCommand = buildCommand({ source: auth?.source ?? "oauth", configPath: fromEnv ? undefined : getDbPath(), user, + host: fromEnv ? undefined : getActiveAuthHost(), + accounts: fromEnv ? undefined : listAccounts().length, token: collectTokenInfo(auth, flags["show-token"]), defaults: collectDefaults(), verification: await verifyCredentials(), diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts new file mode 100644 index 000000000..ef69f6a81 --- /dev/null +++ b/packages/cli/src/commands/auth/switch.ts @@ -0,0 +1,148 @@ +/** + * sentry auth switch [account] + * + * Change which stored account the CLI uses. + */ + +import type { SentryContext } from "../../context.js"; +import { + pointDefaultUrlAtAccount, + promptForAccount, + resolveAccountSelector, +} from "../../lib/account-selection.js"; +import { buildCommand } from "../../lib/command.js"; +import { + type AccountSummary, + getAccountRow, + isAccountOnServer, + listAccounts, + toAccountSummary, +} from "../../lib/db/accounts.js"; +import { + getActiveEnvVarName, + getAuthConfig, + setActiveAccount, +} from "../../lib/db/auth.js"; +import { AuthError, ResolutionError } from "../../lib/errors.js"; +import { + type AccountSwitchResult, + formatAccountSwitch, +} from "../../lib/formatters/accounts.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { parseLoginUrl } from "./login.js"; + +const COMMAND = "sentry auth switch"; + +type SwitchFlags = { + readonly url?: string; +}; + +async function pickAccount( + selector: string | undefined, + accounts: AccountSummary[], + url: string | undefined, +): Promise { + if (selector !== undefined) { + return resolveAccountSelector(selector, COMMAND, { url, fuzzy: true }); + } + const candidates = url + ? accounts.filter((a) => isAccountOnServer(a, url)) + : accounts; + if (candidates.length === 0) { + throw new ResolutionError( + `Account on '${url}'`, + "not found", + `${COMMAND} [account] --url `, + ["Run 'sentry auth list' to see stored accounts"], + ); + } + const [only] = candidates; + if (candidates.length === 1 && only) { + return only; + } + return await promptForAccount( + candidates, + "Switch to which account?", + COMMAND, + ); +} + +export const switchCommand = buildCommand({ + auth: false, + skipRcUrlCheck: true, + docs: { + brief: "Switch the active account", + fullDescription: + "Make another stored account the active one. Subsequent commands use its\n" + + "credentials and server (the default URL is updated to match; SENTRY_HOST\n" + + "and SENTRY_URL still take precedence).\n\n" + + "Select the account by email, username, user ID, or account ID (see\n" + + "'sentry auth list --json'). A close but inexact match (e.g. 'jane' for\n" + + "'jane@acme.com') is used with a warning when it is the only one. When\n" + + "the same user is stored on several servers, add --url (or use\n" + + "'@'). Without an argument, pick from a\n" + + "searchable list interactively.\n\n" + + "Examples:\n" + + " sentry auth switch\n" + + " sentry auth switch jane@acme.com\n" + + " sentry auth switch jane@acme.com --url https://sentry.acme.com", + }, + output: { human: formatAccountSwitch }, + parameters: { + flags: { + url: { + kind: "parsed", + parse: parseLoginUrl, + brief: + "Only consider accounts on this server (disambiguates the same email on several servers)", + optional: true, + }, + }, + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "account", + brief: "Email, username, user ID, or account ID to switch to", + parse: String, + optional: true, + }, + ], + }, + }, + async *func(this: SentryContext, flags: SwitchFlags, selector?: string) { + const accounts = listAccounts(); + if (accounts.length === 0) { + throw new AuthError("not_authenticated", undefined, { + skipAutoAuth: true, + }); + } + + const target = await pickAccount(selector, accounts, flags.url); + if (!target) { + return; + } + + setActiveAccount(target.id); + // Re-read: activation backfills a legacy account's missing host. + const activeRow = getAccountRow(target.id); + const active = activeRow ? toAccountSummary(activeRow) : target; + pointDefaultUrlAtAccount(active.host); + const switched = !target.active; + yield new CommandOutput({ + switched, + account: { ...active, active: true }, + }); + + if (!target.usable) { + return { + hint: "This account's session has ended. Run 'sentry auth login' to log in again.", + }; + } + if (getAuthConfig()?.source.startsWith("env:")) { + return { + hint: `${getActiveEnvVarName()} is set and overrides stored accounts.`, + }; + } + }, +}); diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 91e395e1c..267850ab0 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -9,7 +9,8 @@ import type { SentryContext } from "../../context.js"; import { getCurrentUser } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { getAuthToken } from "../../lib/db/auth.js"; +import { type AccountSummary, listAccounts } from "../../lib/db/accounts.js"; +import { getAuthConfig, getAuthToken } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; import { ResolutionError } from "../../lib/errors.js"; import { @@ -17,6 +18,7 @@ import { formatUserIdentity, type OrgTokenIdentity, } from "../../lib/formatters/index.js"; +import { formatAccountLabel } from "../../lib/formatters/accounts.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -104,19 +106,57 @@ export const whoamiCommand = buildCommand({ const user = await getCurrentUser(); - // Keep cached user info up to date. Non-fatal: display must succeed even - // if the DB write fails (read-only filesystem, corrupted database, etc.). - try { - setUserInfo({ - userId: user.id, - email: user.email ?? undefined, - username: user.username ?? undefined, - name: user.name ?? undefined, - }); - } catch (error) { - log.debug("Failed to update cached user info", error); + // Keep cached user info up to date. Only for stored credentials: an env + // token's user must not be written onto the stored active account. + // Non-fatal: display must succeed even if the DB write fails (read-only + // filesystem, corrupted database, etc.). + if (getAuthConfig()?.source === "oauth") { + try { + setUserInfo({ + userId: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + name: user.name ?? undefined, + }); + } catch (error) { + log.debug("Failed to update cached user info", error); + } } - return yield new CommandOutput(user); + yield new CommandOutput(user); + // An env-var token overrides stored accounts, so switching wouldn't help. + const hint = getAuthConfig()?.source.startsWith("env:") + ? undefined + : formatOtherIdentitiesHint(user); + return hint ? { hint } : undefined; }, }); + +/** + * A light reminder of the other stored accounts the user can switch to, + * e.g. "You are Jane, but you can be whoever you want — as long as it's + * one of: jane@personal.dev on sentry.io. Run 'sentry auth switch'." + * Returns `undefined` when no other usable account is stored. + */ +export function formatOtherIdentitiesHint(user: { + id: string; + name?: string | null; + email?: string | null; + username?: string | null; +}): string | undefined { + let others: AccountSummary[]; + try { + others = listAccounts().filter((a) => !a.active && a.usable); + } catch (error) { + log.debug("Could not list stored accounts", error); + return; + } + if (others.length === 0) { + return; + } + const you = user.name || user.username || user.email || `user ${user.id}`; + return ( + `You are ${you}, but you can be whoever you want — as long as it's one of: ` + + `${others.map(formatAccountLabel).join(", ")}. Run 'sentry auth switch'.` + ); +} diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts new file mode 100644 index 000000000..fd39cc821 --- /dev/null +++ b/packages/cli/src/lib/account-selection.ts @@ -0,0 +1,152 @@ +/** + * Helpers for the account commands: resolve which stored account a + * `sentry auth switch` / `logout` argument refers to (or ask interactively), + * and keep the default URL pointed at the active account's server. + */ + +import { isatty } from "node:tty"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { + type AccountSummary, + findAccounts, + findSimilarAccounts, + hostnameOf, + isAccountOnServer, +} from "./db/accounts.js"; +import { getDefaultUrl, setDefaultUrl } from "./db/defaults.js"; +import { ContextError, ResolutionError } from "./errors.js"; +import { formatAccountLabel } from "./formatters/accounts.js"; +import { interactivePromptsAllowed } from "./interactive-prompts.js"; +import { logger } from "./logger.js"; + +const log = logger.withTag("auth.accounts"); + +/** Options for {@link resolveAccountSelector}. */ +export type AccountSelectorOptions = { + /** + * Only consider accounts on this server (`--url`). Disambiguates the same + * email or username stored for several servers. + */ + url?: string; + /** + * Use a sole fuzzy match (with a warning) when nothing matches exactly. + * Off by default; destructive commands like `logout` only suggest. + */ + fuzzy?: boolean; +}; + +function onServer( + accounts: AccountSummary[], + url: string | undefined, +): AccountSummary[] { + return url ? accounts.filter((a) => isAccountOnServer(a, url)) : accounts; +} + +function describeAccount(account: AccountSummary): string { + return `${formatAccountLabel(account)} (id: ${account.id})`; +} + +/** + * Resolve a user-supplied selector (account id, email, username, user id, + * or `@`) to exactly one stored account. Without an + * exact match, similar accounts from the shared fuzzy matcher are suggested + * (or, with `fuzzy`, a sole one is used). + * + * @throws {ResolutionError} when nothing or more than one account matches + */ +export function resolveAccountSelector( + selector: string, + command: string, + options: AccountSelectorOptions = {}, +): AccountSummary { + const where = options.url ? ` on ${hostnameOf(options.url)}` : ""; + const matches = onServer(findAccounts(selector), options.url); + const [match] = matches; + if (matches.length === 1 && match) { + return match; + } + if (matches.length > 1) { + throw new ResolutionError( + `Account '${selector}'${where}`, + "matches more than one stored account", + `${command} `, + [ + ...matches.map(describeAccount), + "Add --url or use the account id to pick one", + ], + ); + } + + const similar = onServer(findSimilarAccounts(selector), options.url); + const [sole] = similar; + if (options.fuzzy && similar.length === 1 && sole) { + log.warn( + `No account matching '${selector}'. Using ${formatAccountLabel(sole)}.`, + ); + return sole; + } + throw new ResolutionError( + `Account '${selector}'${where}`, + "not found", + `${command} `, + similar.length > 0 + ? [ + ...similar.map((a) => `Did you mean ${describeAccount(a)}?`), + "Run 'sentry auth list' to see stored accounts", + ] + : ["Run 'sentry auth list' to see stored accounts"], + ); +} + +/** + * Ask the user to pick one of `accounts` in an interactive terminal, with + * type-to-filter fuzzy search (the same picker as org/project selection). + * Returns `undefined` when the prompt is cancelled. + * + * @throws {ContextError} when prompts are not allowed (`--json`, or stdin or + * stdout is not a TTY) + */ +export async function promptForAccount( + accounts: readonly AccountSummary[], + message: string, + command: string, +): Promise { + if (!(interactivePromptsAllowed() && isatty(0) && isatty(1))) { + throw new ContextError( + "Account", + `${command} `, + accounts.map(describeAccount), + ); + } + const { selectWithFilter } = await import("./init/ui/ink-ui.js"); + const selected = await selectWithFilter({ + message, + options: accounts.map((a) => ({ + label: formatAccountLabel(a), + value: a.id, + hint: a.active ? "active" : a.usable ? undefined : "logged out", + })), + initialValue: accounts.find((a) => a.active)?.id, + }); + return selected === null + ? undefined + : accounts.find((a) => a.id === selected); +} + +/** + * Point the persisted default URL (`defaults.url`) at an account's server + * so later commands route to it: self-hosted hosts are stored, SaaS clears + * the default. Explicit `SENTRY_HOST`/`SENTRY_URL` still take precedence. + */ +export function pointDefaultUrlAtAccount(host: string | undefined): void { + if (!host) { + return; + } + if (isSaaSTrustOrigin(host)) { + if (getDefaultUrl()) { + setDefaultUrl(null); + } + return; + } + setDefaultUrl(host); +} diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts new file mode 100644 index 000000000..2c42d01af --- /dev/null +++ b/packages/cli/src/lib/db/accounts.ts @@ -0,0 +1,297 @@ +/** + * Stored Sentry accounts (multi-account storage). + * + * Each `sentry auth login` stores one row in the `accounts` table. Exactly + * one row is active; credential reads in `auth.ts` target it. This module + * owns raw row access and display helpers. Mutations that change which + * credential is active go through `auth.ts` so its memoized state resets. + */ + +import { createHash } from "node:crypto"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { uuidv7 } from "uuidv7"; +import { fuzzyMatch } from "../fuzzy.js"; +import { normalizeHttpOrigin } from "../sentry-urls.js"; +import { getDatabase } from "./index.js"; + +/** Raw `accounts` row. */ +export type AccountRow = { + account_id: string; + host: string | null; + user_id: string | null; + email: string | null; + username: string | null; + name: string | null; + token: string | null; + refresh_token: string | null; + expires_at: number | null; + issued_at: number | null; + is_active: number; + created_at: number; + updated_at: number; + last_used_at: number; +}; + +/** Public, secret-free view of a stored account. */ +export type AccountSummary = { + id: string; + active: boolean; + host: string | undefined; + userId: string | undefined; + email: string | undefined; + username: string | undefined; + name: string | undefined; + /** Whether the stored credential can still be used (or refreshed). */ + usable: boolean; + expiresAt: number | undefined; +}; + +/** Read the active account row. Returns `undefined` when none is active. */ +export function getActiveAccountRow(): AccountRow | undefined { + const db = getDatabase(); + return db + .query( + "SELECT * FROM accounts WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1", + ) + .get() as AccountRow | undefined; +} + +/** Read one account row by id. */ +export function getAccountRow(accountId: string): AccountRow | undefined { + const db = getDatabase(); + return db + .query("SELECT * FROM accounts WHERE account_id = ?") + .get(accountId) as AccountRow | undefined; +} + +/** List all stored account rows, oldest login first. */ +export function listAccountRows(): AccountRow[] { + const db = getDatabase(); + return db + .query("SELECT * FROM accounts ORDER BY created_at ASC, account_id ASC") + .all() as AccountRow[]; +} + +/** Insert a new account row and make it the only active one. */ +export function insertActiveAccountRow( + values: Pick< + AccountRow, + "host" | "token" | "refresh_token" | "expires_at" | "issued_at" + >, + now: number, +): string { + const db = getDatabase(); + const accountId = uuidv7(); + db.transaction(() => { + db.query("UPDATE accounts SET is_active = 0 WHERE is_active = 1").run(); + db.query( + `INSERT INTO accounts (account_id, host, token, refresh_token, expires_at, + issued_at, is_active, created_at, updated_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + ).run( + accountId, + values.host, + values.token, + values.refresh_token, + values.expires_at, + values.issued_at, + now, + now, + now, + ); + })(); + return accountId; +} + +/** + * Mark one account active and every other account inactive, recording when + * it was last used. `updated_at` is left alone: it versions the stored + * credential for refresh compare-and-swap. + */ +export function activateAccountRow(accountId: string): void { + const db = getDatabase(); + db.transaction(() => { + db.query("UPDATE accounts SET is_active = 0 WHERE is_active = 1").run(); + db.query( + "UPDATE accounts SET is_active = 1, last_used_at = ? WHERE account_id = ?", + ).run(Date.now(), accountId); + })(); +} + +/** + * Stable SHA-256 namespace for high-entropy OAuth and organization tokens. + * A collision would mix credentials' cached responses, so retain the full + * digest. This fingerprint is never a password verifier or a bearer token. + */ +export function hashIdentity(kind: string, secret: string): string { + return createHash("sha256") + .update(kind) + .update("\0") + .update(secret) + .digest("hex"); +} + +/** Cache namespace for a stored account (matches `getIdentityFingerprint`). */ +export function accountIdentity(row: AccountRow): string | undefined { + if (row.refresh_token) { + return hashIdentity("oauth", row.refresh_token); + } + if (row.token) { + return hashIdentity("oauth-access", row.token); + } + return; +} + +/** True when the row's credential is usable now or can be refreshed. */ +export function isAccountRowUsable(row: AccountRow): boolean { + if (!row.token) { + return false; + } + return !row.expires_at || Date.now() <= row.expires_at || !!row.refresh_token; +} + +/** + * Delete account rows plus their identity-scoped region cache entries. + * Callers decide whether a new active account is needed afterwards. + */ +export function deleteAccountRows(rows: readonly AccountRow[]): void { + if (rows.length === 0) { + return; + } + const db = getDatabase(); + db.transaction(() => { + for (const row of rows) { + const identity = accountIdentity(row); + if (identity) { + db.query("DELETE FROM org_regions WHERE credential_identity = ?").run( + identity, + ); + } + db.query("DELETE FROM accounts WHERE account_id = ?").run(row.account_id); + } + })(); +} + +/** Convert a row into its secret-free summary. */ +export function toAccountSummary(row: AccountRow): AccountSummary { + return { + id: row.account_id, + active: row.is_active === 1, + host: row.host ?? undefined, + userId: row.user_id ?? undefined, + email: row.email ?? undefined, + username: row.username ?? undefined, + name: row.name ?? undefined, + usable: isAccountRowUsable(row), + expiresAt: row.expires_at ?? undefined, + }; +} + +/** List every stored account without secrets. */ +export function listAccounts(): AccountSummary[] { + return listAccountRows().map(toAccountSummary); +} + +/** + * Find stored accounts matching a user-supplied selector. + * + * Matches, in order of precedence: exact account id, then case-insensitive + * email, username, or user id. A selector of the form `@` where + * `` matches the account's hostname narrows by server as well. + * Returns every match so callers can report ambiguity. + */ +export function findAccounts(selector: string): AccountSummary[] { + const accounts = listAccounts(); + const byId = accounts.filter((a) => a.id === selector); + if (byId.length > 0) { + return byId; + } + const needle = selector.trim().toLowerCase(); + const direct = accounts.filter((a) => matchesIdentity(a, needle)); + if (direct.length > 0) { + return direct; + } + const at = needle.lastIndexOf("@"); + if (at <= 0) { + return []; + } + const who = needle.slice(0, at); + const host = needle.slice(at + 1); + return accounts.filter( + (a) => matchesIdentity(a, who) && hostnameOf(a.host) === host, + ); +} + +/** + * Stored accounts whose email, username, or name resembles `selector`, + * ranked by the shared {@link fuzzyMatch} matcher (prefix, substring, then + * small typos). Used when {@link findAccounts} has no exact match, to + * suggest — or, for non-destructive commands, pick — the intended account. + */ +export function findSimilarAccounts( + selector: string, + maxResults = 5, +): AccountSummary[] { + const needle = selector.trim(); + if (!needle) { + return []; + } + const byValue = new Map(); + for (const account of listAccounts()) { + const host = hostnameOf(account.host); + const values = [ + account.email, + account.username, + account.name, + account.email && host ? `${account.email}@${host}` : undefined, + ]; + for (const value of values) { + if (value) { + byValue.set(value, [...(byValue.get(value) ?? []), account]); + } + } + } + const ranked: AccountSummary[] = []; + for (const value of fuzzyMatch(needle, [...byValue.keys()])) { + for (const account of byValue.get(value) ?? []) { + if (!ranked.some((a) => a.id === account.id)) { + ranked.push(account); + } + } + } + return ranked.slice(0, maxResults); +} + +function matchesIdentity(account: AccountSummary, needle: string): boolean { + return [account.email, account.username, account.userId].some( + (value) => value?.toLowerCase() === needle, + ); +} + +/** + * Whether an account's stored host is the given server: the same origin, + * or both SaaS (`*.sentry.io`). `false` for an account without a host. + */ +export function isAccountOnServer( + account: Pick, + url: string, +): boolean { + const host = account.host ? normalizeHttpOrigin(account.host) : undefined; + const target = normalizeHttpOrigin(url); + if (!(host && target)) { + return false; + } + return ( + host === target || (isSaaSTrustOrigin(host) && isSaaSTrustOrigin(target)) + ); +} + +/** Hostname of a stored account origin, lowercased. */ +export function hostnameOf(host: string | undefined): string | undefined { + if (!host) { + return; + } + return URL.canParse(host) + ? new URL(host).hostname.toLowerCase() + : host.toLowerCase(); +} diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 65af35268..1f428ee2f 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -1,8 +1,12 @@ /** - * Authentication credential storage (single-row table pattern). + * Authentication credential storage. + * + * Credentials live in the multi-row `accounts` table (see `accounts.ts`). + * Every read and refresh here targets the single active account, so the + * rest of the CLI keeps a one-credential view while users can store and + * switch between several logins. */ -import { createHash } from "node:crypto"; import { normalizeAuthToken, trimAuthToken } from "../auth-header.js"; import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js"; import { getEnv } from "../env.js"; @@ -14,10 +18,20 @@ import { ConfigError } from "../errors.js"; import { logger } from "../logger.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { withDbSpan } from "../telemetry.js"; +import { + type AccountRow, + accountIdentity, + activateAccountRow, + deleteAccountRows, + getAccountRow, + getActiveAccountRow, + hashIdentity, + insertActiveAccountRow, + listAccountRows, +} from "./accounts.js"; import { getDatabase } from "./index.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; -import { runUpsert } from "./utils.js"; /** Refresh when less than 10% of token lifetime remains */ export const REFRESH_THRESHOLD = 0.1; @@ -25,28 +39,17 @@ export const REFRESH_THRESHOLD = 0.1; /** Default token lifetime (1 hour) for tokens without issuedAt */ export const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000; -type AuthRow = { - token: string | null; - refresh_token: string | null; - expires_at: number | null; - issued_at: number | null; - updated_at: number; - /** - * Origin URL the token was issued against (e.g., `https://sentry.io` or - * `https://sentry.example.com`). NULL for rows written before schema v16; - * lazily migrated by `migrateNullHostIfPresent` on first access. - */ - host: string | null; -}; +/** + * The active account row. `host` is NULL only for rows migrated from the + * pre-v16 `auth` table; `migrateNullHost` backfills it on access. + */ +type AuthRow = AccountRow; const log = logger.withTag("auth"); -/** Read the single auth row. Returns `undefined` when no row exists. */ +/** Read the active account row. Returns `undefined` when none is active. */ function getAuthRow(): AuthRow | undefined { - const db = getDatabase(); - return db.query("SELECT * FROM auth WHERE id = 1").get() as - | AuthRow - | undefined; + return getActiveAccountRow(); } /** @@ -70,7 +73,10 @@ function migrateNullHost(row: AuthRow): string { try { withDbSpan("migrateAuthHost", () => { const db = getDatabase(); - db.query("UPDATE auth SET host = ? WHERE id = 1").run(host); + db.query("UPDATE accounts SET host = ? WHERE account_id = ?").run( + host, + row.account_id, + ); }); log.info(`Migrated stored credentials to host-scoped model: ${host}`); } catch { @@ -82,6 +88,19 @@ function migrateNullHost(row: AuthRow): string { return host; } +/** + * Backfill the active account's legacy NULL host (see `migrateNullHost`). + * Account mutations call this around activation so no stored account is + * left without a host, and `auth switch`/`logout` can point the default + * URL at a concrete server instead of keeping a previous account's. + */ +function backfillActiveNullHost(): void { + const row = getAuthRow(); + if (row && !row.host) { + migrateNullHost(row); + } +} + function normalizeCredentialHost(host: string): string { const origin = normalizeHttpOrigin(host); if (!origin) { @@ -393,97 +412,220 @@ export function resetHasStoredCredsCache(): void { * an update (e.g., access-token refresh), the existing row's host is * preserved. When omitted on a fresh write, defaults to the * currently-configured host (`SENTRY_HOST`/`SENTRY_URL`) or `DEFAULT_SENTRY_URL`. + * @property newAccount - Store the token as a new account and make it + * active instead of overwriting the active account's credentials. Login + * flows set this so earlier logins stay available to `sentry auth switch`. + * Recording user info afterwards ({@link setUserInfo}) folds a re-login to + * the same user on the same host into one account. */ export type SetAuthTokenOptions = { host?: string; + newAccount?: boolean; }; -/** Normalize an access token before storage; malformed input leaves the auth row unchanged. */ +/** Drop all memoized active-credential state after an account mutation. */ +function resetActiveCredentialState(): void { + resetIdentityFingerprintCache(); + refreshIdentityAliases.clear(); + resetAuthTokenCache(); + resetAuthRowCache(); + resetHasStoredCredsCache(); +} + +/** + * Normalize an access token before storage; malformed input leaves the + * stored accounts unchanged. Returns the id of the account written. + */ export function setAuthToken( token: string, expiresIn?: number, newRefreshToken?: string, options?: SetAuthTokenOptions, -): void { +): string { const normalizedToken = normalizeAuthToken(token); - withDbSpan("setAuthToken", () => { + const accountId = withDbSpan("setAuthToken", () => { const db = getDatabase(); const now = Date.now(); const expiresAt = expiresIn ? now + expiresIn * 1000 : null; const issuedAt = expiresIn ? now : null; + const existing = options?.newAccount ? undefined : getAuthRow(); // Host resolution precedence: // 1. Explicit `options.host` (login command, tests) // 2. Existing row's `host` (refresh flow preserves the original scope) // 3. Currently-configured host (getConfiguredSentryUrl) // 4. SaaS default // Always normalized to scheme+host[+port]. - const existingHost = ( - db.query("SELECT host FROM auth WHERE id = 1").get() as - | { host: string | null } - | undefined - )?.host; const rawHost = options?.host ?? - existingHost ?? + existing?.host ?? getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL; const host = normalizeCredentialHost(rawHost); - runUpsert( - db, - "auth", - { - id: 1, - token: normalizedToken, - refresh_token: newRefreshToken ?? null, - expires_at: expiresAt, - issued_at: issuedAt, - updated_at: now, - host, - }, - ["id"], + const values = { + host, + token: normalizedToken, + refresh_token: newRefreshToken ?? null, + expires_at: expiresAt, + issued_at: issuedAt, + }; + if (!existing) { + // A new account is a new identity: drop caches scoped to the + // previous account's permissions but not keyed by it. + backfillActiveNullHost(); + clearUnscopedIdentityCaches(); + return insertActiveAccountRow(values, now); + } + db.query( + "UPDATE accounts SET host = ?, token = ?, refresh_token = ?, expires_at = ?, issued_at = ?, updated_at = ? WHERE account_id = ?", + ).run( + values.host, + values.token, + values.refresh_token, + values.expires_at, + values.issued_at, + now, + existing.account_id, ); + return existing.account_id; }); - // Auth row changed — drop memoized fingerprint, token, row, and + // Active credential changed — drop memoized fingerprint, token, row, and // stored-credentials flag so the next read reflects the new row. - resetIdentityFingerprintCache(); - refreshIdentityAliases.clear(); - resetAuthTokenCache(); - resetAuthRowCache(); - resetHasStoredCredsCache(); + resetActiveCredentialState(); + return accountId; } -export async function clearAuth(): Promise { - withDbSpan("clearAuth", () => { - const db = getDatabase(); - db.query("DELETE FROM auth WHERE id = 1").run(); - // Also clear user info, org region cache, pagination cursors, and the - // issue-id → org cache (scoped to the current user's permissions) when - // logging out. - db.query("DELETE FROM user_info WHERE id = 1").run(); - db.query("DELETE FROM org_regions").run(); - db.query("DELETE FROM pagination_cursors").run(); - clearAllIssueOrgCache(); - }); - resetIdentityFingerprintCache(); - refreshIdentityAliases.clear(); - resetAuthTokenCache(); - resetAuthRowCache(); - resetHasStoredCredsCache(); - // Evict in-process trust extensions tied to the now-cleared identity. +/** + * Clear caches whose entries are scoped to one identity's permissions but + * not keyed by it (pagination cursors, issue-id → org mappings), plus the + * in-process trust extension. + */ +function clearUnscopedIdentityCaches(): void { + const db = getDatabase(); + db.query("DELETE FROM pagination_cursors").run(); + clearAllIssueOrgCache(); clearTrustedHostState(); +} +async function clearResponseCacheBestEffort(): Promise { // Dynamic import avoids the auth→response-cache→auth cycle. - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { const { clearResponseCache } = await import("../response-cache.js"); await clearResponseCache(); - } catch { + } catch (error) { // Non-fatal: cache directory may not exist yet + log.debug("Could not clear response cache", error); } } +/** Log out of every stored account and clear identity-scoped caches. */ +export async function clearAuth(): Promise { + withDbSpan("clearAuth", () => { + const db = getDatabase(); + db.query("DELETE FROM accounts").run(); + // Also clear the org region cache, pagination cursors, and the + // issue-id → org cache (scoped to the logged-out users' permissions). + db.query("DELETE FROM org_regions").run(); + clearUnscopedIdentityCaches(); + }); + resetActiveCredentialState(); + await clearResponseCacheBestEffort(); +} + +/** The active stored account's id, or `undefined` when none is active. */ +export function getActiveAccountId(): string | undefined { + return getAuthRow()?.account_id; +} + +/** + * Make a stored account the active one. Subsequent commands use its + * credentials. No-op when it is already active. + * + * @throws {ConfigError} when no stored account has this id + */ +export function setActiveAccount(accountId: string): void { + withDbSpan("setActiveAccount", () => { + if (!getAccountRow(accountId)) { + throw new ConfigError(`No stored account with id '${accountId}'.`); + } + if (getAuthRow()?.account_id === accountId) { + return; + } + backfillActiveNullHost(); + activateAccountRow(accountId); + backfillActiveNullHost(); + clearUnscopedIdentityCaches(); + }); + resetActiveCredentialState(); +} + +/** + * Remove one stored account. Removing the active account leaves no account + * active unless `promoteNext` is set, in which case the most recently + * used remaining account becomes active. + * + * @returns the id of the account that is active afterwards, if any + */ +export async function removeAccount( + accountId: string, + options: { promoteNext?: boolean } = {}, +): Promise { + const nextActive = withDbSpan("removeAccount", () => { + const row = getAccountRow(accountId); + if (!row) { + return getAuthRow()?.account_id; + } + deleteAccountRows([row]); + if (row.is_active !== 1) { + return getAuthRow()?.account_id; + } + clearUnscopedIdentityCaches(); + if (!options.promoteNext) { + return; + } + const [next] = listAccountRows().sort( + (a, b) => b.last_used_at - a.last_used_at || b.updated_at - a.updated_at, + ); + if (next) { + activateAccountRow(next.account_id); + backfillActiveNullHost(); + } + return next?.account_id; + }); + resetActiveCredentialState(); + await clearResponseCacheBestEffort(); + return nextActive; +} + +/** + * Drop an account's rejected or expired credentials while keeping the + * account (and its active state) so the user can see it and log in again. + * Never promotes another account: a request must not silently continue + * under a different identity. + */ +async function invalidateAccountCredentials(accountId: string): Promise { + withDbSpan("invalidateAccountCredentials", () => { + const row = getAccountRow(accountId); + if (!row) { + return; + } + const db = getDatabase(); + const identity = accountIdentity(row); + if (identity) { + db.query("DELETE FROM org_regions WHERE credential_identity = ?").run( + identity, + ); + } + db.query( + "UPDATE accounts SET token = NULL, refresh_token = NULL, expires_at = NULL, issued_at = NULL, updated_at = ? WHERE account_id = ?", + ).run(Date.now(), accountId); + clearUnscopedIdentityCaches(); + }); + resetActiveCredentialState(); + await clearResponseCacheBestEffort(); +} + export function isAuthenticated(): boolean { const token = getAuthToken(); return !!token; @@ -532,18 +674,7 @@ function computeIdentityFingerprint(): string { } } - const row = withDbSpan("getIdentityFingerprint", () => { - const db = getDatabase(); - return db - .query("SELECT token, refresh_token, expires_at FROM auth WHERE id = 1") - .get() as - | { - token: string | null; - refresh_token: string | null; - expires_at: number | null; - } - | undefined; - }); + const row = withDbSpan("getIdentityFingerprint", getAuthRow); // Prefer refresh_token: stable across access-token rotation. if (row?.refresh_token) { return hashIdentity("oauth", row.refresh_token); @@ -560,19 +691,6 @@ function computeIdentityFingerprint(): string { return ANON_IDENTITY; } -/** - * Stable SHA-256 namespace for high-entropy OAuth and organization tokens. - * A collision would mix credentials' cached responses, so retain the full - * digest. This fingerprint is never a password verifier or a bearer token. - */ -function hashIdentity(kind: string, secret: string): string { - return createHash("sha256") - .update(kind) - .update("\0") - .update(secret) - .digest("hex"); -} - /** Immutable token, host, and namespace captured from one auth state. */ export type CredentialContext = Readonly<{ token: string; @@ -687,6 +805,7 @@ export type RefreshTokenResult = { }; type StoredCredentialSnapshot = { + accountId: string; token: string; refreshToken: string; expiresAt: number | null; @@ -745,7 +864,8 @@ function rowMatchesCredential( credential: StoredCredentialSnapshot, ): boolean { return ( - row?.token === credential.token && + row?.account_id === credential.accountId && + row.token === credential.token && row.refresh_token === credential.refreshToken && row.expires_at === credential.expiresAt && row.issued_at === credential.issuedAt && @@ -783,12 +903,16 @@ function persistRefreshedCredential( const saved = withDbSpan("persistRefreshedCredential", () => { const db = getDatabase(); return db.transaction(() => { - if (!rowMatchesCredential(getAuthRow(), credential)) { + // Match by account id (not "active"): a concurrent `auth switch` + // must not redirect this refresh onto another account's row. + if ( + !rowMatchesCredential(getAccountRow(credential.accountId), credential) + ) { return false; } const now = Date.now(); db.query( - "UPDATE auth SET token = ?, refresh_token = ?, expires_at = ?, issued_at = ?, updated_at = ?, host = ? WHERE id = 1", + "UPDATE accounts SET token = ?, refresh_token = ?, expires_at = ?, issued_at = ?, updated_at = ?, host = ? WHERE account_id = ?", ).run( token, nextRefreshToken, @@ -796,6 +920,7 @@ function persistRefreshedCredential( now, now, credential.host, + credential.accountId, ); return true; })(); @@ -824,9 +949,9 @@ async function performTokenRefresh( // Only clear auth on explicit rejection, not network errors if ( error instanceof AuthError && - rowMatchesCredential(getAuthRow(), credential) + rowMatchesCredential(getAccountRow(credential.accountId), credential) ) { - await clearAuth(); + await invalidateAccountCredentials(credential.accountId); } throw error; } @@ -964,7 +1089,7 @@ export async function refreshToken( } if (!row.refresh_token) { - await clearAuth(); + await invalidateAccountCredentials(row.account_id); // Fall back to env token if available (consistent with getAuthToken/getAuthConfig) const fallback = getEnvRefreshResult(options.expectedCredential); if (fallback) { @@ -977,6 +1102,7 @@ export async function refreshToken( } const credential: StoredCredentialSnapshot = { + accountId: row.account_id, token: row.token, refreshToken: row.refresh_token, expiresAt: row.expires_at, diff --git a/packages/cli/src/lib/db/migration.ts b/packages/cli/src/lib/db/migration.ts index 55a4dbd25..d57778222 100644 --- a/packages/cli/src/lib/db/migration.ts +++ b/packages/cli/src/lib/db/migration.ts @@ -5,6 +5,7 @@ import { rmSync } from "node:fs"; import { createRequire } from "node:module"; import { join } from "node:path"; +import { uuidv7 } from "uuidv7"; const _require = createRequire(import.meta.url); @@ -170,15 +171,19 @@ export function migrateFromJson(db: Database): void { if (token && oldConfig.auth) { // Direct write (not via setAuthToken) — safe only because migration // runs during DB bootstrap, before getIdentityFingerprint() memoizes. + const now = Date.now(); + db.query("UPDATE accounts SET is_active = 0 WHERE is_active = 1").run(); db.query(` - INSERT OR REPLACE INTO auth (id, token, refresh_token, expires_at, issued_at, updated_at) - VALUES (1, ?, ?, ?, ?, ?) + INSERT INTO accounts (account_id, token, refresh_token, expires_at, issued_at, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?) `).run( + uuidv7(), token, oldConfig.auth.refreshToken ?? null, oldConfig.auth.expiresAt ?? null, oldConfig.auth.issuedAt ?? null, - Date.now(), + now, + now, ); } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 2d2ac5df7..d631d9f8d 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -12,6 +12,7 @@ */ import { createRequire } from "node:module"; +import { uuidv7 } from "uuidv7"; import { getEnv } from "../env.js"; import { stringifyUnknown } from "../errors.js"; import { logger } from "../logger.js"; @@ -19,7 +20,7 @@ import type { Database } from "./sqlite.js"; const _require = createRequire(import.meta.url); -export const CURRENT_SCHEMA_VERSION = 17; +export const CURRENT_SCHEMA_VERSION = 18; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -57,23 +58,50 @@ export const TABLE_SCHEMAS: Record = { version: { type: "INTEGER", primaryKey: true }, }, }, - auth: { + /** + * Stored Sentry accounts (one row per login). Replaces the single-row + * `auth` and `user_info` tables in schema v18. + * + * Exactly one row is active at a time (`is_active = 1`); every + * credential read targets that row. `user_id` is NULL until the login + * flow learns who the token belongs to. Rows sharing `(host, user_id)` + * are deduplicated when user info is recorded, so re-logging in to the + * same account updates it instead of adding a duplicate. + */ + accounts: { columns: { - id: { type: "INTEGER", primaryKey: true, check: "id = 1" }, + account_id: { type: "TEXT", primaryKey: true }, + // Origin URL (scheme://host[:port]) this token was issued against. + // Enforced at the fetch layer: credentials are only attached to requests + // whose origin matches this host (with SaaS equivalence). NULL only for + // rows migrated from pre-v16 `auth` rows; backfilled lazily on read. + host: { type: "TEXT" }, + user_id: { type: "TEXT" }, + email: { type: "TEXT" }, + username: { type: "TEXT" }, + name: { type: "TEXT" }, token: { type: "TEXT" }, refresh_token: { type: "TEXT" }, expires_at: { type: "INTEGER" }, issued_at: { type: "INTEGER" }, + is_active: { type: "INTEGER", notNull: true, default: "0" }, + created_at: { + type: "INTEGER", + notNull: true, + default: "(unixepoch() * 1000)", + }, updated_at: { type: "INTEGER", notNull: true, default: "(unixepoch() * 1000)", }, - // Origin URL (scheme://host[:port]) this token was issued against. - // Enforced at the fetch layer: credentials are only attached to requests - // whose origin matches this host (with SaaS equivalence). Nullable for - // rows created before schema v16; migrated lazily in getAuthConfig. - host: { type: "TEXT", addedInVersion: 16 }, + // When this account last became active (login, switch, or logout + // promotion). Logout promotes the most recently used account. + last_used_at: { + type: "INTEGER", + notNull: true, + default: "(unixepoch() * 1000)", + }, }, }, @@ -179,20 +207,6 @@ export const TABLE_SCHEMAS: Record = { }, compositePrimaryKey: ["credential_identity", "source_origin", "org_slug"], }, - user_info: { - columns: { - id: { type: "INTEGER", primaryKey: true, check: "id = 1" }, - user_id: { type: "TEXT", notNull: true }, - email: { type: "TEXT" }, - username: { type: "TEXT" }, - name: { type: "TEXT", addedInVersion: 3 }, - updated_at: { - type: "INTEGER", - notNull: true, - default: "(unixepoch() * 1000)", - }, - }, - }, instance_info: { columns: { id: { type: "INTEGER", primaryKey: true, check: "id = 1" }, @@ -734,6 +748,81 @@ function getSchemaVersion(db: Database): number { return row?.version ?? 0; } +type LegacyAuthRow = { + token: string | null; + refresh_token: string | null; + expires_at: number | null; + issued_at: number | null; + updated_at: number | null; + host: string | null; +}; + +type LegacyUserRow = { + user_id: string | null; + email: string | null; + username: string | null; + name: string | null; +}; + +/** + * Copy the pre-v18 single-row credential (`auth`) and identity + * (`user_info`) into one active `accounts` row, then drop both tables. + * + * A NULL `host` (rows from before v16) is carried over as-is; the auth + * layer backfills it lazily from the boot-time URL snapshot. + */ +function migrateSingleAuthToAccounts(db: Database): void { + db.exec(EXPECTED_TABLES.accounts as string); + + const auth = tableExists(db, "auth") + ? (db + .query( + `SELECT token, refresh_token, expires_at, issued_at, updated_at, ${ + hasColumn(db, "auth", "host") ? "host" : "NULL AS host" + } FROM auth WHERE id = 1`, + ) + .get() as LegacyAuthRow | undefined) + : undefined; + const user = + auth?.token && tableExists(db, "user_info") + ? (db + .query( + `SELECT user_id, email, username, ${ + hasColumn(db, "user_info", "name") ? "name" : "NULL AS name" + } FROM user_info WHERE id = 1`, + ) + .get() as LegacyUserRow | undefined) + : undefined; + + if (auth?.token) { + const now = Date.now(); + db.query("UPDATE accounts SET is_active = 0").run(); + db.query( + `INSERT INTO accounts (account_id, host, user_id, email, username, name, + token, refresh_token, expires_at, issued_at, is_active, created_at, updated_at, + last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + ).run( + uuidv7(), + auth.host, + user?.user_id ?? null, + user?.email ?? null, + user?.username ?? null, + user?.name ?? null, + auth.token, + auth.refresh_token, + auth.expires_at, + auth.issued_at, + now, + auth.updated_at ?? now, + now, + ); + } + + db.exec("DROP TABLE IF EXISTS auth"); + db.exec("DROP TABLE IF EXISTS user_info"); +} + /** * Run migrations for schema changes between versions. * @@ -747,17 +836,17 @@ function getSchemaVersion(db: Database): number { export function runMigrations(db: Database): void { const currentVersion = getSchemaVersion(db); - // Migration 1 -> 2: Add org_regions, user_info, and instance_info tables + // Migration 1 -> 2: Add org_regions and instance_info tables. The + // `user_info` table this step also created was folded into `accounts` in v18. if (currentVersion < 2) { db.exec(` ${EXPECTED_TABLES.org_regions}; - ${EXPECTED_TABLES.user_info}; ${EXPECTED_TABLES.instance_info}; `); } // Migration 2 -> 3: Add name column to user_info table - if (currentVersion < 3) { + if (currentVersion < 3 && tableExists(db, "user_info")) { addColumnIfMissing(db, "user_info", "name", "TEXT"); } @@ -870,7 +959,7 @@ export function runMigrations(db: Database): void { // The column is NULL for existing rows; the first credential read backfills // it from the boot-time explicit host (or SaaS), before later config shims // can change SENTRY_URL. - if (currentVersion < 16) { + if (currentVersion < 16 && tableExists(db, "auth")) { addColumnIfMissing(db, "auth", "host", "TEXT"); } @@ -883,6 +972,12 @@ export function runMigrations(db: Database): void { })(); } + // Migration 17 -> 18: Multi-account support. Fold the single-row `auth` + // and `user_info` tables into `accounts`; the migrated login stays active. + if (currentVersion < 18) { + db.transaction(() => migrateSingleAuthToAccounts(db))(); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION, diff --git a/packages/cli/src/lib/db/user.ts b/packages/cli/src/lib/db/user.ts index 0d646fc83..dad38ddc1 100644 --- a/packages/cli/src/lib/db/user.ts +++ b/packages/cli/src/lib/db/user.ts @@ -1,11 +1,18 @@ /** - * User identity storage for telemetry. + * User identity of the active stored account. * - * Stores user info fetched from Sentry API to set Sentry user context. + * Stores user info fetched from Sentry API on the active `accounts` row, + * used for display and to set Sentry user context. */ +import { withDbSpan } from "../telemetry.js"; +import { + deleteAccountRows, + getActiveAccountRow, + isAccountOnServer, + listAccountRows, +} from "./accounts.js"; import { getDatabase } from "./index.js"; -import { runUpsert } from "./utils.js"; export type UserInfo = { userId: string; @@ -15,24 +22,13 @@ export type UserInfo = { name?: string; }; -type UserRow = { - user_id: string; - email: string | null; - username: string | null; - name: string | null; -}; - /** - * Get stored user info. - * Returns undefined if no user info is stored. + * Get the active account's user info. + * Returns undefined when no account is active or its user is not known yet. */ export function getUserInfo(): UserInfo | undefined { - const db = getDatabase(); - const row = db.query("SELECT * FROM user_info WHERE id = 1").get() as - | UserRow - | undefined; - - if (!row) { + const row = getActiveAccountRow(); + if (!row?.user_id) { return; } @@ -45,24 +41,75 @@ export function getUserInfo(): UserInfo | undefined { } /** - * Store user info. - * Overwrites any existing user info. + * Record who the active account's credentials belong to. + * + * Any other stored account for the same user on the same server is + * removed: logging in again to an existing account replaces its old + * credentials instead of adding a duplicate. "Same server" matches `--url` + * selection, so every `*.sentry.io` region counts as SaaS. No-op when no + * account is active (for example, when only an env-var token is in use). + * + * Leaves `updated_at` alone: it versions the stored credential for refresh + * compare-and-swap, and identity writes must not invalidate an in-flight + * refresh. */ export function setUserInfo(info: UserInfo): void { - const db = getDatabase(); - const now = Date.now(); + withDbSpan("setUserInfo", () => { + const active = getActiveAccountRow(); + if (!active) { + return; + } + const duplicates = listAccountRows().filter( + (row) => + row.account_id !== active.account_id && + row.user_id === info.userId && + isSameAccountServer(row.host, active.host), + ); + // OAuth login responses carry no username. Keep one already known for + // this user — on this row or on a duplicate about to be folded into it — + // so `sentry auth switch ` keeps working after a re-login. + const knownUsername = + (active.user_id === info.userId ? active.username : null) ?? + duplicates.find((row) => row.username)?.username ?? + null; + const db = getDatabase(); + db.query( + "UPDATE accounts SET user_id = ?, email = ?, username = ?, name = ? WHERE account_id = ?", + ).run( + info.userId, + info.email ?? null, + info.username ?? knownUsername, + info.name ?? null, + active.account_id, + ); + deleteAccountRows(duplicates); + }); +} - runUpsert( - db, - "user_info", - { - id: 1, - user_id: info.userId, - email: info.email ?? null, - username: info.username ?? null, - name: info.name ?? null, - updated_at: now, - }, - ["id"], - ); +/** + * Forget who the active account's credentials belong to. Used after its + * credentials were replaced in place without user info for the new ones, + * so list/switch/status never show the previous user against a new token. + * Leaves `updated_at` alone (see {@link setUserInfo}). + */ +export function clearUserInfo(): void { + withDbSpan("clearUserInfo", () => { + const active = getActiveAccountRow(); + if (!active) { + return; + } + getDatabase() + .query( + "UPDATE accounts SET user_id = NULL, email = NULL, username = NULL, name = NULL WHERE account_id = ?", + ) + .run(active.account_id); + }); +} + +/** Same server for duplicate folding; a NULL (pre-v16) host only matches NULL. */ +function isSameAccountServer(a: string | null, b: string | null): boolean { + if (!(a && b)) { + return a === b; + } + return isAccountOnServer({ host: a }, b); } diff --git a/packages/cli/src/lib/formatters/accounts.ts b/packages/cli/src/lib/formatters/accounts.ts new file mode 100644 index 000000000..e6bebf24b --- /dev/null +++ b/packages/cli/src/lib/formatters/accounts.ts @@ -0,0 +1,75 @@ +/** + * Formatters for stored-account commands (`sentry auth list`, `switch`, + * `logout`). + */ + +import { type AccountSummary, hostnameOf } from "../db/accounts.js"; +import { colorTag, escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { formatTable } from "./table.js"; + +/** Short human label for an account, e.g. `Jane `. */ +export function formatAccountIdentity(account: AccountSummary): string { + const displayName = account.name ?? account.username; + if (displayName && account.email) { + return `${displayName} <${account.email}>`; + } + return ( + displayName ?? + account.email ?? + (account.userId ? `user ${account.userId}` : "unknown user") + ); +} + +/** Label including the server, e.g. `jane@acme.com on sentry.io`. */ +export function formatAccountLabel(account: AccountSummary): string { + const host = hostnameOf(account.host); + const who = formatAccountIdentity(account); + return host ? `${who} on ${host}` : who; +} + +function accountStatus(account: AccountSummary): string { + if (!account.usable) { + return colorTag("red", "logged out"); + } + return account.active ? colorTag("green", "active") : ""; +} + +/** Data yielded by `sentry auth list`. */ +export type AccountListResult = { + accounts: AccountSummary[]; +}; + +/** Human output for `sentry auth list`. */ +export function formatAccountList(data: AccountListResult): string { + if (data.accounts.length === 0) { + return renderMarkdown("No stored accounts. Run `sentry auth login`."); + } + return formatTable(data.accounts, [ + { header: "", value: (a) => (a.active ? "*" : ""), minWidth: 1 }, + { + header: "ACCOUNT", + value: (a) => escapeMarkdownInline(formatAccountIdentity(a)), + }, + { + header: "SERVER", + value: (a) => escapeMarkdownInline(hostnameOf(a.host) ?? "unknown"), + }, + { header: "STATUS", value: accountStatus }, + ]); +} + +/** Data yielded by `sentry auth switch`. */ +export type AccountSwitchResult = { + /** False when the selected account was already active. */ + switched: boolean; + account: AccountSummary; +}; + +/** Human output for `sentry auth switch`. */ +export function formatAccountSwitch(data: AccountSwitchResult): string { + const label = escapeMarkdownInline(formatAccountLabel(data.account)); + const line = data.switched + ? `${colorTag("green", "✓")} Switched to ${label}.` + : `Already using ${label}.`; + return renderMarkdown(line); +} diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index f0bdb5ab2..6ddd0a3c9 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -32,6 +32,7 @@ import { resolveOrgDisplayName } from "../api-client.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { withSerializeSpan } from "../telemetry.js"; import { tipsSuppressed } from "../tips.js"; +import { formatAccountLabel } from "./accounts.js"; import { type FixabilityTier, muted } from "./colors.js"; import { colorTag, @@ -1760,10 +1761,29 @@ export function formatLogoutResult(data: LogoutResult): string { return renderMarkdown(data.message ?? "Not currently authenticated."); } const lines: string[] = []; - lines.push(`${colorTag("green", "✓")} Logged out successfully.`); + const removed = data.removed ?? []; + const [only] = removed; + let removedLabel = ""; + if (removed.length > 1) { + removedLabel = data.all + ? ` of all ${removed.length} accounts` + : ` of ${removed.length} accounts (${removed + .map((a) => escapeMarkdownInline(formatAccountLabel(a))) + .join(", ")})`; + } else if (only) { + removedLabel = ` of ${escapeMarkdownInline(formatAccountLabel(only))}`; + } + lines.push( + `${colorTag("green", "✓")} Logged out${removedLabel} successfully.`, + ); if (data.configPath) { lines.push(`Credentials removed from: ${safeCodeSpan(data.configPath)}`); } + if (data.activeAccount) { + lines.push( + `Now using: ${escapeMarkdownInline(formatAccountLabel(data.activeAccount))}`, + ); + } return renderMarkdown(lines.join("\n\n")); } @@ -1807,6 +1827,15 @@ function buildAuthDetailRows(data: AuthStatusData): [string, string][] { if (data.user) { rows.push(["User", formatUserIdentity(data.user)]); } + if (data.host) { + rows.push(["Server", escapeMarkdownInline(data.host)]); + } + if (data.accounts !== undefined && data.accounts > 1) { + rows.push([ + "Accounts", + `${data.accounts} stored (${safeCodeSpan("sentry auth list")})`, + ]); + } if (data.token) { // Stored credentials do not retain an explicit auth method, so managed // expiry or refresh capability identifies OAuth access tokens. diff --git a/packages/cli/src/lib/interactive-login.ts b/packages/cli/src/lib/interactive-login.ts index 8354fd164..fe9d17d70 100644 --- a/packages/cli/src/lib/interactive-login.ts +++ b/packages/cli/src/lib/interactive-login.ts @@ -10,8 +10,8 @@ import * as Sentry from "@sentry/node-core/light"; import { openBrowser } from "./browser.js"; import { setupCopyKeyListener } from "./clipboard.js"; import { getDbPath } from "./db/index.js"; -import { setUserInfo } from "./db/user.js"; -import { formatError } from "./errors.js"; +import { clearUserInfo, setUserInfo } from "./db/user.js"; +import { formatError, ValidationError } from "./errors.js"; import { renderInlineMarkdown } from "./formatters/markdown.js"; import { logger } from "./logger.js"; import { completeOAuthFlow, performDeviceFlow } from "./oauth.js"; @@ -59,8 +59,47 @@ export type InteractiveLoginOptions = { * requests the full default scope set. Build via `resolveOAuthScopeString`. */ scope?: string; + /** + * Store the login as a new account instead of replacing the active + * account's credentials (see {@link completeOAuthFlow}). Default: false. + */ + newAccount?: boolean; + /** + * Only accept a login as this email. Sentry's device flow cannot be told + * which account to authorize (the browser's signed-in user approves), so + * the check happens here: a mismatching token is discarded, not stored. + */ + expectedEmail?: string; }; +/** + * Refuse a device-flow login that was approved as a different user than + * the one the user asked for with `sentry auth login `. + * + * @throws {ValidationError} when the approving user's email differs + */ +export function assertExpectedLoginEmail( + expectedEmail: string | undefined, + actualEmail: string | null | undefined, +): void { + if (!expectedEmail) { + return; + } + if (!actualEmail) { + log.warn( + `Could not confirm the login is for ${expectedEmail}: the server did not report an email.`, + ); + return; + } + if (actualEmail.trim().toLowerCase() !== expectedEmail.trim().toLowerCase()) { + throw new ValidationError( + `Signed in as ${actualEmail}, not ${expectedEmail}. Nothing was saved.\n\n` + + `Sentry authorizes whichever user is signed in to the browser. Sign in as ${expectedEmail} ` + + "there (or use a private window) and run the command again.", + ); + } +} + /** * Build the user-facing display lines for the device flow verification step. * @@ -121,8 +160,13 @@ export async function runInteractiveLogin( ): Promise { const timeout = options?.timeout ?? 900_000; // 15 minutes default const scope = options?.scope; + const expectedEmail = options?.expectedEmail; - log.info("Starting authentication..."); + log.info( + expectedEmail + ? `Starting authentication for ${expectedEmail}...` + : "Starting authentication...", + ); let urlToCopy = ""; // Object wrapper needed for TypeScript control flow analysis with async callbacks @@ -150,6 +194,12 @@ export async function runInteractiveLogin( log.log(qr); } + if (expectedEmail) { + log.info( + `Approve the request in a browser signed in to Sentry as ${expectedEmail}.`, + ); + } + // Show the complete URL (with user_code embedded) and optional copy hint const displayLines = buildDeviceFlowDisplay( userCode, @@ -180,11 +230,15 @@ export async function runInteractiveLogin( // Stop the spinner log.success("Authorization received!"); + const user = tokenResponse.user; + assertExpectedLoginEmail(expectedEmail, user?.email); + // Store the token - await completeOAuthFlow(tokenResponse); + await completeOAuthFlow(tokenResponse, { + newAccount: options?.newAccount, + }); // Store user info from token response for telemetry and display - const user = tokenResponse.user; if (user) { try { setUserInfo({ @@ -196,6 +250,15 @@ export async function runInteractiveLogin( // Report to Sentry but don't block auth - user info is not critical Sentry.captureException(error); } + } else if (!options?.newAccount) { + // The active account's credentials were replaced in place; without + // user info for the new ones, forget the previous user's identity + // rather than showing it against a token that may belong to someone else. + try { + clearUserInfo(); + } catch (error) { + Sentry.captureException(error); + } } const result: LoginResult = { diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 587807e21..0ee77f688 100644 --- a/packages/cli/src/lib/login-host-guard.ts +++ b/packages/cli/src/lib/login-host-guard.ts @@ -55,10 +55,10 @@ export function isLoginHostTrusted(host: string): boolean { * Covers 403 scope-recovery re-auth: the user is already authenticated to * this exact instance, so re-authing to add scopes is safe. `setAuthToken` * always records this, even when default-URL persistence failed. - * - **Persisted default URL** (`defaults.url`) — survives `clearAuth()`, so - * it covers the expired-session path, where both expired branches call - * `clearAuth()` before the `AuthError` reaches the middleware (see db/auth - * `refreshToken`) and the token row is already gone. + * - **Persisted default URL** (`defaults.url`) — survives credential + * invalidation, so it covers the expired-session path, where both expired + * branches drop the active account's token before the `AuthError` reaches + * the middleware (see db/auth `refreshToken`) and no token is left. * * Both are written only by explicit, confirmed user actions — never by the * `.sentryclirc` env shim — and {@link isHostTrusted} requires an exact match diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 98feddb14..db9070cec 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -480,21 +480,36 @@ export async function performDeviceFlow( ); } +/** Where {@link completeOAuthFlow} / {@link setApiToken} store the token. */ +export type StoreTokenOptions = { + /** + * Store the token as a new, active account, keeping earlier logins for + * `sentry auth switch` (a re-login as the same user on the same server is + * folded back into one account once user info is recorded). When false + * (the default), the active account's credentials are replaced — what + * re-authentication (`sentry auth login --force`, `sentry auth refresh`, + * automatic login) expects. + */ + newAccount?: boolean; +}; + /** * Complete the OAuth flow by storing the token in the database. The token * is scoped to {@link getSentryUrl} so the fetch-layer trust check refuses * to attach it to other hosts. * * @param tokenResponse - The token response from performDeviceFlow + * @param options - Whether to add a new account or replace the active one */ export async function completeOAuthFlow( tokenResponse: TokenResponse, + options: StoreTokenOptions = {}, ): Promise { await setAuthToken( tokenResponse.access_token, tokenResponse.expires_in, tokenResponse.refresh_token, - { host: getSentryUrl() }, + { host: getSentryUrl(), newAccount: options.newAccount }, ); } @@ -504,9 +519,16 @@ export async function completeOAuthFlow( * Use this for users who have an existing API token from Sentry settings. * * @param token - The API token to store + * @param options - Whether to add a new account or replace the active one */ -export async function setApiToken(token: string): Promise { - await setAuthToken(token, undefined, undefined, { host: getSentryUrl() }); +export async function setApiToken( + token: string, + options: StoreTokenOptions = {}, +): Promise { + await setAuthToken(token, undefined, undefined, { + host: getSentryUrl(), + newAccount: options.newAccount, + }); } /** Refresh an access token using a refresh token. */ diff --git a/packages/cli/src/lib/sentryclirc-import.ts b/packages/cli/src/lib/sentryclirc-import.ts index 95efa022e..1225ccf74 100644 --- a/packages/cli/src/lib/sentryclirc-import.ts +++ b/packages/cli/src/lib/sentryclirc-import.ts @@ -33,8 +33,10 @@ import { join } from "node:path"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { - clearAuth, + getActiveAccountId, hasStoredAuthCredentials, + removeAccount, + setActiveAccount, setAuthToken, } from "./db/auth.js"; import { @@ -503,18 +505,26 @@ function isAuthFailure(error: unknown): boolean { /** * Validate token against the Sentry API and fetch user info. * - * On auth failure (401): clears the stored token and returns false. + * On auth failure (401): removes the imported account (restoring the + * previously active one) and returns false. * On transient network/permission error (403, 5xx, DNS, timeout): keeps * the token stored but warns the user. */ -async function validateAndFetchUser(result: ImportResult): Promise { +async function validateAndFetchUser( + result: ImportResult, + accountId: string, + previousAccountId: string | undefined, +): Promise { try { const { getUserRegions } = await import("./api-client.js"); await getUserRegions(); result.tokenValid = true; } catch (error) { if (isAuthFailure(error)) { - await clearAuth(); + await removeAccount(accountId); + if (previousAccountId) { + setActiveAccount(previousAccountId); + } result.stored.token = false; result.tokenValid = false; result.warnings.push( @@ -647,10 +657,17 @@ export async function executeImport( if (!authAcquiredDuringPrompt) { const host = effective.url ?? DEFAULT_SENTRY_URL; - setAuthToken(effective.token, undefined, undefined, { host }); + const previousAccountId = getActiveAccountId(); + const accountId = setAuthToken(effective.token, undefined, undefined, { + host, + newAccount: true, + }); result.stored.token = true; - if (validateToken && !(await validateAndFetchUser(result))) { + if ( + validateToken && + !(await validateAndFetchUser(result, accountId, previousAccountId)) + ) { // Mark as declined (not completed) to prevent infinite re-prompting. // Using "declined" because: markImportCompleted + hasStoredAuth()=false // (auth cleared by validation failure) would re-trigger on next run. diff --git a/packages/cli/test/commands/auth/login-force.test.ts b/packages/cli/test/commands/auth/login-force.test.ts new file mode 100644 index 000000000..c1cff1753 --- /dev/null +++ b/packages/cli/test/commands/auth/login-force.test.ts @@ -0,0 +1,223 @@ +/** + * `sentry auth login --force --token` against a real test database: the + * active account is replaced only once the new token checks out. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +const { loginCommand } = await import("../../../src/commands/auth/login.js"); + +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { + getActiveAccountId, + getAuthToken, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { getUserInfo, setUserInfo } from "../../../src/lib/db/user.js"; +import { ApiError, AuthError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +type LoginFunc = ( + this: unknown, + flags: { token?: string; force: boolean; timeout: number; url?: string }, + email?: string, +) => Promise; + +useTestConfigDir("auth-login-force-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +const SAAS = "https://sentry.io"; + +function createContext() { + return { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }; +} + +function addAccount( + token: string, + userId: string, + email: string, + username?: string, +): string { + const id = setAuthToken(token, undefined, undefined, { + host: SAAS, + newAccount: true, + }); + setUserInfo({ userId, email, username }); + return id; +} + +describe("login --force --token", () => { + let func: LoginFunc; + + beforeEach(async () => { + vi.spyOn(apiClient, "listOrganizationsUncached").mockResolvedValue([]); + func = (await loginCommand.loader()) as unknown as LoginFunc; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("a rejected token keeps the active account and its credentials", async () => { + const other = addAccount("other-token", "1", "other@example.com"); + const active = addAccount("old-token", "2", "jane@example.com"); + vi.spyOn(apiClient, "getUserRegions").mockRejectedValue( + new ApiError("Unauthorized", 401), + ); + + await expect( + func.call(createContext(), { + token: "bad-token", + force: true, + timeout: 900, + }), + ).rejects.toBeInstanceOf(AuthError); + + expect(getActiveAccountId()).toBe(active); + expect(getAuthToken()).toBe("old-token"); + expect(getUserInfo()?.email).toBe("jane@example.com"); + expect(listAccounts().map((a) => a.id)).toEqual([other, active]); + }); + + test("a valid token replaces the active account with the new identity", async () => { + const other = addAccount("other-token", "1", "other@example.com"); + addAccount("old-token", "2", "jane@example.com"); + vi.spyOn(apiClient, "getUserRegions").mockResolvedValue([]); + vi.spyOn(apiClient, "getCurrentUser").mockResolvedValue({ + id: "3", + email: "bob@example.com", + }); + + await func.call(createContext(), { + token: "new-token", + force: true, + timeout: 900, + }); + + expect(getAuthToken()).toBe("new-token"); + expect(getUserInfo()?.email).toBe("bob@example.com"); + expect(listAccounts().map((a) => [a.id === other, a.email])).toEqual([ + [true, "other@example.com"], + [false, "bob@example.com"], + ]); + }); + + test("a transient validation failure keeps the previous account active", async () => { + const active = addAccount("old-token", "2", "jane@example.com"); + const cause = new ApiError("Server error", 503); + vi.spyOn(apiClient, "getUserRegions").mockRejectedValue(cause); + + // `--force`, and adding an account by naming it (no re-auth prompt). + for (const [force, email] of [ + [true, undefined], + [false, "bob@example.com"], + ] as const) { + await expect( + func.call( + createContext(), + { token: "maybe-good", force, timeout: 900 }, + email, + ), + ).rejects.toBe(cause); + + expect(getActiveAccountId()).toBe(active); + expect(getAuthToken()).toBe("old-token"); + expect(listAccounts().map((a) => a.id)).toEqual([active]); + } + }); + + test("a transient validation failure with no other account keeps the token", async () => { + const cause = new ApiError("Server error", 503); + vi.spyOn(apiClient, "getUserRegions").mockRejectedValue(cause); + + await expect( + func.call(createContext(), { + token: "maybe-good", + force: true, + timeout: 900, + }), + ).rejects.toBe(cause); + + expect(getAuthToken()).toBe("maybe-good"); + }); + + test("a transient failure keeps the token when the previous account can't authenticate", async () => { + const stale = addAccount("old-token", "2", "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(stale); + const cause = new ApiError("Server error", 503); + vi.spyOn(apiClient, "getUserRegions").mockRejectedValue(cause); + + await expect( + func.call(createContext(), { + token: "maybe-good", + force: true, + timeout: 900, + }), + ).rejects.toBe(cause); + + expect(getAuthToken()).toBe("maybe-good"); + }); + + test("re-login as the same user keeps the known username", async () => { + addAccount("old-token", "2", "jane@example.com", "jane"); + vi.spyOn(apiClient, "getUserRegions").mockResolvedValue([]); + vi.spyOn(apiClient, "getCurrentUser").mockResolvedValue({ + id: "2", + email: "jane@example.com", + }); + + await func.call(createContext(), { + token: "new-token", + force: true, + timeout: 900, + }); + + expect(getAuthToken()).toBe("new-token"); + expect(listAccounts().map((a) => a.username)).toEqual(["jane"]); + }); + + test("never shows the previous user when the new user can't be fetched", async () => { + addAccount("old-token", "2", "jane@example.com"); + vi.spyOn(apiClient, "getUserRegions").mockResolvedValue([]); + vi.spyOn(apiClient, "getCurrentUser").mockRejectedValue( + new Error("network down"), + ); + + await func.call(createContext(), { + token: "new-token", + force: true, + timeout: 900, + }); + + expect(getAuthToken()).toBe("new-token"); + expect(getUserInfo()).toBeUndefined(); + expect(listAccounts()).toHaveLength(1); + }); +}); diff --git a/packages/cli/test/commands/auth/login.test.ts b/packages/cli/test/commands/auth/login.test.ts index 763892749..5733ab681 100644 --- a/packages/cli/test/commands/auth/login.test.ts +++ b/packages/cli/test/commands/auth/login.test.ts @@ -86,6 +86,22 @@ vi.mock("../../../src/lib/logger.js", () => ({ const { loginCommand, rcTokenHint } = await import("../../../src/commands/auth/login.js"); +vi.mock("../../../src/lib/account-selection.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/account-selection.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as accountSelection from "../../../src/lib/account-selection.js"; + vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { const actual = await importOriginal(); @@ -162,7 +178,11 @@ type LoginFlags = { }; /** Command function type extracted from loader result */ -type LoginFunc = (this: unknown, flags: LoginFlags) => Promise; +type LoginFunc = ( + this: unknown, + flags: LoginFlags, + email?: string, +) => Promise; const SAMPLE_USER = { id: "42", @@ -203,6 +223,7 @@ function createContext() { function expectTokenStored( spy: ReturnType, expectedToken: string, + newAccount = true, ): void { // shared helper expect(spy).toHaveBeenCalled(); @@ -211,6 +232,7 @@ function expectTokenStored( // shared helper expect(spy.mock.calls[0]?.[3]).toMatchObject({ host: expect.any(String), + newAccount, }); } @@ -222,6 +244,7 @@ describe("loginCommand.func --token path", () => { let setAuthTokenSpy: ReturnType; let getUserRegionsSpy: ReturnType; let clearAuthSpy: ReturnType; + let removeAccountSpy: ReturnType; let getCurrentUserSpy: ReturnType; let setUserInfoSpy: ReturnType; let runInteractiveLoginSpy: ReturnType; @@ -235,6 +258,8 @@ describe("loginCommand.func --token path", () => { setAuthTokenSpy = vi.spyOn(dbAuth, "setAuthToken"); getUserRegionsSpy = vi.spyOn(apiClient, "getUserRegions"); clearAuthSpy = vi.spyOn(dbAuth, "clearAuth"); + removeAccountSpy = vi.spyOn(dbAuth, "removeAccount"); + removeAccountSpy.mockResolvedValue(undefined); getCurrentUserSpy = vi.spyOn(apiClient, "getCurrentUser"); setUserInfoSpy = vi.spyOn(dbUser, "setUserInfo"); runInteractiveLoginSpy = vi.spyOn(interactiveLogin, "runInteractiveLogin"); @@ -255,6 +280,7 @@ describe("loginCommand.func --token path", () => { setAuthTokenSpy.mockRestore(); getUserRegionsSpy.mockRestore(); clearAuthSpy.mockRestore(); + removeAccountSpy.mockRestore(); getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); runInteractiveLoginSpy.mockRestore(); @@ -272,6 +298,137 @@ describe("loginCommand.func --token path", () => { expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); + test("already authenticated (non-TTY) with an email: adds that account without prompting", async () => { + isAuthenticatedSpy.mockReturnValue(true); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/fake", + }); + + const { context } = createContext(); + await func.call( + context, + { force: false, timeout: 900 }, + "jane@example.com", + ); + + expect(runInteractiveLoginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + newAccount: true, + expectedEmail: "jane@example.com", + }), + ); + }); + + test("--force: replaces the active account instead of adding one", async () => { + isAuthenticatedSpy.mockReturnValue(true); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/fake", + }); + + const { context } = createContext(); + await func.call(context, { force: true, timeout: 900 }); + + expect(runInteractiveLoginSpy).toHaveBeenCalledWith( + expect.objectContaining({ newAccount: false }), + ); + }); + + test("--token with an email for someone else: removes the stored token and fails", async () => { + isAuthenticatedSpy.mockReturnValue(false); + setAuthTokenSpy.mockReturnValue("new-account"); + getUserRegionsSpy.mockResolvedValue([]); + getCurrentUserSpy.mockResolvedValue({ + ...SAMPLE_USER, + email: "bob@example.com", + }); + + const { context } = createContext(); + await expect( + func.call( + context, + { token: "new-token", force: false, timeout: 900 }, + "jane@example.com", + ), + ).rejects.toBeInstanceOf(ValidationError); + + expect(removeAccountSpy).toHaveBeenCalledWith("new-account"); + expect(setUserInfoSpy).not.toHaveBeenCalled(); + }); + + test("--token --url with an email for someone else: leaves the default URL alone", async () => { + isAuthenticatedSpy.mockReturnValue(true); + vi.spyOn(dbAuth, "getActiveAccountId").mockReturnValue("previous-account"); + setAuthTokenSpy.mockReturnValue("new-account"); + getUserRegionsSpy.mockResolvedValue([]); + getCurrentUserSpy.mockResolvedValue({ + ...SAMPLE_USER, + email: "bob@example.com", + }); + const setActiveAccountSpy = vi + .spyOn(dbAuth, "setActiveAccount") + .mockReturnValue(undefined); + const pointDefaultUrlSpy = vi + .spyOn(accountSelection, "pointDefaultUrlAtAccount") + .mockReturnValue(undefined); + + try { + const { context } = createContext(); + await expect( + func.call( + context, + { + token: "new-token", + force: false, + timeout: 900, + url: "https://other.example.com", + }, + "jane@example.com", + ), + ).rejects.toBeInstanceOf(ValidationError); + + expect(removeAccountSpy).toHaveBeenCalledWith("new-account"); + expect(setActiveAccountSpy).toHaveBeenCalledWith("previous-account"); + expect(pointDefaultUrlSpy).not.toHaveBeenCalled(); + } finally { + setActiveAccountSpy.mockRestore(); + pointDefaultUrlSpy.mockRestore(); + vi.mocked(dbAuth.getActiveAccountId).mockRestore(); + } + }); + + test("--token --url with a matching email: points the default URL at the new host", async () => { + isAuthenticatedSpy.mockReturnValue(false); + setAuthTokenSpy.mockReturnValue("new-account"); + getUserRegionsSpy.mockResolvedValue([]); + getCurrentUserSpy.mockResolvedValue(SAMPLE_USER); + setUserInfoSpy.mockReturnValue(undefined); + const pointDefaultUrlSpy = vi + .spyOn(accountSelection, "pointDefaultUrlAtAccount") + .mockReturnValue(undefined); + + try { + const { context } = createContext(); + await func.call( + context, + { + token: "new-token", + force: false, + timeout: 900, + url: "https://other.example.com", + }, + SAMPLE_USER.email, + ); + + expect(pointDefaultUrlSpy).toHaveBeenCalledWith( + "https://other.example.com", + ); + } finally { + pointDefaultUrlSpy.mockRestore(); + } + }); + test("already authenticated (env token SENTRY_AUTH_TOKEN): warns and proceeds to OAuth login", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); @@ -428,9 +585,9 @@ describe("loginCommand.func --token path", () => { expect(out).toContain("x@y.com"); }); - test("--token: 401 from validation clears auth and throws AuthError", async () => { + test("--token: 401 from validation removes only the new account and throws AuthError", async () => { isAuthenticatedSpy.mockReturnValue(false); - setAuthTokenSpy.mockReturnValue(undefined); + setAuthTokenSpy.mockReturnValue("new-account"); getUserRegionsSpy.mockRejectedValue(new ApiError("Unauthorized", 401)); clearAuthSpy.mockResolvedValue(undefined); @@ -439,7 +596,8 @@ describe("loginCommand.func --token path", () => { func.call(context, { token: "bad-token", force: false, timeout: 900 }), ).rejects.toBeInstanceOf(AuthError); - expect(clearAuthSpy).toHaveBeenCalled(); + expect(removeAccountSpy).toHaveBeenCalledWith("new-account"); + expect(clearAuthSpy).not.toHaveBeenCalled(); expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); @@ -458,6 +616,7 @@ describe("loginCommand.func --token path", () => { ).rejects.toBe(cause); expect(clearAuthSpy).not.toHaveBeenCalled(); + expect(removeAccountSpy).not.toHaveBeenCalled(); expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); @@ -536,7 +695,7 @@ describe("loginCommand.func --token path", () => { expect(getStdout()).toContain("Access token expires in: 1 hour"); }); - test("--force when authenticated: clears auth and proceeds to interactive login", async () => { + test("--force when authenticated: keeps stored accounts and proceeds to interactive login", async () => { isAuthenticatedSpy.mockReturnValue(true); clearAuthSpy.mockResolvedValue(undefined); runInteractiveLoginSpy.mockResolvedValue({ @@ -547,11 +706,11 @@ describe("loginCommand.func --token path", () => { const { context } = createContext(); await func.call(context, { force: true, timeout: 900 }); - expect(clearAuthSpy).toHaveBeenCalled(); + expect(clearAuthSpy).not.toHaveBeenCalled(); expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); - test("--force --token when authenticated: clears auth and proceeds to token login", async () => { + test("--force --token when authenticated: replaces the active account's token", async () => { isAuthenticatedSpy.mockReturnValue(true); clearAuthSpy.mockResolvedValue(undefined); setAuthTokenSpy.mockReturnValue(undefined); @@ -566,8 +725,9 @@ describe("loginCommand.func --token path", () => { timeout: 900, }); - expect(clearAuthSpy).toHaveBeenCalled(); - expectTokenStored(setAuthTokenSpy, "new-token"); + expect(clearAuthSpy).not.toHaveBeenCalled(); + // Validated as its own account first; the previous one is dropped after. + expectTokenStored(setAuthTokenSpy, "new-token", true); expect(getStdout()).toContain("Authenticated"); }); @@ -657,7 +817,7 @@ describe("login re-authentication interactive prompt", () => { const promptMessage = (mockPrompt.mock.calls[0] as unknown as string[])[0]; expect(promptMessage).toContain("Jane Doe"); expect(promptMessage).toContain("jane@example.com"); - expect(promptMessage).toContain("Re-authenticate?"); + expect(promptMessage).toContain("Add another account?"); }); test("shows 'current user' fallback when no cached user info", async () => { @@ -673,7 +833,7 @@ describe("login re-authentication interactive prompt", () => { expect(promptMessage).toContain("current user"); }); - test("confirm: clears auth and proceeds to login", async () => { + test("confirm: keeps stored accounts and proceeds to login", async () => { isAuthenticatedSpy.mockReturnValue(true); getUserInfoSpy.mockReturnValue(undefined); mockPrompt.mockResolvedValue(true); @@ -681,7 +841,7 @@ describe("login re-authentication interactive prompt", () => { const context = createPromptContext(); await func.call(context, { force: false, timeout: 900 }); - expect(clearAuthSpy).toHaveBeenCalled(); + expect(clearAuthSpy).not.toHaveBeenCalled(); expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); @@ -720,11 +880,11 @@ describe("login re-authentication interactive prompt", () => { await func.call(context, { force: true, timeout: 900 }); expect(mockPrompt).not.toHaveBeenCalled(); - expect(clearAuthSpy).toHaveBeenCalled(); + expect(clearAuthSpy).not.toHaveBeenCalled(); expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); - test("confirm + --token: clears auth and re-authenticates with token", async () => { + test("confirm + --token: adds the token as an account", async () => { isAuthenticatedSpy.mockReturnValue(true); getUserInfoSpy.mockReturnValue(undefined); mockPrompt.mockResolvedValue(true); @@ -751,8 +911,8 @@ describe("login re-authentication interactive prompt", () => { timeout: 900, }); - expect(clearAuthSpy).toHaveBeenCalled(); - // Token stored with host scope (4th arg = { host: ... }) + expect(clearAuthSpy).not.toHaveBeenCalled(); + // Token stored as a new account with host scope (4th arg) expectTokenStored(setAuthTokenSpy, "new-token"); } finally { setAuthTokenSpy.mockRestore(); diff --git a/packages/cli/test/commands/auth/logout.test.ts b/packages/cli/test/commands/auth/logout.test.ts index 66559a800..614a2d159 100644 --- a/packages/cli/test/commands/auth/logout.test.ts +++ b/packages/cli/test/commands/auth/logout.test.ts @@ -1,26 +1,39 @@ /** * Logout Command Tests * - * Tests for the logoutCommand func() in src/commands/auth/logout.ts. - * Covers the env-token-aware branches added for headless auth support. + * Tests for the logoutCommand func() in src/commands/auth/logout.ts against a + * real test database: single-account logout, multi-account logout (active, + * by selector, --all), and the env-token-aware branches. * - * Now that logout uses CommandOutput, success output goes to stdout via the - * rendering pipeline, and error cases throw typed errors (AuthError). + * Success output goes to stdout via the rendering pipeline; error cases throw + * typed errors (AuthError, ResolutionError). */ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { logoutCommand } from "../../../src/commands/auth/logout.js"; -// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking -import * as dbAuth from "../../../src/lib/db/auth.js"; -// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking -import * as dbIndex from "../../../src/lib/db/index.js"; -import { AuthError } from "../../../src/lib/errors.js"; +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { getAuthToken, setAuthToken } from "../../../src/lib/db/auth.js"; +import { getDefaultUrl, setDefaultUrl } from "../../../src/lib/db/defaults.js"; +import { getDbPath } from "../../../src/lib/db/index.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { AuthError, ResolutionError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; type LogoutFunc = ( this: unknown, - flags: Record, + flags: { url?: string }, + ...selectors: string[] ) => Promise; +useTestConfigDir("auth-logout-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + function createContext() { const stdoutChunks: string[] = []; return { @@ -41,123 +54,249 @@ function createContext() { }; } +async function loadFunc(): Promise { + return (await logoutCommand.loader()) as unknown as LogoutFunc; +} + +function addAccount( + token: string, + host: string, + user: { userId: string; email: string }, +): void { + setAuthToken(token, undefined, undefined, { host, newAccount: true }); + setUserInfo(user); +} + describe("logoutCommand.func", () => { - let isAuthenticatedSpy: ReturnType; - let isEnvTokenActiveSpy: ReturnType; - let getAuthConfigSpy: ReturnType; - let clearAuthSpy: ReturnType; - let getDbPathSpy: ReturnType; - let func: LogoutFunc; - - beforeEach(async () => { - isAuthenticatedSpy = vi.spyOn(dbAuth, "isAuthenticated"); - isEnvTokenActiveSpy = vi.spyOn(dbAuth, "isEnvTokenActive"); - getAuthConfigSpy = vi.spyOn(dbAuth, "getAuthConfig"); - clearAuthSpy = vi.spyOn(dbAuth, "clearAuth"); - getDbPathSpy = vi.spyOn(dbIndex, "getDbPath"); - - clearAuthSpy.mockResolvedValue(undefined); - getDbPathSpy.mockReturnValue("/fake/db/path"); - - func = (await logoutCommand.loader()) as unknown as LogoutFunc; + test("not authenticated: returns loggedOut false with message", async () => { + const func = await loadFunc(); + const { context, getOutput } = createContext(); + + await func.call(context, {}); + + expect(getOutput()).toContain("Not currently authenticated"); }); - afterEach(() => { - isAuthenticatedSpy.mockRestore(); - isEnvTokenActiveSpy.mockRestore(); - getAuthConfigSpy.mockRestore(); - clearAuthSpy.mockRestore(); - getDbPathSpy.mockRestore(); + test("single stored account: removes it and reports the config path", async () => { + addAccount("only-token", "https://sentry.io", { + userId: "1", + email: "me@example.com", + }); + const func = await loadFunc(); + const { context, getOutput } = createContext(); + + await func.call(context, {}); + + expect(listAccounts()).toEqual([]); + expect(getAuthToken()).toBeUndefined(); + expect(getOutput()).toContain("Logged out of me@example.com"); + expect(getOutput()).toContain(getDbPath()); }); - test("not authenticated: returns loggedOut false with message", async () => { - isAuthenticatedSpy.mockReturnValue(false); + test("env token only (SENTRY_AUTH_TOKEN): throws AuthError with env var message", async () => { + process.env.SENTRY_AUTH_TOKEN = "sntrys_env_123"; + const func = await loadFunc(); + const { context } = createContext(); + + const error = await func.call(context, {}).catch((e) => e); + + expect(error).toBeInstanceOf(AuthError); + const msg = (error as AuthError).message; + expect(msg).toContain("SENTRY_AUTH_TOKEN"); + expect(msg).toContain("environment variable"); + expect(msg).toContain("Unset"); + }); + + test("env token only (SENTRY_TOKEN): shows correct env var name in error", async () => { + process.env.SENTRY_TOKEN = "sntrys_token_456"; + const func = await loadFunc(); + const { context } = createContext(); + + const error = await func.call(context, {}).catch((e) => e); + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).message).toContain("SENTRY_TOKEN"); + }); + + test("stored OAuth with env var set: removes stored account (env var still present)", async () => { + process.env.SENTRY_AUTH_TOKEN = "sntrys_env_123"; + addAccount("stored-token", "https://sentry.io", { + userId: "1", + email: "me@example.com", + }); + const func = await loadFunc(); const { context, getOutput } = createContext(); await func.call(context, {}); - expect(clearAuthSpy).not.toHaveBeenCalled(); - expect(getOutput()).toContain("Not currently authenticated"); + expect(listAccounts()).toEqual([]); + expect(getOutput()).toContain("Logged out of"); }); +}); - test("OAuth token: clears auth and writes success to stdout", async () => { - isAuthenticatedSpy.mockReturnValue(true); - getAuthConfigSpy.mockReturnValue({ - token: "sntrys_oauth_123", - source: "oauth", +describe("logoutCommand.func with several accounts", () => { + test("logs out of the active account and activates the most recent remaining one", async () => { + addAccount("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", }); + addAccount("personal-token", "https://sentry.io", { + userId: "2", + email: "personal@example.com", + }); + const func = await loadFunc(); const { context, getOutput } = createContext(); await func.call(context, {}); - expect(clearAuthSpy).toHaveBeenCalled(); - expect(getOutput()).toContain("Logged out successfully"); - expect(getOutput()).toContain("/fake/db/path"); + const remaining = listAccounts(); + expect(remaining.map((a) => a.email)).toEqual(["work@example.com"]); + expect(remaining[0]?.active).toBe(true); + expect(getAuthToken()).toBe("work-token"); + expect(getOutput()).toContain("Now using: work@example.com on sentry.io"); }); - test("env token (SENTRY_AUTH_TOKEN): throws AuthError with env var message", async () => { - isAuthenticatedSpy.mockReturnValue(true); - isEnvTokenActiveSpy.mockReturnValue(true); - getAuthConfigSpy.mockReturnValue({ - token: "sntrys_env_123", - source: "env:SENTRY_AUTH_TOKEN", + test("logging out of a self-hosted account points the default URL at the next account", async () => { + addAccount("saas-token", "https://sentry.io", { + userId: "1", + email: "saas@example.com", + }); + addAccount("self-token", "https://sentry.example.com", { + userId: "1", + email: "self@example.com", }); + setDefaultUrl("https://sentry.example.com"); + const func = await loadFunc(); const { context } = createContext(); - try { - await func.call(context, {}); - expect.unreachable("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(AuthError); - const msg = (err as AuthError).message; - expect(msg).toContain("SENTRY_AUTH_TOKEN"); - expect(msg).toContain("environment variable"); - expect(msg).toContain("Unset"); - } - expect(clearAuthSpy).not.toHaveBeenCalled(); + await func.call(context, {}); + + expect(getAuthToken()).toBe("saas-token"); + expect(getDefaultUrl()).toBeNull(); }); - test("env token (SENTRY_TOKEN): shows correct env var name in error", async () => { - isAuthenticatedSpy.mockReturnValue(true); - getAuthConfigSpy.mockReturnValue({ - token: "sntrys_token_456", - source: "env:SENTRY_TOKEN", + test("logs out of a named account without changing the active one", async () => { + addAccount("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", }); - // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority - const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; - // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() - process.env.SENTRY_TOKEN = "sntrys_token_456"; + addAccount("personal-token", "https://sentry.io", { + userId: "2", + email: "personal@example.com", + }); + const func = await loadFunc(); const { context } = createContext(); - try { - await func.call(context, {}); - expect.unreachable("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(AuthError); - const msg = (err as AuthError).message; - expect(msg).toContain("SENTRY_TOKEN"); - } finally { - delete process.env.SENTRY_TOKEN; - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } - } - expect(clearAuthSpy).not.toHaveBeenCalled(); + await func.call(context, {}, "work@example.com"); + + expect(listAccounts().map((a) => a.email)).toEqual([ + "personal@example.com", + ]); + expect(getAuthToken()).toBe("personal-token"); }); - test("OAuth source with env var set: clears stored OAuth (env var still present)", async () => { - isAuthenticatedSpy.mockReturnValue(true); - // Effective source is OAuth even though env var is set - getAuthConfigSpy.mockReturnValue({ - token: "sntrys_oauth", - source: "oauth", + test("unknown account selector throws ResolutionError", async () => { + addAccount("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", + }); + const func = await loadFunc(); + const { context } = createContext(); + + await expect( + func.call(context, {}, "nobody@example.com"), + ).rejects.toBeInstanceOf(ResolutionError); + expect(listAccounts()).toHaveLength(1); + }); + + test("'all' removes every stored account", async () => { + addAccount("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", + }); + addAccount("self-token", "https://sentry.example.com", { + userId: "1", + email: "work@example.com", }); + const func = await loadFunc(); const { context, getOutput } = createContext(); - await func.call(context, {}); + await func.call(context, {}, "all"); + + expect(listAccounts()).toEqual([]); + expect(getAuthToken()).toBeUndefined(); + expect(getOutput()).toContain("Logged out of all 2 accounts"); + }); + + test("logs out of several comma- or space-separated accounts", async () => { + addAccount("a-token", "https://sentry.io", { + userId: "1", + email: "a@example.com", + }); + addAccount("b-token", "https://sentry.io", { + userId: "2", + email: "b@example.com", + }); + addAccount("c-token", "https://sentry.io", { + userId: "3", + email: "c@example.com", + }); + const func = await loadFunc(); + const { context, getOutput } = createContext(); + + await func.call(context, {}, "a@example.com,c@example.com"); + + expect(listAccounts().map((a) => a.email)).toEqual(["b@example.com"]); + expect(getAuthToken()).toBe("b-token"); + expect(getOutput()).toContain("Logged out of 2 accounts"); + + addAccount("d-token", "https://sentry.io", { + userId: "4", + email: "d@example.com", + }); + await func.call(context, {}, "b@example.com", "d@example.com"); + expect(listAccounts()).toEqual([]); + }); + + test("--url picks the account on that server when an email is stored twice", async () => { + addAccount("saas-token", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + addAccount("self-token", "https://sentry.example.com", { + userId: "1", + email: "jane@example.com", + }); + const func = await loadFunc(); + const { context } = createContext(); + + await expect( + func.call(context, {}, "jane@example.com"), + ).rejects.toBeInstanceOf(ResolutionError); + + await func.call( + context, + { url: "https://sentry.example.com" }, + "jane@example.com", + ); + + expect(listAccounts().map((a) => a.host)).toEqual(["https://sentry.io"]); + }); + + test("does not log out of a fuzzy match, only suggests it", async () => { + addAccount("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", + }); + const func = await loadFunc(); + const { context } = createContext(); + + const error = await func.call(context, {}, "work").catch((e: unknown) => e); - expect(clearAuthSpy).toHaveBeenCalled(); - expect(getOutput()).toContain("Logged out successfully"); + expect(error).toBeInstanceOf(ResolutionError); + expect(String((error as Error).message)).toContain( + "Did you mean work@example.com", + ); + expect(listAccounts()).toHaveLength(1); }); }); diff --git a/packages/cli/test/commands/auth/switch.test.ts b/packages/cli/test/commands/auth/switch.test.ts new file mode 100644 index 000000000..00b9f228a --- /dev/null +++ b/packages/cli/test/commands/auth/switch.test.ts @@ -0,0 +1,238 @@ +/** + * `sentry auth list` / `sentry auth switch` command tests against a real + * test database. + */ + +import { describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/auth/list.js"; +import { switchCommand } from "../../../src/commands/auth/switch.js"; +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { getAuthToken, setAuthToken } from "../../../src/lib/db/auth.js"; +import { getDefaultUrl, setDefaultUrl } from "../../../src/lib/db/defaults.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { + AuthError, + ContextError, + ResolutionError, +} from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +type ListFunc = (this: unknown, flags: { json?: boolean }) => Promise; +type SwitchFunc = ( + this: unknown, + flags: { json?: boolean }, + selector?: string, +) => Promise; + +useTestConfigDir("auth-switch-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +function createContext() { + const chunks: string[] = []; + return { + context: { + stdout: { write: vi.fn((s: string) => chunks.push(s)) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + getOutput: () => chunks.join(""), + }; +} + +function addAccount( + token: string, + host: string, + user: { userId: string; email: string }, +): string { + const id = setAuthToken(token, undefined, undefined, { + host, + newAccount: true, + }); + setUserInfo(user); + return id; +} + +function seedTwoAccounts(): { saas: string; selfHosted: string } { + const saas = addAccount("saas-token", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + const selfHosted = addAccount("self-token", "https://sentry.example.com", { + userId: "9", + email: "jane@corp.example.com", + }); + setDefaultUrl("https://sentry.example.com"); + return { saas, selfHosted }; +} + +describe("auth list", () => { + test("lists every stored account and marks the active one", async () => { + seedTwoAccounts(); + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }); + + const out = getOutput(); + expect(out).toContain("jane@example.com"); + expect(out).toContain("jane@corp.example.com"); + expect(out).toContain("sentry.example.com"); + expect(out).toContain("active"); + }); + + test("--json outputs account summaries without secrets", async () => { + seedTwoAccounts(); + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: true }); + + const out = getOutput(); + const parsed = JSON.parse(out) as Array>; + expect(parsed.map((a) => [a.email, a.active])).toEqual([ + ["jane@example.com", false], + ["jane@corp.example.com", true], + ]); + expect(out).not.toContain("saas-token"); + expect(out).not.toContain("self-token"); + }); + + test("suggests a plain login, not --force, to add another account", async () => { + addAccount("saas-token", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }); + + expect(getOutput()).toContain( + "Add another account with 'sentry auth login'", + ); + expect(getOutput()).not.toContain("--force"); + }); + + test("shows a login hint when nothing is stored", async () => { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }); + + expect(getOutput()).toContain("No stored accounts"); + }); +}); + +describe("auth switch", () => { + test("switches by email and points the default URL at the account's server", async () => { + seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }, "jane@example.com"); + + expect(getAuthToken()).toBe("saas-token"); + // SaaS clears the self-hosted default so commands route to sentry.io. + expect(getDefaultUrl()).toBeNull(); + expect(getOutput()).toContain("Switched to jane@example.com on sentry.io"); + }); + + test("switching to a self-hosted account stores its URL as the default", async () => { + const { saas, selfHosted } = seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + await func.call(createContext().context, { json: false }, saas); + + await func.call(createContext().context, { json: false }, selfHosted); + + expect(getAuthToken()).toBe("self-token"); + expect(getDefaultUrl()).toBe("https://sentry.example.com"); + }); + + test("reports when the account is already active", async () => { + seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }, "jane@corp.example.com"); + + expect(getOutput()).toContain("Already using"); + expect(getAuthToken()).toBe("self-token"); + }); + + test("unknown account throws ResolutionError and keeps the active account", async () => { + seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + await expect( + func.call(createContext().context, { json: false }, "nobody"), + ).rejects.toBeInstanceOf(ResolutionError); + expect(getAuthToken()).toBe("self-token"); + }); + + test("an ambiguous selector throws ResolutionError listing the candidates", async () => { + addAccount("a", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + addAccount("b", "https://sentry.example.com", { + userId: "1", + email: "jane@example.com", + }); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + const error = await func + .call(createContext().context, { json: false }, "jane@example.com") + .catch((e) => e); + + expect(error).toBeInstanceOf(ResolutionError); + expect(String((error as Error).message)).toContain("sentry.example.com"); + }); + + test("without an argument in a non-interactive shell, asks for an account", async () => { + seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + await expect( + func.call(createContext().context, { json: false }), + ).rejects.toBeInstanceOf(ContextError); + }); + + test("with no stored accounts, throws not-authenticated", async () => { + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + await expect( + func.call(createContext().context, { json: false }, "anyone"), + ).rejects.toBeInstanceOf(AuthError); + expect(listAccounts()).toEqual([]); + }); +}); + +describe("auth switch selection", () => { + test("uses a close match with a warning", async () => { + const { saas } = seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + await func.call(createContext().context, { json: false }, "jane@example"); + + expect(getAuthToken()).toBe("saas-token"); + expect(listAccounts().find((a) => a.active)?.id).toBe(saas); + }); + + test("--url without an account switches to the only account on that server", async () => { + seedTwoAccounts(); + const func = (await switchCommand.loader()) as unknown as SwitchFunc; + + await func.call(createContext().context, { + json: false, + url: "https://sentry.io", + } as { json: boolean }); + + expect(getAuthToken()).toBe("saas-token"); + }); +}); diff --git a/packages/cli/test/commands/auth/whoami-identities.test.ts b/packages/cli/test/commands/auth/whoami-identities.test.ts new file mode 100644 index 000000000..ecc9301e0 --- /dev/null +++ b/packages/cli/test/commands/auth/whoami-identities.test.ts @@ -0,0 +1,37 @@ +/** + * `sentry auth whoami` reminder of the other stored accounts. + */ + +import { describe, expect, test } from "vitest"; +import { formatOtherIdentitiesHint } from "../../../src/commands/auth/whoami.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("auth-whoami-identities-"); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); + +function login(token: string, host: string, userId: string, email: string) { + setAuthToken(token, undefined, undefined, { host, newAccount: true }); + setUserInfo({ userId, email }); +} + +describe("formatOtherIdentitiesHint", () => { + test("lists the other stored accounts you could be", () => { + login("work-token", "https://sentry.acme.com", "1", "jane@acme.com"); + login("personal-token", "https://sentry.io", "2", "jane@personal.dev"); + + expect( + formatOtherIdentitiesHint({ id: "2", name: "Jane", email: "x" }), + ).toBe( + "You are Jane, but you can be whoever you want — as long as it's one of: " + + "jane@acme.com on sentry.acme.com. Run 'sentry auth switch'.", + ); + }); + + test("says nothing with a single stored account", () => { + login("work-token", "https://sentry.io", "1", "jane@acme.com"); + + expect(formatOtherIdentitiesHint({ id: "1" })).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/commands/auth/whoami.test.ts b/packages/cli/test/commands/auth/whoami.test.ts index 210e10b92..769838f06 100644 --- a/packages/cli/test/commands/auth/whoami.test.ts +++ b/packages/cli/test/commands/auth/whoami.test.ts @@ -127,6 +127,7 @@ function createContext() { describe("whoamiCommand.func", () => { let isAuthenticatedSpy: ReturnType; let getAuthTokenSpy: ReturnType; + let storedAuthConfigSpy: ReturnType; let getCurrentUserSpy: ReturnType; let setUserInfoSpy: ReturnType; let func: WhoamiFunc; @@ -134,6 +135,10 @@ describe("whoamiCommand.func", () => { beforeEach(async () => { isAuthenticatedSpy = vi.spyOn(dbAuth, "isAuthenticated"); getAuthTokenSpy = vi.spyOn(dbAuth, "getAuthToken"); + // Default credential source: a stored OAuth login. + storedAuthConfigSpy = vi + .spyOn(dbAuth, "getAuthConfig") + .mockReturnValue({ token: OAUTH_TOKEN, source: "oauth" }); getCurrentUserSpy = vi.spyOn(apiClient, "getCurrentUser"); setUserInfoSpy = vi.spyOn(dbUser, "setUserInfo"); // Default token type: OAuth (not org, not PAT). Tests that need a @@ -145,6 +150,7 @@ describe("whoamiCommand.func", () => { afterEach(() => { isAuthenticatedSpy.mockRestore(); getAuthTokenSpy.mockRestore(); + storedAuthConfigSpy.mockRestore(); getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); }); @@ -353,6 +359,21 @@ describe("whoamiCommand.func", () => { }); }); + test("does not write an env token's user onto the stored account", async () => { + isAuthenticatedSpy.mockReturnValue(true); + storedAuthConfigSpy.mockReturnValue({ + token: OAUTH_TOKEN, + source: "env:SENTRY_AUTH_TOKEN", + }); + getCurrentUserSpy.mockResolvedValue(FULL_USER); + + const { context, getOutput } = createContext(); + await func.call(context, { json: false }); + + expect(setUserInfoSpy).not.toHaveBeenCalled(); + expect(getOutput()).toContain("Jane Doe"); + }); + test("still displays identity when DB cache write fails", async () => { isAuthenticatedSpy.mockReturnValue(true); getCurrentUserSpy.mockResolvedValue(FULL_USER); diff --git a/packages/cli/test/commands/cli/fix.test.ts b/packages/cli/test/commands/cli/fix.test.ts index 5f98b6b67..87ece5ce2 100644 --- a/packages/cli/test/commands/cli/fix.test.ts +++ b/packages/cli/test/commands/cli/fix.test.ts @@ -31,7 +31,7 @@ import { useTestConfigDir } from "../../helpers.js"; */ function createPreMigrationDatabase(db: Database): void { // Create all tables, but use pre-migration versions for tables with migrated columns - const preMigrationTables = ["dsn_cache", "user_info"]; + const preMigrationTables = ["dsn_cache"]; const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { diff --git a/packages/cli/test/e2e/auth.test.ts b/packages/cli/test/e2e/auth.test.ts index 040e0cc91..250a02f6c 100644 --- a/packages/cli/test/e2e/auth.test.ts +++ b/packages/cli/test/e2e/auth.test.ts @@ -123,7 +123,7 @@ describe("sentry auth whoami", () => { // Model a pre-validation credential; the setter now rejects this input. const db = new Database(join(ctx.configDir, "cli.db")); try { - db.query("UPDATE auth SET token = ? WHERE id = 1").run(token); + db.query("UPDATE accounts SET token = ? WHERE is_active = 1").run(token); } finally { db.close(); } diff --git a/packages/cli/test/lib/account-selection.test.ts b/packages/cli/test/lib/account-selection.test.ts new file mode 100644 index 000000000..decd65a38 --- /dev/null +++ b/packages/cli/test/lib/account-selection.test.ts @@ -0,0 +1,192 @@ +/** + * Account selector resolution and the interactive account picker used by + * `sentry auth switch` / `logout`. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { selectWithFilter, isatty } = vi.hoisted(() => ({ + selectWithFilter: vi.fn(), + isatty: vi.fn(() => true), +})); +vi.mock("../../src/lib/init/ui/ink-ui.js", () => ({ selectWithFilter })); +vi.mock("node:tty", async (importOriginal) => ({ + ...(await importOriginal()), + isatty, +})); + +import { + promptForAccount, + resolveAccountSelector, +} from "../../src/lib/account-selection.js"; +import { + findSimilarAccounts, + listAccounts, +} from "../../src/lib/db/accounts.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setUserInfo } from "../../src/lib/db/user.js"; +import { ContextError, ResolutionError } from "../../src/lib/errors.js"; +import { setInteractivePromptsAllowed } from "../../src/lib/interactive-prompts.js"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("account-selection-"); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); + +function addAccount( + host: string, + user: { userId: string; email: string; username?: string }, +): string { + const id = setAuthToken( + `token-${user.userId}-${host}`, + undefined, + undefined, + { + host, + newAccount: true, + }, + ); + setUserInfo(user); + return id; +} + +beforeEach(() => { + selectWithFilter.mockReset(); + isatty.mockReturnValue(true); + setInteractivePromptsAllowed(true); +}); + +describe("resolveAccountSelector", () => { + test("uses a sole fuzzy match with fuzzy enabled", () => { + const work = addAccount("https://sentry.io", { + userId: "1", + email: "jane@acme.com", + }); + addAccount("https://sentry.io", { userId: "2", email: "bob@other.dev" }); + + expect( + resolveAccountSelector("jane", "sentry auth switch", { fuzzy: true }).id, + ).toBe(work); + expect( + resolveAccountSelector("jane@acme.co", "sentry auth switch", { + fuzzy: true, + }).id, + ).toBe(work); + }); + + test("only suggests fuzzy matches without fuzzy", () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + + expect(() => resolveAccountSelector("jane", "sentry auth logout")).toThrow( + /Did you mean jane@acme\.com/, + ); + }); + + test("--url disambiguates the same email on several servers", () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + const selfHosted = addAccount("https://sentry.acme.com", { + userId: "1", + email: "jane@acme.com", + }); + + expect(() => + resolveAccountSelector("jane@acme.com", "sentry auth switch"), + ).toThrow(ResolutionError); + expect( + resolveAccountSelector("jane@acme.com", "sentry auth switch", { + url: "https://sentry.acme.com", + }).id, + ).toBe(selfHosted); + }); + + test("treats every sentry.io region as the SaaS server for --url", () => { + const saas = addAccount("https://sentry.io", { + userId: "1", + email: "jane@acme.com", + }); + addAccount("https://sentry.acme.com", { + userId: "1", + email: "jane@acme.com", + }); + + expect( + resolveAccountSelector("jane@acme.com", "sentry auth switch", { + url: "https://us.sentry.io", + }).id, + ).toBe(saas); + }); +}); + +describe("findSimilarAccounts", () => { + test("ranks accounts with the shared fuzzy matcher", () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + addAccount("https://sentry.io", { userId: "2", email: "janet@acme.com" }); + addAccount("https://sentry.io", { userId: "3", email: "bob@other.dev" }); + + expect(findSimilarAccounts("jane").map((a) => a.email)).toEqual([ + "jane@acme.com", + "janet@acme.com", + ]); + expect(findSimilarAccounts("zzz")).toEqual([]); + }); +}); + +describe("promptForAccount", () => { + test("uses the type-to-filter picker with the active account highlighted", async () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + const active = addAccount("https://sentry.acme.com", { + userId: "2", + email: "jane@corp.dev", + }); + selectWithFilter.mockResolvedValue(active); + + const picked = await promptForAccount( + listAccounts(), + "Switch to which account?", + "sentry auth switch", + ); + + expect(picked?.id).toBe(active); + expect(selectWithFilter).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Switch to which account?", + initialValue: active, + options: [ + expect.objectContaining({ label: "jane@acme.com on sentry.io" }), + expect.objectContaining({ + label: "jane@corp.dev on sentry.acme.com", + hint: "active", + }), + ], + }), + ); + }); + + test("returns undefined when the picker is cancelled", async () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + selectWithFilter.mockResolvedValue(null); + + await expect( + promptForAccount(listAccounts(), "Pick", "sentry auth switch"), + ).resolves.toBeUndefined(); + }); + + test("never prompts under --json, even in a terminal", async () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + setInteractivePromptsAllowed(false); + + await expect( + promptForAccount(listAccounts(), "Pick", "sentry auth switch"), + ).rejects.toBeInstanceOf(ContextError); + expect(selectWithFilter).not.toHaveBeenCalled(); + }); + + test("never prompts when stdout is not a terminal", async () => { + addAccount("https://sentry.io", { userId: "1", email: "jane@acme.com" }); + isatty.mockImplementation((fd: number) => fd === 0); + + await expect( + promptForAccount(listAccounts(), "Pick", "sentry auth switch"), + ).rejects.toBeInstanceOf(ContextError); + expect(selectWithFilter).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/db/accounts.test.ts b/packages/cli/test/lib/db/accounts.test.ts new file mode 100644 index 000000000..6eda014a0 --- /dev/null +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -0,0 +1,278 @@ +/** + * Multi-account storage tests. + * + * Covers storing several logins, switching and removing accounts, selector + * resolution, and that credential failures never silently move a request + * onto another stored account. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + findAccounts, + getAccountRow, + getActiveAccountRow, + listAccounts, +} from "../../../src/lib/db/accounts.js"; +import { + getActiveAccountId, + getAuthToken, + getIdentityFingerprint, + refreshToken, + removeAccount, + setActiveAccount, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { + getCachedIssueOrg, + setCachedIssueOrg, +} from "../../../src/lib/db/issue-org-cache.js"; +import { getUserInfo, setUserInfo } from "../../../src/lib/db/user.js"; +import { AuthError, ConfigError } from "../../../src/lib/errors.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as oauth from "../../../src/lib/oauth.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("accounts-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +function login( + token: string, + host: string, + user: { userId: string; email?: string; username?: string }, + refresh?: string, +): string { + const id = setAuthToken(token, refresh ? 3600 : undefined, refresh, { + host, + newAccount: true, + }); + setUserInfo(user); + return id; +} + +describe("storing several accounts", () => { + test("a new login is added alongside the previous one and becomes active", () => { + const work = login("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", + }); + const personal = login("personal-token", "https://sentry.io", { + userId: "2", + email: "personal@example.com", + }); + + expect(listAccounts().map((a) => [a.id, a.active])).toEqual([ + [work, false], + [personal, true], + ]); + expect(getAuthToken()).toBe("personal-token"); + expect(getUserInfo()?.email).toBe("personal@example.com"); + }); + + test("an access-token update without newAccount stays on the active account", () => { + const id = login("first", "https://sentry.example.com", { userId: "1" }); + + expect(setAuthToken("second")).toBe(id); + expect(listAccounts()).toHaveLength(1); + expect(getAuthToken()).toBe("second"); + expect(getActiveAccountRow()?.host).toBe("https://sentry.example.com"); + }); +}); + +describe("setActiveAccount", () => { + test("switches the credential, identity, and user info in use", () => { + const work = login("work-token", "https://sentry.io", { + userId: "1", + email: "work@example.com", + }); + login("personal-token", "https://sentry.io", { + userId: "2", + email: "personal@example.com", + }); + const personalIdentity = getIdentityFingerprint(); + + setActiveAccount(work); + + expect(getActiveAccountId()).toBe(work); + expect(getAuthToken()).toBe("work-token"); + expect(getUserInfo()?.email).toBe("work@example.com"); + expect(getIdentityFingerprint()).not.toBe(personalIdentity); + }); + + test("rejects an unknown account id", () => { + login("token", "https://sentry.io", { userId: "1" }); + expect(() => setActiveAccount("missing")).toThrow(ConfigError); + }); +}); + +describe("removeAccount", () => { + test("removing an inactive account keeps the active one", async () => { + const work = login("work-token", "https://sentry.io", { userId: "1" }); + const personal = login("personal-token", "https://sentry.io", { + userId: "2", + }); + + expect(await removeAccount(work)).toBe(personal); + expect(getAuthToken()).toBe("personal-token"); + }); + + test("removing the active account leaves none active unless promoteNext is set", async () => { + login("work-token", "https://sentry.io", { userId: "1" }); + const personal = login("personal-token", "https://sentry.io", { + userId: "2", + }); + + expect(await removeAccount(personal)).toBeUndefined(); + expect(getAuthToken()).toBeUndefined(); + expect(listAccounts()).toHaveLength(1); + }); + + test("promoteNext activates the most recently updated remaining account", async () => { + const work = login("work-token", "https://sentry.io", { userId: "1" }); + const personal = login("personal-token", "https://sentry.io", { + userId: "2", + }); + + expect(await removeAccount(personal, { promoteNext: true })).toBe(work); + expect(getAuthToken()).toBe("work-token"); + }); + + test("promoteNext follows account switches, not login order", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1000); + const a = login("a-token", "https://sentry.io", { userId: "1" }); + vi.setSystemTime(2000); + const b = login("b-token", "https://sentry.io", { userId: "2" }); + vi.setSystemTime(3000); + login("c-token", "https://sentry.io", { userId: "3" }); + vi.setSystemTime(4000); + setActiveAccount(b); + vi.setSystemTime(5000); + setActiveAccount(a); + + expect(await removeAccount(a, { promoteNext: true })).toBe(b); + expect(getAuthToken()).toBe("b-token"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("account changes clear identity-unscoped caches", () => { + test("a new login drops issue-org mappings from the previous account", () => { + login("work-token", "https://sentry.io", { userId: "1" }); + setCachedIssueOrg("123", "org-foo"); + + login("other-token", "https://sentry.example.com", { userId: "2" }); + + expect(getCachedIssueOrg("123")).toBeUndefined(); + }); +}); + +describe("legacy accounts without a host", () => { + test("switching to one backfills its host before it is used", async () => { + const { resetEnvTokenHostForTesting } = + await import("../../../src/lib/env-token-host.js"); + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + resetEnvTokenHostForTesting(); + getDatabase() + .query( + "INSERT INTO accounts (account_id, token, host, is_active, updated_at) VALUES ('legacy', 'legacy-token', NULL, 0, ?)", + ) + .run(Date.now()); + login("self-hosted-token", "https://sentry.example.com", { userId: "1" }); + + setActiveAccount("legacy"); + + expect(getAccountRow("legacy")?.host).toBe("https://sentry.io"); + }); +}); + +describe("credential failures stay on the failing account", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("an expired token without a refresh token is dropped without switching accounts", async () => { + const work = login("work-token", "https://sentry.io", { userId: "1" }); + const expired = setAuthToken("expired-token", -1, undefined, { + host: "https://sentry.io", + newAccount: true, + }); + setUserInfo({ userId: "2", email: "expired@example.com" }); + + await expect(refreshToken()).rejects.toBeInstanceOf(AuthError); + + expect(getActiveAccountId()).toBe(expired); + expect(getAuthToken()).toBeUndefined(); + const accounts = listAccounts(); + expect(accounts.find((a) => a.id === expired)?.usable).toBe(false); + expect(accounts.find((a) => a.id === work)?.usable).toBe(true); + // The account (and who it belongs to) is kept so it can be re-used. + expect(getUserInfo()?.email).toBe("expired@example.com"); + }); + + test("a rejected refresh invalidates only the refreshing account", async () => { + const work = login("work-token", "https://sentry.io", { userId: "1" }); + const personal = login( + "personal-token", + "https://sentry.io", + { userId: "2" }, + "personal-refresh", + ); + vi.spyOn(oauth, "refreshAccessToken").mockRejectedValue( + new AuthError("invalid", "refresh rejected"), + ); + + await expect(refreshToken({ force: true })).rejects.toBeInstanceOf( + AuthError, + ); + + expect(getActiveAccountId()).toBe(personal); + expect(getAuthToken()).toBeUndefined(); + setActiveAccount(work); + expect(getAuthToken()).toBe("work-token"); + }); +}); + +describe("findAccounts", () => { + test("matches by id, email, username, or user id (case-insensitive)", () => { + const id = login("token", "https://sentry.io", { + userId: "42", + email: "Jane@Example.com", + username: "jane", + }); + + for (const selector of [id, "jane@example.com", "JANE", "42"]) { + expect(findAccounts(selector).map((a) => a.id)).toEqual([id]); + } + expect(findAccounts("nobody")).toEqual([]); + }); + + test("returns every match for an ambiguous selector and narrows with @host", () => { + const saas = login("saas", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + const selfHosted = login("self", "https://sentry.example.com", { + userId: "1", + email: "jane@example.com", + }); + + expect(findAccounts("jane@example.com")).toHaveLength(2); + expect(findAccounts("jane@example.com@sentry.io").map((a) => a.id)).toEqual( + [saas], + ); + expect( + findAccounts("jane@example.com@sentry.example.com").map((a) => a.id), + ).toEqual([selfHosted]); + }); +}); diff --git a/packages/cli/test/lib/db/auth.host.test.ts b/packages/cli/test/lib/db/auth.host.test.ts index 17ad6b67c..5f3562379 100644 --- a/packages/cli/test/lib/db/auth.host.test.ts +++ b/packages/cli/test/lib/db/auth.host.test.ts @@ -86,7 +86,7 @@ describe("db/auth host scoping", () => { // Simulate a pre-v16 row: direct INSERT bypassing setAuthToken const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)", + "INSERT INTO accounts (account_id, token, host, is_active, updated_at) VALUES ('legacy', 'legacy-token', NULL, 1, ?)", ).run(Date.now()); // Simulate the boot ordering: SHELL-exports SENTRY_HOST, then @@ -104,7 +104,9 @@ describe("db/auth host scoping", () => { // Second call reads the now-populated host expect(getStoredAuthHost()).toBe("https://legacy-configured.example.com"); // Verify it was actually written to the DB - const row = db.query("SELECT host FROM auth WHERE id = 1").get() as { + const row = db + .query("SELECT host FROM accounts WHERE is_active = 1") + .get() as { host: string | null; }; expect(row.host).toBe("https://legacy-configured.example.com"); @@ -121,7 +123,7 @@ describe("db/auth host scoping", () => { test("lazy migration: NULL host + no boot-time env falls back to SaaS", async () => { const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)", + "INSERT INTO accounts (account_id, token, host, is_active, updated_at) VALUES ('legacy', 'legacy-token', NULL, 1, ?)", ).run(Date.now()); const { captureEnvTokenHost, resetEnvTokenHostForTesting } = @@ -154,7 +156,7 @@ describe("db/auth host scoping", () => { // boot snapshot so rc writes don't affect it. const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)", + "INSERT INTO accounts (account_id, token, host, is_active, updated_at) VALUES ('legacy', 'legacy-token', NULL, 1, ?)", ).run(Date.now()); const { captureEnvTokenHost, resetEnvTokenHostForTesting } = @@ -203,13 +205,9 @@ describe("db/auth host scoping", () => { test("clearAuth evicts region-URL allow-list but PRESERVES login trust anchor", async () => { // The login anchor is set by `applyLoginUrl` at the start of the - // `auth login` command lifecycle. When the user runs `auth login - // --url ` while already authenticated, the flow is: - // 1. applyLoginUrl — registers the new login trust anchor - // 2. handleExistingAuth — calls clearAuth() if user confirms - // 3. login proceeds — needs the anchor for IAP custom headers - // If clearAuth wiped the anchor at step 2, step 3 would lose it - // and IAP-protected re-authentication would fail. The anchor is + // `auth login` command lifecycle, and the device flow needs it for + // IAP custom headers. Any credential reset that runs mid-login (e.g. + // a rejected `--token` being removed) must not wipe it. The anchor is // process-local and overwritten by the NEXT applyLoginUrl, so we // don't need to clear it on logout. const { isLoginTrustAnchorFor, registerLoginTrustAnchor } = @@ -229,8 +227,8 @@ describe("db/auth host scoping", () => { // Region-URL allow-list cleared (was identity-specific). expect(isTrustedRegionOrigin("https://us.host-a.com")).toBe(false); - // Login anchor PRESERVED (the `auth login --url` flow sets this - // BEFORE clearAuth runs and needs it AFTER for the device flow). + // Login anchor PRESERVED (the `auth login --url` flow sets it up + // front and needs it for the device flow). expect(isLoginTrustAnchorFor("https://sentry.host-a.com")).toBe(true); }); }); diff --git a/packages/cli/test/lib/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts index 708e12fc6..344dde160 100644 --- a/packages/cli/test/lib/db/auth.test.ts +++ b/packages/cli/test/lib/db/auth.test.ts @@ -179,11 +179,13 @@ describe("stored credential validation", () => { "rejects a malformed replacement without changing stored credentials %#", (token) => { setAuthToken("previous-token", 3600, "previous-refresh-token"); - const before = getDatabase().query("SELECT * FROM auth").get(); + const before = getDatabase().query("SELECT * FROM accounts").get(); expect(() => setAuthToken(token, 60, "new-refresh-token")).toThrow( MalformedAuthTokenError, ); - expect(getDatabase().query("SELECT * FROM auth").get()).toEqual(before); + expect(getDatabase().query("SELECT * FROM accounts").get()).toEqual( + before, + ); expect(getAuthToken()).toBe("previous-token"); }, ); @@ -330,7 +332,7 @@ describe("getIdentityFingerprint", () => { const fp = getIdentityFingerprint(); // With no DB row, same env token should produce the same fingerprint. - getDatabase().query("DELETE FROM auth").run(); + getDatabase().query("DELETE FROM accounts").run(); resetIdentityFingerprintCache(); expect(getIdentityFingerprint()).toBe(fp); }); @@ -353,7 +355,9 @@ describe("getAuthToken memoization", () => { // Mutate the DB row directly behind getAuthToken's back — a cached // read must not reflect this change until the cache is invalidated. - getDatabase().query("UPDATE auth SET token = 'mutated' WHERE id = 1").run(); + getDatabase() + .query("UPDATE accounts SET token = 'mutated' WHERE is_active = 1") + .run(); // Still returns the cached value expect(getAuthToken()).toBe("stored_token"); @@ -369,7 +373,9 @@ describe("getAuthToken memoization", () => { // Write a token directly to DB, bypassing setAuthToken. The cached // undefined must persist until invalidated. getDatabase() - .query("INSERT INTO auth (id, token, updated_at) VALUES (1, 'sneaky', ?)") + .query( + "INSERT INTO accounts (account_id, token, is_active, updated_at) VALUES ('sneaky', 'sneaky', 1, ?)", + ) .run(Date.now()); expect(getAuthToken()).toBeUndefined(); @@ -462,7 +468,7 @@ describe("hasStoredAuthCredentials memoization", () => { // (simulates a code path the cache doesn't know about). const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token) VALUES (1, 'sneaky')", + "INSERT INTO accounts (account_id, token, is_active) VALUES ('sneaky', 'sneaky', 1)", ).run(); // Cache still returns stale false diff --git a/packages/cli/test/lib/db/migration.test.ts b/packages/cli/test/lib/db/migration.test.ts index 322af456f..efb9257c4 100644 --- a/packages/cli/test/lib/db/migration.test.ts +++ b/packages/cli/test/lib/db/migration.test.ts @@ -74,7 +74,7 @@ describe("legacy auth migration", () => { // Opening the DB must remain possible so login/logout can recover. const db = getDatabase(); expect(getAuthConfig()).toBeUndefined(); - expect(db.query("SELECT * FROM auth").get()).toBeNull(); + expect(db.query("SELECT * FROM accounts").get()).toBeNull(); expect(getDefaultOrganization()).toBe("synthetic-org"); expect(getDefaultProject()).toBe("synthetic-project"); expect( diff --git a/packages/cli/test/lib/db/schema-v17.focused.test.ts b/packages/cli/test/lib/db/schema-v17.focused.test.ts index 28764bd36..9600de8cf 100644 --- a/packages/cli/test/lib/db/schema-v17.focused.test.ts +++ b/packages/cli/test/lib/db/schema-v17.focused.test.ts @@ -7,7 +7,7 @@ useTestConfigDir("schema-v17-focused-"); describe("schema v17 organization region provenance", () => { test("uses a strict credential, lookup-origin, and slug primary key", () => { - expect(CURRENT_SCHEMA_VERSION).toBe(17); + expect(CURRENT_SCHEMA_VERSION).toBeGreaterThanOrEqual(17); const columns = getDatabase() .query("PRAGMA table_info(org_regions)") .all() as Array<{ @@ -42,7 +42,7 @@ describe("schema v17 organization region provenance", () => { migrated.query("SELECT COUNT(*) AS count FROM org_regions").get(), ).toEqual({ count: 0 }); expect(migrated.query("SELECT version FROM schema_version").get()).toEqual({ - version: 17, + version: CURRENT_SCHEMA_VERSION, }); }); }); diff --git a/packages/cli/test/lib/db/schema-v18.focused.test.ts b/packages/cli/test/lib/db/schema-v18.focused.test.ts new file mode 100644 index 000000000..6b100c97e --- /dev/null +++ b/packages/cli/test/lib/db/schema-v18.focused.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { getAuthConfig } from "../../../src/lib/db/auth.js"; +import { + closeDatabase, + getDatabase, + getRawDatabase, +} from "../../../src/lib/db/index.js"; +import { + CURRENT_SCHEMA_VERSION, + tableExists, +} from "../../../src/lib/db/schema.js"; +import { getUserInfo } from "../../../src/lib/db/user.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("schema-v18-focused-"); + +/** Recreate the v17 single-row auth + user_info tables and reopen the DB. */ +function downgradeToV17(rows: { + auth?: { token: string; refresh: string | null; host: string | null }; + user?: { id: string; email: string | null; name: string | null }; +}): void { + const db = getDatabase(); + db.exec("DROP TABLE IF EXISTS accounts"); + db.exec(`CREATE TABLE auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), token TEXT, refresh_token TEXT, + expires_at INTEGER, issued_at INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), host TEXT + )`); + db.exec(`CREATE TABLE user_info ( + id INTEGER PRIMARY KEY CHECK (id = 1), user_id TEXT NOT NULL, email TEXT, + username TEXT, name TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + )`); + if (rows.auth) { + db.query( + "INSERT INTO auth (id, token, refresh_token, expires_at, issued_at, host) VALUES (1, ?, ?, ?, ?, ?)", + ).run( + rows.auth.token, + rows.auth.refresh, + Date.now() + 3_600_000, + Date.now(), + rows.auth.host, + ); + } + if (rows.user) { + db.query( + "INSERT INTO user_info (id, user_id, email, name) VALUES (1, ?, ?, ?)", + ).run(rows.user.id, rows.user.email, rows.user.name); + } + db.query("UPDATE schema_version SET version = 17").run(); + closeDatabase(); +} + +describe("schema v18 multi-account migration", () => { + test("folds the v17 login and user info into one active account", () => { + downgradeToV17({ + auth: { + token: "legacy-token", + refresh: "legacy-refresh", + host: "https://sentry.example.com", + }, + user: { id: "7", email: "legacy@example.com", name: "Legacy" }, + }); + + const db = getDatabase(); + expect(db.query("SELECT version FROM schema_version").get()).toEqual({ + version: CURRENT_SCHEMA_VERSION, + }); + expect(tableExists(getRawDatabase(), "auth")).toBe(false); + expect(tableExists(getRawDatabase(), "user_info")).toBe(false); + + const [account, ...rest] = listAccounts(); + expect(rest).toEqual([]); + expect(account).toMatchObject({ + active: true, + host: "https://sentry.example.com", + userId: "7", + email: "legacy@example.com", + name: "Legacy", + usable: true, + }); + expect(getAuthConfig()).toMatchObject({ + token: "legacy-token", + refreshToken: "legacy-refresh", + source: "oauth", + }); + expect(getUserInfo()?.userId).toBe("7"); + }); + + test("keeps a pre-v16 NULL host for lazy backfill", () => { + downgradeToV17({ + auth: { token: "legacy-token", refresh: null, host: null }, + }); + + const [account] = listAccounts(); + expect(account?.host).toBeUndefined(); + expect(account?.userId).toBeUndefined(); + }); + + test("drops a user_info row without credentials", () => { + downgradeToV17({ + user: { id: "7", email: "orphan@example.com", name: null }, + }); + + expect(listAccounts()).toEqual([]); + expect(getUserInfo()).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/db/schema.test.ts b/packages/cli/test/lib/db/schema.test.ts index f4463db83..52dd3b4a8 100644 --- a/packages/cli/test/lib/db/schema.test.ts +++ b/packages/cli/test/lib/db/schema.test.ts @@ -223,13 +223,12 @@ describe("EXPECTED_TABLES", () => { test("contains all required tables", () => { const expectedTableNames = [ "schema_version", - "auth", + "accounts", "project_cache", "dsn_cache", "project_aliases", "metadata", "org_regions", - "user_info", "instance_info", "project_root_cache", "pagination_cursors", @@ -254,11 +253,9 @@ describe("EXPECTED_COLUMNS", () => { expect(columnNames).toContain("ttl_expires_at"); }); - test("user_info includes v3 column", () => { - const userInfoColumns = EXPECTED_COLUMNS.user_info; - const columnNames = userInfoColumns?.map((c) => c.name) ?? []; - - expect(columnNames).toContain("name"); + test("legacy single-row auth and user_info tables are no longer expected", () => { + expect(EXPECTED_TABLES).not.toHaveProperty("auth"); + expect(EXPECTED_TABLES).not.toHaveProperty("user_info"); }); }); diff --git a/packages/cli/test/lib/db/user.test.ts b/packages/cli/test/lib/db/user.test.ts index 4a9effa1e..973a68f70 100644 --- a/packages/cli/test/lib/db/user.test.ts +++ b/packages/cli/test/lib/db/user.test.ts @@ -2,8 +2,15 @@ * User Info Storage Tests */ -import { describe, expect, test } from "vitest"; -import { getUserInfo, setUserInfo } from "../../../src/lib/db/user.js"; +import { beforeEach, describe, expect, test } from "vitest"; +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { + clearUserInfo, + getUserInfo, + setUserInfo, +} from "../../../src/lib/db/user.js"; import { useTestConfigDir } from "../../helpers.js"; useTestConfigDir("test-user-"); @@ -14,7 +21,13 @@ describe("getUserInfo", () => { expect(result).toBeUndefined(); }); + test("returns undefined when the active account has no user yet", () => { + setAuthToken("token"); + expect(getUserInfo()).toBeUndefined(); + }); + test("returns stored user info", () => { + setAuthToken("token"); setUserInfo({ userId: "12345", email: "test@example.com", @@ -31,6 +44,7 @@ describe("getUserInfo", () => { }); test("returns stored user info with name", () => { + setAuthToken("token"); setUserInfo({ userId: "12345", email: "test@example.com", @@ -48,6 +62,7 @@ describe("getUserInfo", () => { }); test("handles missing email, username, and name", () => { + setAuthToken("token"); setUserInfo({ userId: "12345" }); const result = getUserInfo(); @@ -61,6 +76,9 @@ describe("getUserInfo", () => { }); describe("setUserInfo", () => { + beforeEach(() => { + setAuthToken("token"); + }); test("stores user info with all fields", () => { setUserInfo({ userId: "user123", @@ -110,3 +128,111 @@ describe("setUserInfo", () => { expect(result?.name).toBe("Display Name"); }); }); + +describe("setUserInfo with several stored accounts", () => { + test("is a no-op when no account is active", () => { + setUserInfo({ userId: "nobody" }); + expect(listAccounts()).toEqual([]); + }); + + test("records user info on the active account only", () => { + setAuthToken("first", undefined, undefined, { + host: "https://sentry.io", + }); + setUserInfo({ userId: "1", email: "one@example.com" }); + setAuthToken("second", undefined, undefined, { + host: "https://sentry.io", + newAccount: true, + }); + setUserInfo({ userId: "2", email: "two@example.com" }); + + expect( + listAccounts().map((a) => ({ email: a.email, active: a.active })), + ).toEqual([ + { email: "one@example.com", active: false }, + { email: "two@example.com", active: true }, + ]); + }); + + test("folds a re-login of the same user on the same host into one account", () => { + setAuthToken("old", undefined, undefined, { host: "https://sentry.io" }); + setUserInfo({ userId: "1", email: "one@example.com" }); + setAuthToken("new", undefined, undefined, { + host: "https://sentry.io", + newAccount: true, + }); + setUserInfo({ userId: "1", email: "one@example.com" }); + + const accounts = listAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.active).toBe(true); + }); + + test("keeps the stored username when an OAuth re-login reports none", () => { + setAuthToken("old", undefined, undefined, { host: "https://sentry.io" }); + setUserInfo({ userId: "1", email: "one@example.com", username: "one" }); + setAuthToken("new", undefined, undefined, { + host: "https://sentry.io", + newAccount: true, + }); + setUserInfo({ userId: "1", email: "one@example.com" }); + + expect(listAccounts().map((a) => a.username)).toEqual(["one"]); + }); + + test("folds a re-login on another SaaS region into one account", () => { + setAuthToken("old", undefined, undefined, { host: "https://sentry.io" }); + setUserInfo({ userId: "1", email: "one@example.com" }); + setAuthToken("new", undefined, undefined, { + host: "https://us.sentry.io", + newAccount: true, + }); + setUserInfo({ userId: "1", email: "one@example.com" }); + + expect(listAccounts().map((a) => a.host)).toEqual(["https://us.sentry.io"]); + }); + + test("keeps the same user id on different hosts as separate accounts", () => { + setAuthToken("saas", undefined, undefined, { host: "https://sentry.io" }); + setUserInfo({ userId: "1" }); + setAuthToken("self-hosted", undefined, undefined, { + host: "https://sentry.example.com", + newAccount: true, + }); + setUserInfo({ userId: "1" }); + + expect(listAccounts().map((a) => a.host)).toEqual([ + "https://sentry.io", + "https://sentry.example.com", + ]); + }); +}); + +describe("identity writes and the refresh version", () => { + function updatedAt(): number | undefined { + const row = getDatabase() + .query("SELECT updated_at FROM accounts WHERE is_active = 1") + .get() as { updated_at: number } | null; + return row?.updated_at; + } + + test("setUserInfo leaves updated_at alone so in-flight refreshes still save", () => { + setAuthToken("token", 3600, "refresh"); + getDatabase().query("UPDATE accounts SET updated_at = 1").run(); + + setUserInfo({ userId: "1", email: "one@example.com" }); + + expect(updatedAt()).toBe(1); + }); + + test("clearUserInfo forgets the identity and leaves updated_at alone", () => { + setAuthToken("token"); + setUserInfo({ userId: "1", email: "one@example.com", username: "one" }); + getDatabase().query("UPDATE accounts SET updated_at = 1").run(); + + clearUserInfo(); + + expect(getUserInfo()).toBeUndefined(); + expect(updatedAt()).toBe(1); + }); +}); diff --git a/packages/cli/test/lib/interactive-login.test.ts b/packages/cli/test/lib/interactive-login.test.ts index bd7668ccd..3bba78321 100644 --- a/packages/cli/test/lib/interactive-login.test.ts +++ b/packages/cli/test/lib/interactive-login.test.ts @@ -194,6 +194,51 @@ describe("runInteractiveLogin", () => { }); }); + test("expectedEmail: refuses a login approved as someone else without storing it", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockResolvedValue( + makeTokenResponse({ + id: "1", + name: "Bob", + email: "bob@example.com", + }), + ); + + const result = await runInteractiveLogin({ + timeout: 1000, + newAccount: true, + expectedEmail: "jane@example.com", + }); + + expect(result).toBeNull(); + expect(completeOAuthFlowSpy).not.toHaveBeenCalled(); + expect(setUserInfoSpy).not.toHaveBeenCalled(); + }); + + test("expectedEmail: stores a matching login (case-insensitive) as requested", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockResolvedValue( + makeTokenResponse({ + id: "1", + name: "Jane", + email: "Jane@Example.com", + }), + ); + + const result = await runInteractiveLogin({ + timeout: 1000, + newAccount: true, + expectedEmail: "jane@example.com", + }); + + expect(result).not.toBeNull(); + expect(completeOAuthFlowSpy).toHaveBeenCalledWith(expect.anything(), { + newAccount: true, + }); + }); + test("null user.email is omitted from result", async () => { performDeviceFlowSpy = vi .spyOn(oauth, "performDeviceFlow") @@ -236,11 +281,32 @@ describe("runInteractiveLogin", () => { return makeTokenResponse(); // no user }); + const clearUserInfoSpy = vi + .spyOn(dbUser, "clearUserInfo") + .mockReturnValue(undefined); + const result = await runInteractiveLogin({ timeout: 1000 }); expect(result).not.toBeNull(); expect(result!.user).toBeUndefined(); expect(setUserInfoSpy).not.toHaveBeenCalled(); + // Credentials were replaced in place: forget the previous identity. + expect(clearUserInfoSpy).toHaveBeenCalled(); + clearUserInfoSpy.mockRestore(); + }); + + test("no user in token response for a new account: nothing to clear", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockResolvedValue(makeTokenResponse()); + const clearUserInfoSpy = vi + .spyOn(dbUser, "clearUserInfo") + .mockReturnValue(undefined); + + await runInteractiveLogin({ timeout: 1000, newAccount: true }); + + expect(clearUserInfoSpy).not.toHaveBeenCalled(); + clearUserInfoSpy.mockRestore(); }); test("forwards an explicit scope string to performDeviceFlow", async () => { diff --git a/packages/cli/test/lib/sentry-client.auth.test.ts b/packages/cli/test/lib/sentry-client.auth.test.ts index 10f248283..3bb87bf9a 100644 --- a/packages/cli/test/lib/sentry-client.auth.test.ts +++ b/packages/cli/test/lib/sentry-client.auth.test.ts @@ -92,7 +92,9 @@ describe("authenticated fetch bearer validation", () => { /** Simulate credentials persisted before setAuthToken validated its input. */ function storeLegacyToken(token: string): void { setAuthToken("legacy-token"); - getDatabase().query("UPDATE auth SET token = ? WHERE id = 1").run(token); + getDatabase() + .query("UPDATE accounts SET token = ? WHERE is_active = 1") + .run(token); } test.each([...ENV_TOKEN_KEYS, "stored"])( diff --git a/packages/cli/test/lib/sentry-client.base-url.test.ts b/packages/cli/test/lib/sentry-client.base-url.test.ts index 3ce7894d8..ef61ab6c9 100644 --- a/packages/cli/test/lib/sentry-client.base-url.test.ts +++ b/packages/cli/test/lib/sentry-client.base-url.test.ts @@ -104,13 +104,16 @@ describe("API base URL for org-auth credentials", () => { process.env.SENTRY_AUTH_TOKEN = orgAuthToken; const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token, refresh_token, host, updated_at) VALUES (1, 'stored-token', 'refresh-token', NULL, ?)", + "INSERT INTO accounts (account_id, token, refresh_token, host, is_active, updated_at) VALUES ('legacy', 'stored-token', 'refresh-token', NULL, 1, ?)", ).run(Date.now()); expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); expect( - (db.query("SELECT host FROM auth WHERE id = 1").get() as { host: string }) - .host, + ( + db.query("SELECT host FROM accounts WHERE is_active = 1").get() as { + host: string; + } + ).host, ).toBe(DEFAULT_SENTRY_URL); }); @@ -131,13 +134,16 @@ describe("API base URL for org-auth credentials", () => { }); const db = getDatabase(); db.query( - "INSERT OR REPLACE INTO auth (id, token, refresh_token, host, updated_at) VALUES (1, 'stored-token', 'refresh-token', NULL, ?)", + "INSERT INTO accounts (account_id, token, refresh_token, host, is_active, updated_at) VALUES ('legacy', 'stored-token', 'refresh-token', NULL, 1, ?)", ).run(Date.now()); expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); expect( - (db.query("SELECT host FROM auth WHERE id = 1").get() as { host: string }) - .host, + ( + db.query("SELECT host FROM accounts WHERE is_active = 1").get() as { + host: string; + } + ).host, ).toBe(DEFAULT_SENTRY_URL); });