22 * Authentication credential storage (single-row table pattern).
33 */
44
5+ import { statSync } from "node:fs" ;
56import { getEnv } from "../env.js" ;
67import { clearResponseCache } from "../response-cache.js" ;
78import { withDbSpan } from "../telemetry.js" ;
8- import { getDatabase } from "./index.js" ;
9+ import { getDatabase , getDbPath } from "./index.js" ;
910import { runUpsert } from "./utils.js" ;
1011
1112/** Refresh when less than 10% of token lifetime remains */
@@ -25,6 +26,21 @@ type AuthRow = {
2526/** Prefix for environment variable auth sources in {@link AuthSource} */
2627export const ENV_SOURCE_PREFIX = "env:" ;
2728
29+ /**
30+ * Quick check: does the CLI database file exist?
31+ * A simple existence check is sufficient — if the DB file exists, the user has
32+ * run at least one CLI command. The actual "has auth row" check is done by
33+ * {@link hasStoredAuthCredentials} which opens and queries the DB.
34+ */
35+ function dbFileExists ( ) : boolean {
36+ try {
37+ statSync ( getDbPath ( ) ) ;
38+ return true ;
39+ } catch {
40+ return false ;
41+ }
42+ }
43+
2844/** Where the auth token originated */
2945export type AuthSource = "env:SENTRY_AUTH_TOKEN" | "env:SENTRY_TOKEN" | "oauth" ;
3046
@@ -36,21 +52,70 @@ export type AuthConfig = {
3652 source : AuthSource ;
3753} ;
3854
55+ /**
56+ * Read the raw token string from environment variables, ignoring all filters.
57+ *
58+ * Unlike {@link getEnvToken}, this always returns the env token if set, even
59+ * when stored OAuth credentials would normally take priority. Used by the HTTP
60+ * layer to check "was an env token provided?" independent of whether it's being
61+ * used, and by the per-endpoint permission cache.
62+ */
63+ export function getRawEnvToken ( ) : string | undefined {
64+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
65+ if ( authToken ) {
66+ return authToken ;
67+ }
68+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
69+ if ( sentryToken ) {
70+ return sentryToken ;
71+ }
72+ return ;
73+ }
74+
3975/**
4076 * Read token from environment variables.
4177 * `SENTRY_AUTH_TOKEN` takes priority over `SENTRY_TOKEN` (matches legacy sentry-cli).
4278 * Empty or whitespace-only values are treated as unset.
79+ *
80+ * **Default behavior**: When the user is logged in (stored OAuth credentials
81+ * exist in the database), env tokens are skipped so OAuth takes priority. This
82+ * prevents wizard-generated build tokens from silently overriding interactive
83+ * CLI credentials. Set `SENTRY_FORCE_ENV_TOKEN=1` to override this and force
84+ * the env token to take priority (e.g., for CI pipelines that intentionally
85+ * use an env token alongside stored credentials).
4386 */
4487function getEnvToken ( ) : { token : string ; source : AuthSource } | undefined {
88+ const raw = getRawEnvToken ( ) ;
89+ if ( ! raw ) {
90+ return ;
91+ }
92+
93+ // When OAuth credentials are stored and env token is not forced, prefer OAuth.
94+ // This is the core fix for #646: wizard-generated tokens no longer silently
95+ // override the user's interactive login.
96+ //
97+ // Uses a lightweight file-size check instead of opening the database, to avoid
98+ // DB initialization as a side effect of reading an env var. A non-empty cli.db
99+ // file (>4KB) is a reliable proxy for "user has interacted with the CLI before"
100+ // because a fresh DB is ~12KB after schema init, and the auth table is populated
101+ // only after login/setAuthToken. False positive (DB exists but no auth row) is
102+ // fine — getAuthToken() will fall through to the DB path and return undefined.
103+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
104+ if ( ! forceEnv && dbFileExists ( ) ) {
105+ try {
106+ if ( hasStoredAuthCredentials ( ) ) {
107+ return ;
108+ }
109+ } catch {
110+ // DB unavailable — fall through to use the env token.
111+ }
112+ }
113+
45114 const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
46115 if ( authToken ) {
47116 return { token : authToken , source : "env:SENTRY_AUTH_TOKEN" } ;
48117 }
49- const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
50- if ( sentryToken ) {
51- return { token : sentryToken , source : "env:SENTRY_TOKEN" } ;
52- }
53- return ;
118+ return { token : raw , source : "env:SENTRY_TOKEN" } ;
54119}
55120
56121/**
@@ -62,17 +127,21 @@ export function isEnvTokenActive(): boolean {
62127}
63128
64129/**
65- * Get the name of the active env var providing authentication .
130+ * Get the name of the env var providing a token, for error messages .
66131 * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN").
67132 *
68- * **Important**: Call only after checking {@link isEnvTokenActive} returns true.
69- * Falls back to "SENTRY_AUTH_TOKEN" if no env source is active, which is a safe
70- * default for error messages but may be misleading if used unconditionally .
133+ * Uses { @link getRawEnvToken} (not {@link getEnvToken}) so the result is
134+ * independent of whether stored OAuth takes priority.
135+ * Falls back to "SENTRY_AUTH_TOKEN" if no env var is set .
71136 */
72137export function getActiveEnvVarName ( ) : string {
73- const env = getEnvToken ( ) ;
74- if ( env ) {
75- return env . source . slice ( ENV_SOURCE_PREFIX . length ) ;
138+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
139+ if ( authToken ) {
140+ return "SENTRY_AUTH_TOKEN" ;
141+ }
142+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
143+ if ( sentryToken ) {
144+ return "SENTRY_TOKEN" ;
76145 }
77146 return "SENTRY_AUTH_TOKEN" ;
78147}
@@ -179,6 +248,33 @@ export function isAuthenticated(): boolean {
179248 return ! ! token ;
180249}
181250
251+ /**
252+ * Check if usable OAuth credentials are stored in the database.
253+ *
254+ * Returns true when the `auth` table has either:
255+ * - A non-expired token, or
256+ * - An expired token with a refresh token (will be refreshed on next use)
257+ *
258+ * This is the gate for the "OAuth-preferred" default: when this returns true,
259+ * {@link getEnvToken} skips the env token so OAuth takes priority. Also used
260+ * by the login command to decide whether to prompt for re-authentication.
261+ */
262+ export function hasStoredAuthCredentials ( ) : boolean {
263+ const db = getDatabase ( ) ;
264+ const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
265+ | AuthRow
266+ | undefined ;
267+ if ( ! row ?. token ) {
268+ return false ;
269+ }
270+ // Non-expired token
271+ if ( ! row . expires_at || Date . now ( ) <= row . expires_at ) {
272+ return true ;
273+ }
274+ // Expired but has refresh token — will be refreshed on next use
275+ return ! ! row . refresh_token ;
276+ }
277+
182278export type RefreshTokenOptions = {
183279 /** Bypass threshold check and always refresh */
184280 force ?: boolean ;
@@ -229,7 +325,7 @@ async function performTokenRefresh(
229325export async function refreshToken (
230326 options : RefreshTokenOptions = { }
231327) : Promise < RefreshTokenResult > {
232- // Env var tokens are assumed valid — no refresh, no expiry check
328+ // Env var tokens are assumed valid — no refresh, no expiry check.
233329 const envToken = getEnvToken ( ) ;
234330 if ( envToken ) {
235331 return { token : envToken . token , refreshed : false } ;
0 commit comments