Skip to content

Commit bc79010

Browse files
committed
fix(auth): fall back to OAuth when env token lacks endpoint permissions (#646)
When SENTRY_AUTH_TOKEN is set by build tooling (e.g. the Sentry wizard) but lacks the scopes needed for interactive CLI commands, the CLI now automatically falls back to stored OAuth credentials instead of failing with confusing 401/403 errors. Per-endpoint permission cache: when an env token gets a 401/403 on a specific endpoint, the (token, method, url_path) tuple is stored in a new env_token_permissions table. Subsequent requests to that endpoint skip the env token and use OAuth directly — zero wasted API calls. Key changes: - New env_token_permissions table (schema v13) with 24h TTL and lazy probabilistic cleanup via the existing cleanupExpiredCaches path - HTTP layer pre-checks the cache before each request; on cache miss with 401/403, marks the endpoint, retries with OAuth, logs a warning - sentry auth login no longer blocks when an env token is present — warns and proceeds to store OAuth credentials separately - sentry auth status shows env token info (active, bypassed endpoints) - SENTRY_IGNORE_ENV_TOKEN env var as escape hatch to skip env tokens - --fresh clears the permission cache for re-evaluation - Enhanced 401/403 error messages when no OAuth fallback is available
1 parent ca14e7c commit bc79010

File tree

14 files changed

+805
-44
lines changed

14 files changed

+805
-44
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+
hasStoredAuthToken,
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 (!hasStoredAuthToken()) {
81+
return true;
82+
}
83+
// Fall through to the re-auth confirmation logic below
7984
}
8085

8186
if (!force) {

src/commands/auth/status.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ import {
1111
type AuthConfig,
1212
type AuthSource,
1313
ENV_SOURCE_PREFIX,
14+
getActiveEnvVarName,
1415
getAuthConfig,
16+
getRawEnvToken,
1517
isAuthenticated,
18+
isEnvTokenActive,
1619
} from "../../lib/db/auth.js";
1720
import {
1821
getDefaultOrganization,
1922
getDefaultProject,
2023
} from "../../lib/db/defaults.js";
24+
import { countInsufficientEndpoints } from "../../lib/db/env-token-cache.js";
2125
import { getDbPath } from "../../lib/db/index.js";
2226
import { getUserInfo } from "../../lib/db/user.js";
2327
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
@@ -77,6 +81,15 @@ export type AuthStatusData = {
7781
/** Error message if verification failed */
7882
error?: string;
7983
};
84+
/** Environment variable token info (present when SENTRY_AUTH_TOKEN or SENTRY_TOKEN is set) */
85+
envToken?: {
86+
/** Name of the env var (e.g., "SENTRY_AUTH_TOKEN") */
87+
envVar: string;
88+
/** Whether the env token is the effective auth source (vs bypassed for OAuth) */
89+
active: boolean;
90+
/** Number of endpoints where this token has been found insufficient */
91+
insufficientEndpoints: number;
92+
};
8093
};
8194

8295
/**
@@ -186,6 +199,24 @@ export const statusCommand = buildCommand({
186199
: undefined;
187200
}
188201

202+
// Check for env token regardless of whether it's the active source
203+
// (it may be set but bypassed due to insufficient permissions)
204+
const rawEnv = getRawEnvToken();
205+
let envTokenData: AuthStatusData["envToken"];
206+
if (rawEnv) {
207+
let insufficientCount = 0;
208+
try {
209+
insufficientCount = countInsufficientEndpoints(rawEnv);
210+
} catch {
211+
// Non-fatal: DB may be unavailable in some test/CI environments
212+
}
213+
envTokenData = {
214+
envVar: getActiveEnvVarName(),
215+
active: isEnvTokenActive(),
216+
insufficientEndpoints: insufficientCount,
217+
};
218+
}
219+
189220
const data: AuthStatusData = {
190221
authenticated: true,
191222
source: auth?.source ?? "oauth",
@@ -194,6 +225,7 @@ export const statusCommand = buildCommand({
194225
token: collectTokenInfo(auth, flags["show-token"]),
195226
defaults: collectDefaults(),
196227
verification: await verifyCredentials(),
228+
envToken: envTokenData,
197229
};
198230

199231
yield new CommandOutput(data);

src/lib/api/infrastructure.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import * as Sentry from "@sentry/node-core/light";
1111
import type { z } from "zod";
1212

13+
import { getRawEnvToken } from "../db/auth.js";
1314
import { getEnv } from "../env.js";
1415
import { ApiError, AuthError, stringifyUnknown } from "../errors.js";
1516
import { resolveOrgRegion } from "../region.js";
@@ -57,6 +58,29 @@ export function throwApiError(
5758
error && typeof error === "object" && "detail" in error
5859
? stringifyUnknown((error as { detail: unknown }).detail)
5960
: stringifyUnknown(error);
61+
62+
// When an env token is set and we get 401, the HTTP-layer fallback to
63+
// stored OAuth already failed (no stored credentials). Convert to AuthError
64+
// so the auto-login middleware in cli.ts can trigger interactive login.
65+
if (status === 401 && getRawEnvToken()) {
66+
throw new AuthError(
67+
"not_authenticated",
68+
`${context}: ${status} ${response.statusText ?? "Unknown"}.\n` +
69+
" SENTRY_AUTH_TOKEN is set but lacks permissions for this endpoint.\n" +
70+
" Run 'sentry auth login' to authenticate with OAuth, or set SENTRY_IGNORE_ENV_TOKEN=1."
71+
);
72+
}
73+
74+
// For 403 with env token, keep as ApiError but add guidance
75+
if (status === 403 && getRawEnvToken()) {
76+
throw new ApiError(
77+
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
78+
status,
79+
`${detail}\n\n SENTRY_AUTH_TOKEN may lack permissions for this endpoint.\n` +
80+
" Run 'sentry auth login' to authenticate with OAuth, or set SENTRY_IGNORE_ENV_TOKEN=1."
81+
);
82+
}
83+
6084
throw new ApiError(
6185
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
6286
status,

src/lib/db/auth.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { getEnv } from "../env.js";
66
import { clearResponseCache } from "../response-cache.js";
77
import { withDbSpan } from "../telemetry.js";
8+
import { clearEnvTokenCache } from "./env-token-cache.js";
89
import { getDatabase } from "./index.js";
910
import { runUpsert } from "./utils.js";
1011

@@ -36,12 +37,38 @@ export type AuthConfig = {
3637
source: AuthSource;
3738
};
3839

40+
/**
41+
* Read the raw token string from environment variables, ignoring all filters.
42+
*
43+
* Unlike {@link getEnvToken}, this always returns the env token if set, even
44+
* when `SENTRY_IGNORE_ENV_TOKEN` is active. Used by the HTTP layer to check
45+
* "was an env token provided?" independent of whether it's being used.
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+
* Returns `undefined` when `SENTRY_IGNORE_ENV_TOKEN` is set, causing all
65+
* downstream consumers to fall through to stored OAuth credentials.
4366
*/
4467
function getEnvToken(): { token: string; source: AuthSource } | undefined {
68+
if (getEnv().SENTRY_IGNORE_ENV_TOKEN?.trim()) {
69+
return;
70+
}
71+
4572
const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim();
4673
if (authToken) {
4774
return { token: authToken, source: "env:SENTRY_AUTH_TOKEN" };
@@ -165,6 +192,9 @@ export async function clearAuth(): Promise<void> {
165192
db.query("DELETE FROM pagination_cursors").run();
166193
});
167194

195+
// Clear env token permission cache — stale marks are meaningless after logout
196+
clearEnvTokenCache();
197+
168198
// Clear cached API responses — they are tied to the current user's permissions.
169199
// Awaited so cache is fully removed before the process exits.
170200
try {
@@ -179,9 +209,38 @@ export function isAuthenticated(): boolean {
179209
return !!token;
180210
}
181211

212+
/**
213+
* Check if a valid OAuth token is stored in the database.
214+
*
215+
* Returns true if the `auth` table has a non-expired token, regardless of
216+
* whether an env token is active. Used by the login command to decide whether
217+
* to prompt for re-authentication when an env token is present.
218+
*/
219+
export function hasStoredAuthToken(): boolean {
220+
return withDbSpan("hasStoredAuthToken", () => {
221+
const db = getDatabase();
222+
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
223+
| AuthRow
224+
| undefined;
225+
if (!row?.token) {
226+
return false;
227+
}
228+
if (row.expires_at && Date.now() > row.expires_at) {
229+
return false;
230+
}
231+
return true;
232+
});
233+
}
234+
182235
export type RefreshTokenOptions = {
183236
/** Bypass threshold check and always refresh */
184237
force?: boolean;
238+
/**
239+
* Skip the env token and go directly to stored OAuth credentials.
240+
* Used by the HTTP fallback layer when the env token is known-insufficient
241+
* for a specific endpoint.
242+
*/
243+
skipEnv?: boolean;
185244
};
186245

187246
export type RefreshTokenResult = {
@@ -229,10 +288,14 @@ async function performTokenRefresh(
229288
export async function refreshToken(
230289
options: RefreshTokenOptions = {}
231290
): 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 };
291+
// Env var tokens are assumed valid — no refresh, no expiry check.
292+
// skipEnv bypasses this for the OAuth fallback path when the env token
293+
// is known-insufficient for a specific endpoint.
294+
if (!options.skipEnv) {
295+
const envToken = getEnvToken();
296+
if (envToken) {
297+
return { token: envToken.token, refreshed: false };
298+
}
236299
}
237300

238301
const { force = false } = options;

src/lib/db/env-token-cache.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Per-endpoint permission cache for environment variable tokens.
3+
*
4+
* When an env token (e.g., wizard-generated SENTRY_AUTH_TOKEN) gets a 401/403
5+
* on a specific endpoint, the (token, method, url_path) tuple is recorded here.
6+
* Subsequent requests to the same endpoint skip the env token and fall back to
7+
* stored OAuth credentials.
8+
*
9+
* Entries expire after {@link ENV_TOKEN_CACHE_TTL_MS} (24 hours) to handle
10+
* internal integration tokens whose scopes can be edited without changing the
11+
* token string. Expired entries are ignored on read and cleaned up lazily by
12+
* the probabilistic cleanup in `cleanupExpiredCaches()`.
13+
*/
14+
15+
import { withDbSpan } from "../telemetry.js";
16+
import { getDatabase, maybeCleanupCaches } from "./index.js";
17+
import { runUpsert } from "./utils.js";
18+
19+
/** 24-hour TTL for env token permission cache entries */
20+
export const ENV_TOKEN_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
21+
22+
/**
23+
* Check if a specific endpoint is marked as insufficient for the given token.
24+
*
25+
* Returns `false` if no entry exists or if the entry is older than the TTL.
26+
* This ensures that stale marks are functionally ignored even before the
27+
* probabilistic cleanup physically deletes them.
28+
*/
29+
export function isEndpointInsufficient(
30+
token: string,
31+
method: string,
32+
urlPath: string
33+
): boolean {
34+
return withDbSpan("isEndpointInsufficient", () => {
35+
const db = getDatabase();
36+
const minCreatedAt = Date.now() - ENV_TOKEN_CACHE_TTL_MS;
37+
const row = db
38+
.query(
39+
"SELECT 1 FROM env_token_permissions " +
40+
"WHERE token = ? AND method = ? AND url_path = ? AND created_at > ?"
41+
)
42+
.get(token, method, urlPath, minCreatedAt);
43+
return row !== null;
44+
});
45+
}
46+
47+
/**
48+
* Mark a specific endpoint as insufficient for the given token.
49+
*
50+
* Uses UPSERT so re-encountering the same failure refreshes `created_at`,
51+
* extending the TTL. Triggers probabilistic cache cleanup (10% chance).
52+
*
53+
* @param token - The full env token string
54+
* @param method - HTTP method (e.g., "GET", "POST")
55+
* @param urlPath - URL pathname without query params (e.g., "/api/0/organizations/my-org/issues/")
56+
* @param status - HTTP status code that triggered the mark (401 or 403)
57+
*/
58+
export function markEndpointInsufficient(
59+
token: string,
60+
method: string,
61+
urlPath: string,
62+
status: number
63+
): void {
64+
withDbSpan("markEndpointInsufficient", () => {
65+
const db = getDatabase();
66+
runUpsert(
67+
db,
68+
"env_token_permissions",
69+
{
70+
token,
71+
method,
72+
url_path: urlPath,
73+
status,
74+
created_at: Date.now(),
75+
},
76+
["token", "method", "url_path"]
77+
);
78+
maybeCleanupCaches();
79+
});
80+
}
81+
82+
/**
83+
* Clear all env token permission cache entries.
84+
* Called by `clearAuth()` and `applyFreshFlag()`.
85+
*/
86+
export function clearEnvTokenCache(): void {
87+
withDbSpan("clearEnvTokenCache", () => {
88+
const db = getDatabase();
89+
db.query("DELETE FROM env_token_permissions").run();
90+
});
91+
}
92+
93+
/**
94+
* Clear permission cache entries for a specific token.
95+
* Useful when a token is known to have changed.
96+
*/
97+
export function clearEnvTokenCacheForToken(token: string): void {
98+
withDbSpan("clearEnvTokenCacheForToken", () => {
99+
const db = getDatabase();
100+
db.query("DELETE FROM env_token_permissions WHERE token = ?").run(token);
101+
});
102+
}
103+
104+
/**
105+
* Count the number of endpoints marked as insufficient for a specific token.
106+
* Only counts non-expired entries. Used by `auth status` for diagnostics.
107+
*/
108+
export function countInsufficientEndpoints(token: string): number {
109+
return withDbSpan("countInsufficientEndpoints", () => {
110+
const db = getDatabase();
111+
const minCreatedAt = Date.now() - ENV_TOKEN_CACHE_TTL_MS;
112+
const row = db
113+
.query(
114+
"SELECT COUNT(*) as count FROM env_token_permissions " +
115+
"WHERE token = ? AND created_at > ?"
116+
)
117+
.get(token, minCreatedAt) as { count: number };
118+
return row.count;
119+
});
120+
}

src/lib/db/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Database } from "bun:sqlite";
77
import { chmodSync, mkdirSync } from "node:fs";
88
import { join } from "node:path";
99
import { getEnv } from "../env.js";
10+
import { ENV_TOKEN_CACHE_TTL_MS } from "./env-token-cache.js";
1011
import { migrateFromJson } from "./migration.js";
1112
import { initSchema, runMigrations } from "./schema.js";
1213

@@ -171,6 +172,11 @@ function cleanupExpiredCaches(): void {
171172
database
172173
.query("DELETE FROM project_root_cache WHERE ttl_expires_at < ?")
173174
.run(now);
175+
// env_token_permissions uses a 24-hour TTL based on created_at
176+
const envTokenExpiryTime = now - ENV_TOKEN_CACHE_TTL_MS;
177+
database
178+
.query("DELETE FROM env_token_permissions WHERE created_at < ?")
179+
.run(envTokenExpiryTime);
174180
}
175181

176182
export function maybeCleanupCaches(): void {

0 commit comments

Comments
 (0)