Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildCommand, numberParser } from "../../lib/command.js";
import {
clearAuth,
getActiveEnvVarName,
hasStoredAuthCredentials,
isAuthenticated,
isEnvTokenActive,
setAuthToken,
Expand Down Expand Up @@ -71,11 +72,15 @@ type LoginFlags = {
async function handleExistingAuth(force: boolean): Promise<boolean> {
if (isEnvTokenActive()) {
const envVar = getActiveEnvVarName();
log.info(
`Authentication is provided via ${envVar} environment variable. ` +
`Unset ${envVar} to use OAuth-based login instead.`
log.warn(
`${envVar} is set in your environment (likely from build tooling).\n` +
" OAuth credentials will be stored separately and used for CLI commands."
);
return false;
// If no stored OAuth token exists, proceed directly to login
if (!hasStoredAuthCredentials()) {
return true;
}
// Fall through to the re-auth confirmation logic below
}

if (!force) {
Expand Down
18 changes: 18 additions & 0 deletions src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
type AuthConfig,
type AuthSource,
ENV_SOURCE_PREFIX,
getActiveEnvVarName,
getAuthConfig,
getRawEnvToken,
isAuthenticated,
isEnvTokenActive,
} from "../../lib/db/auth.js";
import {
getDefaultOrganization,
Expand Down Expand Up @@ -77,6 +80,13 @@ export type AuthStatusData = {
/** Error message if verification failed */
error?: string;
};
/** Environment variable token info (present when SENTRY_AUTH_TOKEN or SENTRY_TOKEN is set) */
envToken?: {
/** Name of the env var (e.g., "SENTRY_AUTH_TOKEN") */
envVar: string;
/** Whether the env token is the effective auth source (vs bypassed for OAuth) */
active: boolean;
};
};

/**
Expand Down Expand Up @@ -186,6 +196,13 @@ export const statusCommand = buildCommand({
: undefined;
}

// Check for env token regardless of whether it's the active source
// (it may be set but bypassed because stored OAuth takes priority)
const rawEnv = getRawEnvToken();
const envTokenData: AuthStatusData["envToken"] = rawEnv
? { envVar: getActiveEnvVarName(), active: isEnvTokenActive() }
: undefined;

const data: AuthStatusData = {
authenticated: true,
source: auth?.source ?? "oauth",
Expand All @@ -194,6 +211,7 @@ export const statusCommand = buildCommand({
token: collectTokenInfo(auth, flags["show-token"]),
defaults: collectDefaults(),
verification: await verifyCredentials(),
envToken: envTokenData,
};

yield new CommandOutput(data);
Expand Down
24 changes: 24 additions & 0 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import * as Sentry from "@sentry/node-core/light";
import type { z } from "zod";

import { getRawEnvToken } from "../db/auth.js";
import { getEnv } from "../env.js";
import { ApiError, AuthError, stringifyUnknown } from "../errors.js";
import { resolveOrgRegion } from "../region.js";
Expand Down Expand Up @@ -57,6 +58,29 @@ export function throwApiError(
error && typeof error === "object" && "detail" in error
? stringifyUnknown((error as { detail: unknown }).detail)
: stringifyUnknown(error);

// When an env token is set and we get 401, the HTTP-layer fallback to
// stored OAuth already failed (no stored credentials). Convert to AuthError
// so the auto-login middleware in cli.ts can trigger interactive login.
if (status === 401 && getRawEnvToken()) {
throw new AuthError(
"not_authenticated",
`${context}: ${status} ${response.statusText ?? "Unknown"}.\n` +
" SENTRY_AUTH_TOKEN is set but lacks permissions for this endpoint.\n" +
" Run 'sentry auth login' to authenticate with OAuth."
);
}

// For 403 with env token, keep as ApiError but add guidance
if (status === 403 && getRawEnvToken()) {
throw new ApiError(
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
status,
`${detail}\n\n SENTRY_AUTH_TOKEN may lack permissions for this endpoint.\n` +
" Run 'sentry auth login' to authenticate with OAuth."
);
}

throw new ApiError(
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
status,
Expand Down
137 changes: 117 additions & 20 deletions src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,34 @@ export type AuthConfig = {
source: AuthSource;
};

/**
* Read the raw token string from environment variables, ignoring all filters.
*
* Unlike {@link getEnvToken}, this always returns the env token if set, even
* when stored OAuth credentials would normally take priority. Used by the HTTP
* layer to check "was an env token provided?" independent of whether it's being
* used, and by the per-endpoint permission cache.
*/
export function getRawEnvToken(): string | undefined {
const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim();
if (authToken) {
return authToken;
}
const sentryToken = getEnv().SENTRY_TOKEN?.trim();
if (sentryToken) {
return sentryToken;
}
return;
}

/**
* Read token from environment variables.
* `SENTRY_AUTH_TOKEN` takes priority over `SENTRY_TOKEN` (matches legacy sentry-cli).
* Empty or whitespace-only values are treated as unset.
*
* This function is intentionally pure (no DB access). The "prefer stored OAuth
* over env token" logic lives in {@link getAuthToken} and {@link getAuthConfig}
* which check the DB first when `SENTRY_FORCE_ENV_TOKEN` is not set.
*/
function getEnvToken(): { token: string; source: AuthSource } | undefined {
const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim();
Expand All @@ -62,28 +86,39 @@ export function isEnvTokenActive(): boolean {
}

/**
* Get the name of the active env var providing authentication.
* Get the name of the env var providing a token, for error messages.
* Returns the specific variable name (e.g. "SENTRY_AUTH_TOKEN" or "SENTRY_TOKEN").
*
* **Important**: Call only after checking {@link isEnvTokenActive} returns true.
* Falls back to "SENTRY_AUTH_TOKEN" if no env source is active, which is a safe
* default for error messages but may be misleading if used unconditionally.
* Uses {@link getRawEnvToken} (not {@link getEnvToken}) so the result is
* independent of whether stored OAuth takes priority.
* Falls back to "SENTRY_AUTH_TOKEN" if no env var is set.
*/
export function getActiveEnvVarName(): string {
const env = getEnvToken();
if (env) {
return env.source.slice(ENV_SOURCE_PREFIX.length);
const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim();
if (authToken) {
return "SENTRY_AUTH_TOKEN";
}
const sentryToken = getEnv().SENTRY_TOKEN?.trim();
if (sentryToken) {
return "SENTRY_TOKEN";
}
return "SENTRY_AUTH_TOKEN";
}

export function getAuthConfig(): AuthConfig | undefined {
const envToken = getEnvToken();
if (envToken) {
return { token: envToken.token, source: envToken.source };
// When SENTRY_FORCE_ENV_TOKEN is set, check env first (old behavior).
// Otherwise, check the DB first — stored OAuth takes priority over env tokens.
// This is the core fix for #646: wizard-generated build tokens no longer
// silently override the user's interactive login.
const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim();
if (forceEnv) {
const envToken = getEnvToken();
if (envToken) {
return { token: envToken.token, source: envToken.source };
}
}

return withDbSpan("getAuthConfig", () => {
const dbConfig = withDbSpan("getAuthConfig", () => {
const db = getDatabase();
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
| AuthRow
Expand All @@ -101,16 +136,34 @@ export function getAuthConfig(): AuthConfig | undefined {
source: "oauth" as const,
};
});
}
if (dbConfig) {
return dbConfig;
}

/** Get the active auth token. Checks env vars first, then falls back to SQLite. */
export function getAuthToken(): string | undefined {
// No stored OAuth — fall back to env token
const envToken = getEnvToken();
if (envToken) {
return envToken.token;
return { token: envToken.token, source: envToken.source };
}
return;
}

/**
* Get the active auth token.
*
* Default: checks the DB first (stored OAuth wins), then falls back to env vars.
* With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior).
*/
export function getAuthToken(): string | undefined {
const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim();
if (forceEnv) {
const envToken = getEnvToken();
if (envToken) {
return envToken.token;
}
}

return withDbSpan("getAuthToken", () => {
const dbToken = withDbSpan("getAuthToken", () => {
const db = getDatabase();
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
| AuthRow
Expand All @@ -126,6 +179,16 @@ export function getAuthToken(): string | undefined {

return row.token;
});
if (dbToken) {
return dbToken;
}

// No stored OAuth — fall back to env token
const envToken = getEnvToken();
if (envToken) {
return envToken.token;
}
return;
}

export function setAuthToken(
Expand Down Expand Up @@ -179,6 +242,32 @@ export function isAuthenticated(): boolean {
return !!token;
}

/**
* Check if usable OAuth credentials are stored in the database.
*
* Returns true when the `auth` table has either:
* - A non-expired token, or
* - An expired token with a refresh token (will be refreshed on next use)
*
* Used by the login command to decide whether to prompt for re-authentication
* when an env token is present.
*/
export function hasStoredAuthCredentials(): boolean {
const db = getDatabase();
const row = db.query("SELECT * FROM auth WHERE id = 1").get() as
| AuthRow
| undefined;
if (!row?.token) {
return false;
}
// Non-expired token
if (!row.expires_at || Date.now() <= row.expires_at) {
return true;
}
// Expired but has refresh token — will be refreshed on next use
return !!row.refresh_token;
}

export type RefreshTokenOptions = {
/** Bypass threshold check and always refresh */
force?: boolean;
Expand Down Expand Up @@ -229,10 +318,13 @@ async function performTokenRefresh(
export async function refreshToken(
options: RefreshTokenOptions = {}
): Promise<RefreshTokenResult> {
// Env var tokens are assumed valid — no refresh, no expiry check
const envToken = getEnvToken();
if (envToken) {
return { token: envToken.token, refreshed: false };
// With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed).
const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim();
if (forceEnv) {
const envToken = getEnvToken();
if (envToken) {
return { token: envToken.token, refreshed: false };
}
}

const { force = false } = options;
Expand All @@ -244,6 +336,11 @@ export async function refreshToken(
| undefined;

if (!row?.token) {
// No stored token — try env token as fallback
const envToken = getEnvToken();
if (envToken) {
return { token: envToken.token, refreshed: false };
}
throw new AuthError("not_authenticated");
}

Expand Down
22 changes: 22 additions & 0 deletions src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,6 +1826,10 @@ export function formatAuthStatus(data: AuthStatusData): string {
lines.push(mdKvTable(authRows));
}

if (data.envToken) {
lines.push(formatEnvTokenSection(data.envToken));
}

if (data.defaults) {
lines.push(formatDefaultsSection(data.defaults));
}
Expand All @@ -1837,6 +1841,24 @@ export function formatAuthStatus(data: AuthStatusData): string {
return renderMarkdown(lines.join("\n"));
}

/**
* Format the env token status section.
* Shows whether the env token is active or bypassed, and how many endpoints
* have been marked insufficient.
*/
function formatEnvTokenSection(
envToken: NonNullable<AuthStatusData["envToken"]>
): string {
const status = envToken.active
? "active"
: "set but not used (using OAuth credentials)";
const rows: [string, string][] = [
["Env var", safeCodeSpan(envToken.envVar)],
["Status", status],
];
return `\n${mdKvTable(rows, "Environment Token")}`;
}

// Project Creation Formatting

/** Input for the project-created success formatter */
Expand Down
Loading
Loading