From f7af1f312257b7ea9e8d67a084073e8d26f1d4a4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 7 Apr 2026 01:35:00 +0000 Subject: [PATCH] test: minimal auth.ts change for CI debug --- src/commands/auth/login.ts | 13 +++- src/commands/auth/status.ts | 18 +++++ src/lib/api/infrastructure.ts | 24 ++++++ src/lib/db/auth.ts | 137 +++++++++++++++++++++++++++++----- src/lib/formatters/human.ts | 22 ++++++ 5 files changed, 190 insertions(+), 24 deletions(-) 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 */