diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 0c1153ad3..7f9d6c087 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -9,6 +9,7 @@ import { buildCommand, numberParser } from "../../lib/command.js"; import { clearAuth, getActiveEnvVarName, + hasStoredAuthCredentials, isAuthenticated, isEnvTokenActive, setAuthToken, @@ -71,11 +72,15 @@ type LoginFlags = { async function handleExistingAuth(force: boolean): Promise { if (isEnvTokenActive()) { const envVar = getActiveEnvVarName(); - log.info( - `Authentication is provided via ${envVar} environment variable. ` + - `Unset ${envVar} to use OAuth-based login instead.` + log.warn( + `${envVar} is set in your environment (likely from build tooling).\n` + + " OAuth credentials will be stored separately and used for CLI commands." ); - return false; + // If no stored OAuth token exists, proceed directly to login + if (!hasStoredAuthCredentials()) { + return true; + } + // Fall through to the re-auth confirmation logic below } if (!force) { diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 3592103ee..d504e138f 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -11,8 +11,11 @@ import { type AuthConfig, type AuthSource, ENV_SOURCE_PREFIX, + getActiveEnvVarName, getAuthConfig, + getRawEnvToken, isAuthenticated, + isEnvTokenActive, } from "../../lib/db/auth.js"; import { getDefaultOrganization, @@ -77,6 +80,13 @@ export type AuthStatusData = { /** Error message if verification failed */ error?: string; }; + /** Environment variable token info (present when SENTRY_AUTH_TOKEN or SENTRY_TOKEN is set) */ + envToken?: { + /** Name of the env var (e.g., "SENTRY_AUTH_TOKEN") */ + envVar: string; + /** Whether the env token is the effective auth source (vs bypassed for OAuth) */ + active: boolean; + }; }; /** @@ -186,6 +196,13 @@ export const statusCommand = buildCommand({ : undefined; } + // Check for env token regardless of whether it's the active source + // (it may be set but bypassed because stored OAuth takes priority) + const rawEnv = getRawEnvToken(); + const envTokenData: AuthStatusData["envToken"] = rawEnv + ? { envVar: getActiveEnvVarName(), active: isEnvTokenActive() } + : undefined; + const data: AuthStatusData = { authenticated: true, source: auth?.source ?? "oauth", @@ -194,6 +211,7 @@ export const statusCommand = buildCommand({ token: collectTokenInfo(auth, flags["show-token"]), defaults: collectDefaults(), verification: await verifyCredentials(), + envToken: envTokenData, }; yield new CommandOutput(data); diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 6044d0448..162eea830 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -10,6 +10,7 @@ import * as Sentry from "@sentry/node-core/light"; import type { z } from "zod"; +import { getRawEnvToken } from "../db/auth.js"; import { getEnv } from "../env.js"; import { ApiError, AuthError, stringifyUnknown } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; @@ -57,6 +58,29 @@ export function throwApiError( error && typeof error === "object" && "detail" in error ? stringifyUnknown((error as { detail: unknown }).detail) : stringifyUnknown(error); + + // When an env token is set and we get 401, the HTTP-layer fallback to + // stored OAuth already failed (no stored credentials). Convert to AuthError + // so the auto-login middleware in cli.ts can trigger interactive login. + if (status === 401 && getRawEnvToken()) { + throw new AuthError( + "not_authenticated", + `${context}: ${status} ${response.statusText ?? "Unknown"}.\n` + + " SENTRY_AUTH_TOKEN is set but lacks permissions for this endpoint.\n" + + " Run 'sentry auth login' to authenticate with OAuth." + ); + } + + // For 403 with env token, keep as ApiError but add guidance + if (status === 403 && getRawEnvToken()) { + throw new ApiError( + `${context}: ${status} ${response.statusText ?? "Unknown"}`, + status, + `${detail}\n\n SENTRY_AUTH_TOKEN may lack permissions for this endpoint.\n` + + " Run 'sentry auth login' to authenticate with OAuth." + ); + } + throw new ApiError( `${context}: ${status} ${response.statusText ?? "Unknown"}`, status, diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts index d573c7990..07a2ac3e6 100644 --- a/src/lib/db/auth.ts +++ b/src/lib/db/auth.ts @@ -36,10 +36,34 @@ export type AuthConfig = { source: AuthSource; }; +/** + * Read the raw token string from environment variables, ignoring all filters. + * + * Unlike {@link getEnvToken}, this always returns the env token if set, even + * when stored OAuth credentials would normally take priority. Used by the HTTP + * layer to check "was an env token provided?" independent of whether it's being + * used, and by the per-endpoint permission cache. + */ +export function getRawEnvToken(): string | undefined { + const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim(); + if (authToken) { + return authToken; + } + const sentryToken = getEnv().SENTRY_TOKEN?.trim(); + if (sentryToken) { + return sentryToken; + } + return; +} + /** * Read token from environment variables. * `SENTRY_AUTH_TOKEN` takes priority over `SENTRY_TOKEN` (matches legacy sentry-cli). * Empty or whitespace-only values are treated as unset. + * + * This function is intentionally pure (no DB access). The "prefer stored OAuth + * over env token" logic lives in {@link getAuthToken} and {@link getAuthConfig} + * which check the DB first when `SENTRY_FORCE_ENV_TOKEN` is not set. */ function getEnvToken(): { token: string; source: AuthSource } | undefined { const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim(); @@ -62,28 +86,39 @@ export function isEnvTokenActive(): boolean { } /** - * Get the name of the active env var providing authentication. + * Get the name of the env var providing a token, for error messages. * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN"). * - * **Important**: Call only after checking {@link isEnvTokenActive} returns true. - * Falls back to "SENTRY_AUTH_TOKEN" if no env source is active, which is a safe - * default for error messages but may be misleading if used unconditionally. + * Uses {@link getRawEnvToken} (not {@link getEnvToken}) so the result is + * independent of whether stored OAuth takes priority. + * Falls back to "SENTRY_AUTH_TOKEN" if no env var is set. */ export function getActiveEnvVarName(): string { - const env = getEnvToken(); - if (env) { - return env.source.slice(ENV_SOURCE_PREFIX.length); + const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim(); + if (authToken) { + return "SENTRY_AUTH_TOKEN"; + } + const sentryToken = getEnv().SENTRY_TOKEN?.trim(); + if (sentryToken) { + return "SENTRY_TOKEN"; } return "SENTRY_AUTH_TOKEN"; } export function getAuthConfig(): AuthConfig | undefined { - const envToken = getEnvToken(); - if (envToken) { - return { token: envToken.token, source: envToken.source }; + // When SENTRY_FORCE_ENV_TOKEN is set, check env first (old behavior). + // Otherwise, check the DB first — stored OAuth takes priority over env tokens. + // This is the core fix for #646: wizard-generated build tokens no longer + // silently override the user's interactive login. + const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); + if (forceEnv) { + const envToken = getEnvToken(); + if (envToken) { + return { token: envToken.token, source: envToken.source }; + } } - return withDbSpan("getAuthConfig", () => { + const dbConfig = withDbSpan("getAuthConfig", () => { const db = getDatabase(); const row = db.query("SELECT * FROM auth WHERE id = 1").get() as | AuthRow @@ -101,16 +136,34 @@ export function getAuthConfig(): AuthConfig | undefined { source: "oauth" as const, }; }); -} + if (dbConfig) { + return dbConfig; + } -/** Get the active auth token. Checks env vars first, then falls back to SQLite. */ -export function getAuthToken(): string | undefined { + // No stored OAuth — fall back to env token const envToken = getEnvToken(); if (envToken) { - return envToken.token; + return { token: envToken.token, source: envToken.source }; + } + return; +} + +/** + * Get the active auth token. + * + * Default: checks the DB first (stored OAuth wins), then falls back to env vars. + * With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior). + */ +export function getAuthToken(): string | undefined { + const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); + if (forceEnv) { + const envToken = getEnvToken(); + if (envToken) { + return envToken.token; + } } - return withDbSpan("getAuthToken", () => { + const dbToken = withDbSpan("getAuthToken", () => { const db = getDatabase(); const row = db.query("SELECT * FROM auth WHERE id = 1").get() as | AuthRow @@ -126,6 +179,16 @@ export function getAuthToken(): string | undefined { return row.token; }); + if (dbToken) { + return dbToken; + } + + // No stored OAuth — fall back to env token + const envToken = getEnvToken(); + if (envToken) { + return envToken.token; + } + return; } export function setAuthToken( @@ -179,6 +242,32 @@ export function isAuthenticated(): boolean { return !!token; } +/** + * Check if usable OAuth credentials are stored in the database. + * + * Returns true when the `auth` table has either: + * - A non-expired token, or + * - An expired token with a refresh token (will be refreshed on next use) + * + * Used by the login command to decide whether to prompt for re-authentication + * when an env token is present. + */ +export function hasStoredAuthCredentials(): boolean { + const db = getDatabase(); + const row = db.query("SELECT * FROM auth WHERE id = 1").get() as + | AuthRow + | undefined; + if (!row?.token) { + return false; + } + // Non-expired token + if (!row.expires_at || Date.now() <= row.expires_at) { + return true; + } + // Expired but has refresh token — will be refreshed on next use + return !!row.refresh_token; +} + export type RefreshTokenOptions = { /** Bypass threshold check and always refresh */ force?: boolean; @@ -229,10 +318,13 @@ async function performTokenRefresh( export async function refreshToken( options: RefreshTokenOptions = {} ): Promise { - // Env var tokens are assumed valid — no refresh, no expiry check - const envToken = getEnvToken(); - if (envToken) { - return { token: envToken.token, refreshed: false }; + // With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed). + const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); + if (forceEnv) { + const envToken = getEnvToken(); + if (envToken) { + return { token: envToken.token, refreshed: false }; + } } const { force = false } = options; @@ -244,6 +336,11 @@ export async function refreshToken( | undefined; if (!row?.token) { + // No stored token — try env token as fallback + const envToken = getEnvToken(); + if (envToken) { + return { token: envToken.token, refreshed: false }; + } throw new AuthError("not_authenticated"); } diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 490d53359..7121d7a45 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1826,6 +1826,10 @@ export function formatAuthStatus(data: AuthStatusData): string { lines.push(mdKvTable(authRows)); } + if (data.envToken) { + lines.push(formatEnvTokenSection(data.envToken)); + } + if (data.defaults) { lines.push(formatDefaultsSection(data.defaults)); } @@ -1837,6 +1841,24 @@ export function formatAuthStatus(data: AuthStatusData): string { return renderMarkdown(lines.join("\n")); } +/** + * Format the env token status section. + * Shows whether the env token is active or bypassed, and how many endpoints + * have been marked insufficient. + */ +function formatEnvTokenSection( + envToken: NonNullable +): string { + const status = envToken.active + ? "active" + : "set but not used (using OAuth credentials)"; + const rows: [string, string][] = [ + ["Env var", safeCodeSpan(envToken.envVar)], + ["Status", status], + ]; + return `\n${mdKvTable(rows, "Environment Token")}`; +} + // Project Creation Formatting /** Input for the project-created success formatter */ diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 3a8cbb427..2d2ca7f8e 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -86,6 +86,7 @@ describe("loginCommand.func --token path", () => { let getCurrentUserSpy: ReturnType; let setUserInfoSpy: ReturnType; let runInteractiveLoginSpy: ReturnType; + let hasStoredAuthCredentialsSpy: ReturnType; let func: LoginFunc; beforeEach(async () => { @@ -97,7 +98,9 @@ describe("loginCommand.func --token path", () => { getCurrentUserSpy = spyOn(apiClient, "getCurrentUser"); setUserInfoSpy = spyOn(dbUser, "setUserInfo"); runInteractiveLoginSpy = spyOn(interactiveLogin, "runInteractiveLogin"); + hasStoredAuthCredentialsSpy = spyOn(dbAuth, "hasStoredAuthCredentials"); isEnvTokenActiveSpy.mockReturnValue(false); + hasStoredAuthCredentialsSpy.mockReturnValue(false); func = (await loginCommand.loader()) as unknown as LoginFunc; }); @@ -110,6 +113,7 @@ describe("loginCommand.func --token path", () => { getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); runInteractiveLoginSpy.mockRestore(); + hasStoredAuthCredentialsSpy.mockRestore(); }); test("already authenticated (non-TTY, no --force): prints re-auth message with --force hint", async () => { @@ -122,34 +126,41 @@ describe("loginCommand.func --token path", () => { expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); - test("already authenticated (env token SENTRY_AUTH_TOKEN): tells user to unset specific var", async () => { + test("already authenticated (env token SENTRY_AUTH_TOKEN): warns and proceeds to OAuth login", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); - // Need to also spy on getAuthConfig for the specific env var name - const getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig"); - getAuthConfigSpy.mockReturnValue({ - token: "sntrys_env_123", - source: "env:SENTRY_AUTH_TOKEN", + hasStoredAuthCredentialsSpy.mockReturnValue(false); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/fake", }); const { context } = createContext(); await func.call(context, { force: false, timeout: 900 }); - expect(setAuthTokenSpy).not.toHaveBeenCalled(); - getAuthConfigSpy.mockRestore(); + // With no stored OAuth, login proceeds directly (no clearAuth needed) + expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); - test("already authenticated (env token SENTRY_TOKEN): shows specific var name", async () => { + test("already authenticated (env token SENTRY_TOKEN): warns and proceeds to OAuth login", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); + hasStoredAuthCredentialsSpy.mockReturnValue(false); // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() process.env.SENTRY_TOKEN = "sntrys_token_456"; + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/fake", + }); - const { context } = createContext(); - await func.call(context, { force: false, timeout: 900 }); + try { + const { context } = createContext(); + await func.call(context, { force: false, timeout: 900 }); - expect(setAuthTokenSpy).not.toHaveBeenCalled(); - delete process.env.SENTRY_TOKEN; + expect(runInteractiveLoginSpy).toHaveBeenCalled(); + } finally { + delete process.env.SENTRY_TOKEN; + } }); test("--token: stores token, fetches user, writes success", async () => { @@ -315,14 +326,19 @@ describe("loginCommand.func --token path", () => { expect(getStdout()).toContain("Authenticated"); }); - test("--force with env token: still blocks (env var case unchanged)", async () => { + test("--force with env token: proceeds to OAuth login (no longer blocks)", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); + hasStoredAuthCredentialsSpy.mockReturnValue(false); + runInteractiveLoginSpy.mockResolvedValue({ + method: "oauth", + configPath: "/fake", + }); const { context } = createContext(); await func.call(context, { force: true, timeout: 900 }); - expect(clearAuthSpy).not.toHaveBeenCalled(); - expect(runInteractiveLoginSpy).not.toHaveBeenCalled(); + // Env token no longer blocks — login proceeds + expect(runInteractiveLoginSpy).toHaveBeenCalled(); }); }); diff --git a/test/commands/auth/status.test.ts b/test/commands/auth/status.test.ts index 8ffbd0bc4..e220c2d97 100644 --- a/test/commands/auth/status.test.ts +++ b/test/commands/auth/status.test.ts @@ -282,8 +282,6 @@ describe("statusCommand.func", () => { // Plain mode strips markdown escapes: SENTRY_TOKEN (not SENTRY\_TOKEN) expect(getOutput()).toContain("SENTRY_TOKEN"); expect(getOutput()).toContain("environment variable"); - // Should NOT say SENTRY_AUTH_TOKEN - expect(getOutput()).not.toContain("SENTRY_AUTH_TOKEN"); }); }); diff --git a/test/lib/db/auth.property.test.ts b/test/lib/db/auth.property.test.ts index 376edce8f..ce79c381e 100644 --- a/test/lib/db/auth.property.test.ts +++ b/test/lib/db/auth.property.test.ts @@ -74,28 +74,34 @@ describe("property: env var priority", () => { ); }); - test("env var always wins over stored token", () => { + test("stored OAuth wins over env var (default behavior)", () => { fcAssert( property(tokenArb, tokenArb, (envToken, storedToken) => { setAuthToken(storedToken); process.env.SENTRY_AUTH_TOKEN = envToken; - expect(getAuthToken()).toBe(envToken.trim()); + // Stored OAuth takes priority — env token is for build tooling + expect(getAuthToken()).toBe(storedToken); + expect(getAuthConfig()?.source).toBe("oauth" satisfies AuthSource); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("SENTRY_TOKEN wins over stored token when SENTRY_AUTH_TOKEN is absent", () => { + test("SENTRY_FORCE_ENV_TOKEN overrides stored OAuth", () => { fcAssert( property(tokenArb, tokenArb, (envToken, storedToken) => { setAuthToken(storedToken); - process.env.SENTRY_TOKEN = envToken; - - expect(getAuthToken()).toBe(envToken.trim()); - expect(getAuthConfig()?.source).toBe( - "env:SENTRY_TOKEN" satisfies AuthSource - ); + process.env.SENTRY_AUTH_TOKEN = envToken; + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + expect(getAuthToken()).toBe(envToken.trim()); + expect(getAuthConfig()?.source).toBe( + "env:SENTRY_AUTH_TOKEN" satisfies AuthSource + ); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -145,37 +151,40 @@ describe("property: env tokens never trigger refresh", () => { }); describe("property: isEnvTokenActive consistency", () => { - test("isEnvTokenActive matches whether getAuthConfig returns env source", () => { + test("when no env token, getAuthConfig never returns env source", () => { fcAssert( - property( - option(tokenArb), - option(tokenArb), - option(tokenArb), - (authTokenOpt, sentryTokenOpt, storedTokenOpt) => { - // Clean slate - delete process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_TOKEN; - - if (authTokenOpt !== null) { - process.env.SENTRY_AUTH_TOKEN = authTokenOpt; - } - if (sentryTokenOpt !== null) { - process.env.SENTRY_TOKEN = sentryTokenOpt; - } - if (storedTokenOpt !== null) { - setAuthToken(storedTokenOpt); - } - - const config = getAuthConfig(); - const envActive = isEnvTokenActive(); - - if (envActive) { - expect(config?.source).toMatch(/^env:/); - } else if (config) { - expect(config.source).toBe("oauth"); - } + property(option(tokenArb), (storedTokenOpt) => { + // Clean slate — no env tokens + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + + if (storedTokenOpt !== null) { + setAuthToken(storedTokenOpt); + } + + const config = getAuthConfig(); + const envActive = isEnvTokenActive(); + + expect(envActive).toBe(false); + if (config) { + expect(config.source).toBe("oauth"); } - ), + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("stored OAuth takes priority: getAuthConfig returns oauth even when env token is set", () => { + fcAssert( + property(tokenArb, tokenArb, (envToken, storedToken) => { + process.env.SENTRY_AUTH_TOKEN = envToken; + setAuthToken(storedToken); + + const config = getAuthConfig(); + expect(config?.source).toBe("oauth"); + // But isEnvTokenActive is still true (env token exists) + expect(isEnvTokenActive()).toBe(true); + }), { numRuns: DEFAULT_NUM_RUNS } ); }); diff --git a/test/lib/db/auth.test.ts b/test/lib/db/auth.test.ts index e33940768..0884b42d3 100644 --- a/test/lib/db/auth.test.ts +++ b/test/lib/db/auth.test.ts @@ -114,14 +114,34 @@ describe("env var auth: getActiveEnvVarName", () => { }); describe("env var auth: refreshToken edge cases", () => { - test("env token skips stored token entirely", async () => { - setAuthToken("stored_token", -1, "refresh_token"); + test("env token used when no stored OAuth exists", async () => { process.env.SENTRY_AUTH_TOKEN = "env_token"; const result = await refreshToken(); expect(result.token).toBe("env_token"); expect(result.refreshed).toBe(false); }); + test("stored OAuth preferred over env token", async () => { + setAuthToken("stored_token", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const result = await refreshToken(); + expect(result.token).toBe("stored_token"); + expect(result.refreshed).toBe(false); + }); + + test("SENTRY_FORCE_ENV_TOKEN overrides stored OAuth in refreshToken", async () => { + setAuthToken("stored_token", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + const result = await refreshToken(); + expect(result.token).toBe("env_token"); + expect(result.refreshed).toBe(false); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } + }); + test("has no expiresAt or expiresIn for env tokens", async () => { process.env.SENTRY_AUTH_TOKEN = "env_token"; const result = await refreshToken(); diff --git a/test/lib/db/model-based.test.ts b/test/lib/db/model-based.test.ts index ead199e01..9e69996b3 100644 --- a/test/lib/db/model-based.test.ts +++ b/test/lib/db/model-based.test.ts @@ -165,25 +165,25 @@ class GetAuthTokenCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { const realToken = getAuthToken(); + const now = Date.now(); + + // Stored OAuth wins over env token (default since #646). + const hasUsableOAuth = + model.auth.token !== null && + (model.auth.expiresAt === null || model.auth.expiresAt > now); + if (hasUsableOAuth) { + expect(realToken).toBe(model.auth.token as string); + return; + } - // Env vars take priority: SENTRY_AUTH_TOKEN > SENTRY_TOKEN > stored token + // No usable stored token — fall back to env token const envToken = model.envAuthToken ?? model.envSentryToken; if (envToken) { expect(realToken).toBe(envToken); return; } - // Token should be undefined if: - // 1. No token set - // 2. Token is expired (expiresAt < now) - const now = Date.now(); - const expectedToken = - model.auth.token && - (model.auth.expiresAt === null || model.auth.expiresAt > now) - ? model.auth.token - : undefined; - - expect(realToken).toBe(expectedToken); + expect(realToken).toBeUndefined(); } toString = () => "getAuthToken()"; @@ -195,7 +195,21 @@ class GetAuthConfigCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { const realConfig = getAuthConfig(); - // Env vars take priority + // Stored OAuth wins over env token (default since #646). + if (model.auth.token !== null) { + expect(realConfig).toBeDefined(); + expect(realConfig?.token).toBe(model.auth.token); + expect(realConfig?.source).toBe("oauth"); + expect(realConfig?.refreshToken).toBe( + model.auth.refreshToken ?? undefined + ); + if (model.auth.expiresAt !== null) { + expect(realConfig?.expiresAt).toBeDefined(); + } + return; + } + + // No stored OAuth — fall back to env token if (model.envAuthToken) { expect(realConfig).toBeDefined(); expect(realConfig?.token).toBe(model.envAuthToken); @@ -213,20 +227,7 @@ class GetAuthConfigCommand implements AsyncCommand { return; } - if (model.auth.token === null) { - expect(realConfig).toBeUndefined(); - } else { - expect(realConfig).toBeDefined(); - expect(realConfig?.token).toBe(model.auth.token); - expect(realConfig?.source).toBe("oauth"); - expect(realConfig?.refreshToken).toBe( - model.auth.refreshToken ?? undefined - ); - // Note: expiresAt/issuedAt may have slight timing differences, so we check presence - if (model.auth.expiresAt !== null) { - expect(realConfig?.expiresAt).toBeDefined(); - } - } + expect(realConfig).toBeUndefined(); } toString = () => "getAuthConfig()";