Skip to content

Commit 264c632

Browse files
authored
fix(auth): fall back to OAuth when env token lacks endpoint permissions (#673)
## Summary Fixes #646 When `SENTRY_AUTH_TOKEN` is set by build tooling (e.g., the Sentry wizard's `.env.sentry-build-plugin`) but lacks the scopes needed for interactive CLI commands, the CLI now prefers stored OAuth credentials by default. ### OAuth-preferred default When the user is logged in via OAuth, env tokens are automatically skipped — `getEnvToken()` returns `undefined` when `hasStoredAuthCredentials()` is true, cascading through all consumers (`isEnvTokenActive`, `refreshToken`, `handleUnauthorized`, etc.). Set `SENTRY_FORCE_ENV_TOKEN=1` to restore the old env-token-first priority. ### Other improvements - **Login unblocked**: `sentry auth login` no longer blocks when an env token is present — warns and proceeds to store OAuth credentials separately. - **Auth status enriched**: `sentry auth status` shows env token info (whether it's active or bypassed in favor of OAuth). - **Enhanced error messages** when env token is the only auth source and it fails (401 converts to `AuthError` triggering auto-login; 403 adds guidance).
1 parent 6f46e16 commit 264c632

File tree

14 files changed

+393
-138
lines changed

14 files changed

+393
-138
lines changed

src/commands/auth/login.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildCommand, numberParser } from "../../lib/command.js";
99
import {
1010
clearAuth,
1111
getActiveEnvVarName,
12+
hasStoredAuthCredentials,
1213
isAuthenticated,
1314
isEnvTokenActive,
1415
setAuthToken,
@@ -71,11 +72,15 @@ type LoginFlags = {
7172
async function handleExistingAuth(force: boolean): Promise<boolean> {
7273
if (isEnvTokenActive()) {
7374
const envVar = getActiveEnvVarName();
74-
log.info(
75-
`Authentication is provided via ${envVar} environment variable. ` +
76-
`Unset ${envVar} to use OAuth-based login instead.`
75+
log.warn(
76+
`${envVar} is set in your environment (likely from build tooling).\n` +
77+
" OAuth credentials will be stored separately and used for CLI commands."
7778
);
78-
return false;
79+
// If no stored OAuth token exists, proceed directly to login
80+
if (!hasStoredAuthCredentials()) {
81+
return true;
82+
}
83+
// Fall through to the re-auth confirmation logic below
7984
}
8085

8186
if (!force) {

src/commands/auth/logout.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import type { SentryContext } from "../../context.js";
88
import { buildCommand } from "../../lib/command.js";
99
import {
1010
clearAuth,
11+
ENV_SOURCE_PREFIX,
1112
getActiveEnvVarName,
13+
getAuthConfig,
1214
isAuthenticated,
13-
isEnvTokenActive,
1415
} from "../../lib/db/auth.js";
1516
import { getDbPath } from "../../lib/db/index.js";
1617
import { AuthError } from "../../lib/errors.js";
@@ -46,7 +47,8 @@ export const logoutCommand = buildCommand({
4647
});
4748
}
4849

49-
if (isEnvTokenActive()) {
50+
const auth = getAuthConfig();
51+
if (auth?.source.startsWith(ENV_SOURCE_PREFIX)) {
5052
const envVar = getActiveEnvVarName();
5153
throw new AuthError(
5254
"invalid",

src/commands/auth/refresh.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
import type { SentryContext } from "../../context.js";
88
import { buildCommand } from "../../lib/command.js";
99
import {
10+
ENV_SOURCE_PREFIX,
1011
getActiveEnvVarName,
1112
getAuthConfig,
12-
isEnvTokenActive,
1313
refreshToken,
1414
} from "../../lib/db/auth.js";
1515
import { AuthError } from "../../lib/errors.js";
@@ -71,8 +71,9 @@ Examples:
7171
},
7272
},
7373
async *func(this: SentryContext, flags: RefreshFlags) {
74-
// Env var tokens can't be refreshed
75-
if (isEnvTokenActive()) {
74+
// Env var tokens can't be refreshed — only block if env is the effective source
75+
const currentAuth = getAuthConfig();
76+
if (currentAuth?.source.startsWith(ENV_SOURCE_PREFIX)) {
7677
const envVar = getActiveEnvVarName();
7778
throw new AuthError(
7879
"invalid",

src/commands/auth/status.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
type AuthConfig,
1212
type AuthSource,
1313
ENV_SOURCE_PREFIX,
14+
getActiveEnvVarName,
1415
getAuthConfig,
16+
getRawEnvToken,
1517
isAuthenticated,
1618
} from "../../lib/db/auth.js";
1719
import {
@@ -77,6 +79,13 @@ export type AuthStatusData = {
7779
/** Error message if verification failed */
7880
error?: string;
7981
};
82+
/** Environment variable token info (present when SENTRY_AUTH_TOKEN or SENTRY_TOKEN is set) */
83+
envToken?: {
84+
/** Name of the env var (e.g., "SENTRY_AUTH_TOKEN") */
85+
envVar: string;
86+
/** Whether the env token is the effective auth source (vs bypassed for OAuth) */
87+
active: boolean;
88+
};
8089
};
8190

8291
/**
@@ -186,6 +195,13 @@ export const statusCommand = buildCommand({
186195
: undefined;
187196
}
188197

198+
// Check for env token regardless of whether it's the active source
199+
// (it may be set but bypassed because stored OAuth takes priority)
200+
const rawEnv = getRawEnvToken();
201+
const envTokenData: AuthStatusData["envToken"] = rawEnv
202+
? { envVar: getActiveEnvVarName(), active: fromEnv }
203+
: undefined;
204+
189205
const data: AuthStatusData = {
190206
authenticated: true,
191207
source: auth?.source ?? "oauth",
@@ -194,6 +210,7 @@ export const statusCommand = buildCommand({
194210
token: collectTokenInfo(auth, flags["show-token"]),
195211
defaults: collectDefaults(),
196212
verification: await verifyCredentials(),
213+
envToken: envTokenData,
197214
};
198215

199216
yield new CommandOutput(data);

src/lib/api/infrastructure.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export function throwApiError(
5757
error && typeof error === "object" && "detail" in error
5858
? stringifyUnknown((error as { detail: unknown }).detail)
5959
: stringifyUnknown(error);
60+
6061
throw new ApiError(
6162
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
6263
status,

src/lib/db/auth.ts

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
4468
function 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
*/
7294
export 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

80105
export 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

131198
export 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+
182275
export type RefreshTokenOptions = {
183276
/** Bypass threshold check and always refresh */
184277
force?: boolean;
@@ -229,10 +322,13 @@ async function performTokenRefresh(
229322
export 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'."

src/lib/formatters/human.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,10 @@ export function formatAuthStatus(data: AuthStatusData): string {
18261826
lines.push(mdKvTable(authRows));
18271827
}
18281828

1829+
if (data.envToken) {
1830+
lines.push(formatEnvTokenSection(data.envToken));
1831+
}
1832+
18291833
if (data.defaults) {
18301834
lines.push(formatDefaultsSection(data.defaults));
18311835
}
@@ -1837,6 +1841,24 @@ export function formatAuthStatus(data: AuthStatusData): string {
18371841
return renderMarkdown(lines.join("\n"));
18381842
}
18391843

1844+
/**
1845+
* Format the env token status section.
1846+
* Shows whether the env token is active or bypassed, and how many endpoints
1847+
* have been marked insufficient.
1848+
*/
1849+
function formatEnvTokenSection(
1850+
envToken: NonNullable<AuthStatusData["envToken"]>
1851+
): string {
1852+
const status = envToken.active
1853+
? "active"
1854+
: "set but not used (using OAuth credentials)";
1855+
const rows: [string, string][] = [
1856+
["Env var", safeCodeSpan(envToken.envVar)],
1857+
["Status", status],
1858+
];
1859+
return `\n${mdKvTable(rows, "Environment Token")}`;
1860+
}
1861+
18401862
// Project Creation Formatting
18411863

18421864
/** Input for the project-created success formatter */

0 commit comments

Comments
 (0)