@@ -25,6 +25,13 @@ type AuthRow = {
2525/** Prefix for environment variable auth sources in {@link AuthSource} */
2626export const ENV_SOURCE_PREFIX = "env:" ;
2727
28+ /**
29+ * Tracks whether the DB has been accessed via getAuthToken/getAuthConfig.
30+ * Used by getEnvToken() to avoid opening the DB as a side effect.
31+ * Object wrapper avoids Biome's noLetDeclaration lint.
32+ */
33+ const _state = { dbAccessed : false } ;
34+
2835/** Where the auth token originated */
2936export type AuthSource = "env:SENTRY_AUTH_TOKEN" | "env:SENTRY_TOKEN" | "oauth" ;
3037
@@ -36,21 +43,71 @@ export type AuthConfig = {
3643 source : AuthSource ;
3744} ;
3845
46+ /**
47+ * Read the raw token string from environment variables, ignoring all filters.
48+ *
49+ * Unlike {@link getEnvToken}, this always returns the env token if set, even
50+ * when stored OAuth credentials would normally take priority. Used by the HTTP
51+ * layer to check "was an env token provided?" independent of whether it's being
52+ * used, and by the per-endpoint permission cache.
53+ */
54+ export function getRawEnvToken ( ) : string | undefined {
55+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
56+ if ( authToken ) {
57+ return authToken ;
58+ }
59+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
60+ if ( sentryToken ) {
61+ return sentryToken ;
62+ }
63+ return ;
64+ }
65+
3966/**
4067 * Read token from environment variables.
4168 * `SENTRY_AUTH_TOKEN` takes priority over `SENTRY_TOKEN` (matches legacy sentry-cli).
4269 * Empty or whitespace-only values are treated as unset.
70+ *
71+ * **Default behavior**: When the user is logged in (stored OAuth credentials
72+ * exist in the database), env tokens are skipped so OAuth takes priority. This
73+ * prevents wizard-generated build tokens from silently overriding interactive
74+ * CLI credentials. Set `SENTRY_FORCE_ENV_TOKEN=1` to override this and force
75+ * the env token to take priority (e.g., for CI pipelines that intentionally
76+ * use an env token alongside stored credentials).
4377 */
4478function getEnvToken ( ) : { token : string ; source : AuthSource } | undefined {
79+ const raw = getRawEnvToken ( ) ;
80+ if ( ! raw ) {
81+ return ;
82+ }
83+
84+ // When OAuth credentials are stored and env token is not forced, prefer OAuth.
85+ // This is the core fix for #646: wizard-generated tokens no longer silently
86+ // override the user's interactive login.
87+ //
88+ // Use hasStoredAuthCredentials() only when the DB singleton is already open
89+ // (i.e., another code path already initialized it). This avoids opening the
90+ // database as a side effect of reading an env var. On the first call within a
91+ // process (before any command initializes the DB), the env token is used —
92+ // which is correct since no OAuth login has been performed in this session yet.
93+ // After the first command opens the DB (via getAuthToken/refreshToken/etc.),
94+ // subsequent getEnvToken() calls will see stored OAuth and skip the env token.
95+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
96+ if ( ! forceEnv && _state . dbAccessed ) {
97+ try {
98+ if ( hasStoredAuthCredentials ( ) ) {
99+ return ;
100+ }
101+ } catch {
102+ // DB unavailable — fall through to use the env token.
103+ }
104+ }
105+
45106 const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
46107 if ( authToken ) {
47108 return { token : authToken , source : "env:SENTRY_AUTH_TOKEN" } ;
48109 }
49- const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
50- if ( sentryToken ) {
51- return { token : sentryToken , source : "env:SENTRY_TOKEN" } ;
52- }
53- return ;
110+ return { token : raw , source : "env:SENTRY_TOKEN" } ;
54111}
55112
56113/**
@@ -62,17 +119,21 @@ export function isEnvTokenActive(): boolean {
62119}
63120
64121/**
65- * Get the name of the active env var providing authentication .
122+ * Get the name of the env var providing a token, for error messages .
66123 * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN").
67124 *
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 .
125+ * Uses { @link getRawEnvToken} (not {@link getEnvToken}) so the result is
126+ * independent of whether stored OAuth takes priority.
127+ * Falls back to "SENTRY_AUTH_TOKEN" if no env var is set .
71128 */
72129export function getActiveEnvVarName ( ) : string {
73- const env = getEnvToken ( ) ;
74- if ( env ) {
75- return env . source . slice ( ENV_SOURCE_PREFIX . length ) ;
130+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
131+ if ( authToken ) {
132+ return "SENTRY_AUTH_TOKEN" ;
133+ }
134+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
135+ if ( sentryToken ) {
136+ return "SENTRY_TOKEN" ;
76137 }
77138 return "SENTRY_AUTH_TOKEN" ;
78139}
@@ -83,6 +144,7 @@ export function getAuthConfig(): AuthConfig | undefined {
83144 return { token : envToken . token , source : envToken . source } ;
84145 }
85146
147+ _state . dbAccessed = true ;
86148 return withDbSpan ( "getAuthConfig" , ( ) => {
87149 const db = getDatabase ( ) ;
88150 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
@@ -110,6 +172,7 @@ export function getAuthToken(): string | undefined {
110172 return envToken . token ;
111173 }
112174
175+ _state . dbAccessed = true ;
113176 return withDbSpan ( "getAuthToken" , ( ) => {
114177 const db = getDatabase ( ) ;
115178 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
@@ -133,6 +196,7 @@ export function setAuthToken(
133196 expiresIn ?: number ,
134197 newRefreshToken ?: string
135198) : void {
199+ _state . dbAccessed = true ;
136200 withDbSpan ( "setAuthToken" , ( ) => {
137201 const db = getDatabase ( ) ;
138202 const now = Date . now ( ) ;
@@ -179,6 +243,33 @@ export function isAuthenticated(): boolean {
179243 return ! ! token ;
180244}
181245
246+ /**
247+ * Check if usable OAuth credentials are stored in the database.
248+ *
249+ * Returns true when the `auth` table has either:
250+ * - A non-expired token, or
251+ * - An expired token with a refresh token (will be refreshed on next use)
252+ *
253+ * This is the gate for the "OAuth-preferred" default: when this returns true,
254+ * {@link getEnvToken} skips the env token so OAuth takes priority. Also used
255+ * by the login command to decide whether to prompt for re-authentication.
256+ */
257+ export function hasStoredAuthCredentials ( ) : boolean {
258+ const db = getDatabase ( ) ;
259+ const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
260+ | AuthRow
261+ | undefined ;
262+ if ( ! row ?. token ) {
263+ return false ;
264+ }
265+ // Non-expired token
266+ if ( ! row . expires_at || Date . now ( ) <= row . expires_at ) {
267+ return true ;
268+ }
269+ // Expired but has refresh token — will be refreshed on next use
270+ return ! ! row . refresh_token ;
271+ }
272+
182273export type RefreshTokenOptions = {
183274 /** Bypass threshold check and always refresh */
184275 force ?: boolean ;
@@ -229,7 +320,7 @@ async function performTokenRefresh(
229320export async function refreshToken (
230321 options : RefreshTokenOptions = { }
231322) : Promise < RefreshTokenResult > {
232- // Env var tokens are assumed valid — no refresh, no expiry check
323+ // Env var tokens are assumed valid — no refresh, no expiry check.
233324 const envToken = getEnvToken ( ) ;
234325 if ( envToken ) {
235326 return { token : envToken . token , refreshed : false } ;
0 commit comments