@@ -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,39 @@ export function isEnvTokenActive(): boolean {
6286}
6387
6488/**
65- * Get the name of the active env var providing authentication .
89+ * Get the name of the env var providing a token, for error messages .
6690 * Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN").
6791 *
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 .
92+ * Uses { @link getRawEnvToken} (not {@link getEnvToken}) so the result is
93+ * independent of whether stored OAuth takes priority.
94+ * Falls back to "SENTRY_AUTH_TOKEN" if no env var is set .
7195 */
7296export function getActiveEnvVarName ( ) : string {
73- const env = getEnvToken ( ) ;
74- if ( env ) {
75- return env . source . slice ( ENV_SOURCE_PREFIX . length ) ;
97+ const authToken = getEnv ( ) . SENTRY_AUTH_TOKEN ?. trim ( ) ;
98+ if ( authToken ) {
99+ return "SENTRY_AUTH_TOKEN" ;
100+ }
101+ const sentryToken = getEnv ( ) . SENTRY_TOKEN ?. trim ( ) ;
102+ if ( sentryToken ) {
103+ return "SENTRY_TOKEN" ;
76104 }
77105 return "SENTRY_AUTH_TOKEN" ;
78106}
79107
80108export function getAuthConfig ( ) : AuthConfig | undefined {
81- const envToken = getEnvToken ( ) ;
82- if ( envToken ) {
83- return { token : envToken . token , source : envToken . source } ;
109+ // When SENTRY_FORCE_ENV_TOKEN is set, check env first (old behavior).
110+ // Otherwise, check the DB first — stored OAuth takes priority over env tokens.
111+ // This is the core fix for #646: wizard-generated build tokens no longer
112+ // silently override the user's interactive login.
113+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
114+ if ( forceEnv ) {
115+ const envToken = getEnvToken ( ) ;
116+ if ( envToken ) {
117+ return { token : envToken . token , source : envToken . source } ;
118+ }
84119 }
85120
86- return withDbSpan ( "getAuthConfig" , ( ) => {
121+ const dbConfig = withDbSpan ( "getAuthConfig" , ( ) => {
87122 const db = getDatabase ( ) ;
88123 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
89124 | AuthRow
@@ -101,16 +136,34 @@ export function getAuthConfig(): AuthConfig | undefined {
101136 source : "oauth" as const ,
102137 } ;
103138 } ) ;
104- }
139+ if ( dbConfig ) {
140+ return dbConfig ;
141+ }
105142
106- /** Get the active auth token. Checks env vars first, then falls back to SQLite. */
107- export function getAuthToken ( ) : string | undefined {
143+ // No stored OAuth — fall back to env token
108144 const envToken = getEnvToken ( ) ;
109145 if ( envToken ) {
110- return envToken . token ;
146+ return { token : envToken . token , source : envToken . source } ;
147+ }
148+ return ;
149+ }
150+
151+ /**
152+ * Get the active auth token.
153+ *
154+ * Default: checks the DB first (stored OAuth wins), then falls back to env vars.
155+ * With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior).
156+ */
157+ export function getAuthToken ( ) : string | undefined {
158+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
159+ if ( forceEnv ) {
160+ const envToken = getEnvToken ( ) ;
161+ if ( envToken ) {
162+ return envToken . token ;
163+ }
111164 }
112165
113- return withDbSpan ( "getAuthToken" , ( ) => {
166+ const dbToken = withDbSpan ( "getAuthToken" , ( ) => {
114167 const db = getDatabase ( ) ;
115168 const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
116169 | AuthRow
@@ -126,6 +179,16 @@ export function getAuthToken(): string | undefined {
126179
127180 return row . token ;
128181 } ) ;
182+ if ( dbToken ) {
183+ return dbToken ;
184+ }
185+
186+ // No stored OAuth — fall back to env token
187+ const envToken = getEnvToken ( ) ;
188+ if ( envToken ) {
189+ return envToken . token ;
190+ }
191+ return ;
129192}
130193
131194export function setAuthToken (
@@ -179,6 +242,32 @@ export function isAuthenticated(): boolean {
179242 return ! ! token ;
180243}
181244
245+ /**
246+ * Check if usable OAuth credentials are stored in the database.
247+ *
248+ * Returns true when the `auth` table has either:
249+ * - A non-expired token, or
250+ * - An expired token with a refresh token (will be refreshed on next use)
251+ *
252+ * Used by the login command to decide whether to prompt for re-authentication
253+ * when an env token is present.
254+ */
255+ export function hasStoredAuthCredentials ( ) : boolean {
256+ const db = getDatabase ( ) ;
257+ const row = db . query ( "SELECT * FROM auth WHERE id = 1" ) . get ( ) as
258+ | AuthRow
259+ | undefined ;
260+ if ( ! row ?. token ) {
261+ return false ;
262+ }
263+ // Non-expired token
264+ if ( ! row . expires_at || Date . now ( ) <= row . expires_at ) {
265+ return true ;
266+ }
267+ // Expired but has refresh token — will be refreshed on next use
268+ return ! ! row . refresh_token ;
269+ }
270+
182271export type RefreshTokenOptions = {
183272 /** Bypass threshold check and always refresh */
184273 force ?: boolean ;
@@ -229,10 +318,13 @@ async function performTokenRefresh(
229318export async function refreshToken (
230319 options : RefreshTokenOptions = { }
231320) : 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 } ;
321+ // With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed).
322+ const forceEnv = getEnv ( ) . SENTRY_FORCE_ENV_TOKEN ?. trim ( ) ;
323+ if ( forceEnv ) {
324+ const envToken = getEnvToken ( ) ;
325+ if ( envToken ) {
326+ return { token : envToken . token , refreshed : false } ;
327+ }
236328 }
237329
238330 const { force = false } = options ;
@@ -244,6 +336,11 @@ export async function refreshToken(
244336 | undefined ;
245337
246338 if ( ! row ?. token ) {
339+ // No stored token — try env token as fallback
340+ const envToken = getEnvToken ( ) ;
341+ if ( envToken ) {
342+ return { token : envToken . token , refreshed : false } ;
343+ }
247344 throw new AuthError ( "not_authenticated" ) ;
248345 }
249346
0 commit comments