@@ -36,10 +36,34 @@ export type AuthConfig = {
3636 source : AuthSource ;
3737} ;
3838
39+ /**
40+ * Read the raw token string from environment variables, ignoring all filters.
41+ *
42+ * Unlike {@link getEnvToken}, this always returns the env token if set, even
43+ * when stored OAuth credentials would normally take priority. Used by the HTTP
44+ * layer to check "was an env token provided?" independent of whether it's being
45+ * used, and by the per-endpoint permission cache.
46+ */
47+ export function getRawEnvToken ( ) : string | undefined {
48+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
49+ if ( authToken ) {
50+ return authToken ;
51+ }
52+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
53+ if ( sentryToken ) {
54+ return sentryToken ;
55+ }
56+ return ;
57+ }
58+
3959/**
4060 * Read token from environment variables.
4161 * `SENTRY_AUTH_TOKEN` takes priority over `SENTRY_TOKEN` (matches legacy sentry-cli).
4262 * Empty or whitespace-only values are treated as unset.
63+ *
64+ * This function is intentionally pure (no DB access). The "prefer stored OAuth
65+ * over env token" logic lives in {@link getAuthToken} and {@link getAuthConfig}
66+ * which check the DB first when `SENTRY_FORCE_ENV_TOKEN` is not set.
4367 */
4468function getEnvToken ( ) : { token : string ; source : AuthSource } | undefined {
4569 const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
@@ -62,28 +86,36 @@ export function isEnvTokenActive(): boolean {
6286}
6387
6488/**
65- * Get the name of the active env var providing authentication.
66- * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN").
67- *
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.
89+ * Get the name of the env var providing a token, for error messages.
90+ * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN")
91+ * by checking which env var {@link getRawEnvToken} would read.
92+ * Falls back to "SENTRY_AUTH_TOKEN" if no env var is set.
7193 */
7294export function getActiveEnvVarName ( ) : string {
73- const env = getEnvToken ( ) ;
74- if ( env ) {
75- return env . source . slice ( ENV_SOURCE_PREFIX . length ) ;
95+ // Match getRawEnvToken() priority: SENTRY_AUTH_TOKEN first, then SENTRY_TOKEN
96+ if ( getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ) {
97+ return "SENTRY_AUTH_TOKEN" ;
98+ }
99+ if ( getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ) {
100+ return "SENTRY_TOKEN" ;
76101 }
77102 return "SENTRY_AUTH_TOKEN" ;
78103}
79104
80105export function getAuthConfig ( ) : AuthConfig | undefined {
81- const envToken = getEnvToken ( ) ;
82- if ( envToken ) {
83- return { token : envToken . token , source : envToken . source } ;
106+ // When SENTRY_FORCE_ENV_TOKEN is set, check env first (old behavior).
107+ // Otherwise, check the DB first — stored OAuth takes priority over env tokens.
108+ // This is the core fix for #646: wizard-generated build tokens no longer
109+ // silently override the user's interactive login.
110+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
111+ if ( forceEnv ) {
112+ const envToken = getEnvToken ( ) ;
113+ if ( envToken ) {
114+ return { token : envToken . token , source : envToken . source } ;
115+ }
84116 }
85117
86- return withDbSpan ( "getAuthConfig" , ( ) => {
118+ const dbConfig = withDbSpan ( "getAuthConfig" , ( ) => {
87119 const db = getDatabase ( ) ;
88120 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
89121 | AuthRow
@@ -93,6 +125,13 @@ export function getAuthConfig(): AuthConfig | undefined {
93125 return ;
94126 }
95127
128+ // Skip expired tokens without a refresh token — they're unusable.
129+ // Expired tokens WITH a refresh token are kept: auth refresh and
130+ // refreshToken() need them to perform the OAuth refresh flow.
131+ if ( row . expires_at && Date . now ( ) > row . expires_at && ! row . refresh_token ) {
132+ return ;
133+ }
134+
96135 return {
97136 token : row . token ?? undefined ,
98137 refreshToken : row . refresh_token ?? undefined ,
@@ -101,16 +140,34 @@ export function getAuthConfig(): AuthConfig | undefined {
101140 source : "oauth" as const ,
102141 } ;
103142 } ) ;
104- }
143+ if ( dbConfig ) {
144+ return dbConfig ;
145+ }
105146
106- /** Get the active auth token. Checks env vars first, then falls back to SQLite. */
107- export function getAuthToken ( ) : string | undefined {
147+ // No stored OAuth — fall back to env token
108148 const envToken = getEnvToken ( ) ;
109149 if ( envToken ) {
110- return envToken . token ;
150+ return { token : envToken . token , source : envToken . source } ;
111151 }
152+ return ;
153+ }
112154
113- return withDbSpan ( "getAuthToken" , ( ) => {
155+ /**
156+ * Get the active auth token.
157+ *
158+ * Default: checks the DB first (stored OAuth wins), then falls back to env vars.
159+ * With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior).
160+ */
161+ export function getAuthToken ( ) : string | undefined {
162+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
163+ if ( forceEnv ) {
164+ const envToken = getEnvToken ( ) ;
165+ if ( envToken ) {
166+ return envToken . token ;
167+ }
168+ }
169+
170+ const dbToken = withDbSpan ( "getAuthToken" , ( ) => {
114171 const db = getDatabase ( ) ;
115172 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
116173 | AuthRow
@@ -126,6 +183,16 @@ export function getAuthToken(): string | undefined {
126183
127184 return row . token ;
128185 } ) ;
186+ if ( dbToken ) {
187+ return dbToken ;
188+ }
189+
190+ // No stored OAuth — fall back to env token
191+ const envToken = getEnvToken ( ) ;
192+ if ( envToken ) {
193+ return envToken . token ;
194+ }
195+ return ;
129196}
130197
131198export function setAuthToken (
@@ -179,6 +246,32 @@ export function isAuthenticated(): boolean {
179246 return ! ! token ;
180247}
181248
249+ /**
250+ * Check if usable OAuth credentials are stored in the database.
251+ *
252+ * Returns true when the `auth` table has either:
253+ * - A non-expired token, or
254+ * - An expired token with a refresh token (will be refreshed on next use)
255+ *
256+ * Used by the login command to decide whether to prompt for re-authentication
257+ * when an env token is present.
258+ */
259+ export function hasStoredAuthCredentials ( ) : boolean {
260+ const db = getDatabase ( ) ;
261+ const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
262+ | AuthRow
263+ | undefined ;
264+ if ( ! row ?. token ) {
265+ return false ;
266+ }
267+ // Non-expired token
268+ if ( ! row . expires_at || Date . now ( ) <= row . expires_at ) {
269+ return true ;
270+ }
271+ // Expired but has refresh token — will be refreshed on next use
272+ return ! ! row . refresh_token ;
273+ }
274+
182275export type RefreshTokenOptions = {
183276 /** Bypass threshold check and always refresh */
184277 force ?: boolean ;
@@ -229,10 +322,13 @@ async function performTokenRefresh(
229322export async function refreshToken (
230323 options : RefreshTokenOptions = { }
231324) : Promise < RefreshTokenResult > {
232- // Env var tokens are assumed valid — no refresh, no expiry check
233- const envToken = getEnvToken ( ) ;
234- if ( envToken ) {
235- return { token : envToken . token , refreshed : false } ;
325+ // With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed).
326+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
327+ if ( forceEnv ) {
328+ const envToken = getEnvToken ( ) ;
329+ if ( envToken ) {
330+ return { token : envToken . token , refreshed : false } ;
331+ }
236332 }
237333
238334 const { force = false } = options ;
@@ -244,6 +340,11 @@ export async function refreshToken(
244340 | undefined ;
245341
246342 if ( ! row ?. token ) {
343+ // No stored token — try env token as fallback
344+ const envToken = getEnvToken ( ) ;
345+ if ( envToken ) {
346+ return { token : envToken . token , refreshed : false } ;
347+ }
247348 throw new AuthError ( "not_authenticated" ) ;
248349 }
249350
@@ -271,6 +372,11 @@ export async function refreshToken(
271372
272373 if ( ! row . refresh_token ) {
273374 await clearAuth ( ) ;
375+ // Fall back to env token if available (consistent with getAuthToken/getAuthConfig)
376+ const envToken = getEnvToken ( ) ;
377+ if ( envToken ) {
378+ return { token : envToken . token , refreshed : false } ;
379+ }
274380 throw new AuthError (
275381 "expired" ,
276382 "Session expired and no refresh token available. Run 'sentry auth login'."
0 commit comments