Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,17 @@ override this precedence and force environment tokens to win, set
`SENTRY_FORCE_ENV_TOKEN=1`.

When a token comes from an environment variable, the CLI skips expiry checks and automatic refresh.

## Invalid Token Formatting

Tokens must be a single line of printable ASCII characters, without spaces.
When preparing an access token for storage or an authenticated request, the CLI
removes surrounding whitespace and ASCII control characters, then rejects any
remaining whitespace, control characters, and non-ASCII characters. It does not
join split lines.

If you see "Invalid authentication token", copy the complete token again into
the configuration that supplies it. For environment tokens, check
`SENTRY_AUTH_TOKEN` (or the legacy `SENTRY_TOKEN`). For stored credentials, run
`sentry auth login` to replace them. A token rejected for formatting exits with
code `12` (`AUTH_INVALID`).
9 changes: 7 additions & 2 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getUserRegions,
listOrganizationsUncached,
} from "../../lib/api-client.js";
import { normalizeAuthToken } from "../../lib/auth-header.js";
import { buildCommand, numberParser } from "../../lib/command.js";
import { normalizeUrl } from "../../lib/constants.js";
import {
Expand Down Expand Up @@ -459,6 +460,10 @@ export const loginCommand = buildCommand({
// (--token + --read-only/--scope, --read-only + --scope) and invalid
// scope values fail fast before any network or DB work.
const oauthScope = resolveLoginScope(flags);
// Validate explicit credentials before changing the host or replacing an
// existing session. An empty --token is invalid, not an OAuth request.
const token =
flags.token === undefined ? undefined : normalizeAuthToken(flags.token);

// Apply --url first so the device flow / token refresh target the
// requested instance. Default URL persistence is deferred until login
Expand Down Expand Up @@ -490,9 +495,9 @@ export const loginCommand = buildCommand({
// Non-fatal: cache directory may not exist
}

if (flags.token) {
if (token !== undefined) {
// Save token first (with host scope), then validate by fetching user regions
await setAuthToken(flags.token, undefined, undefined, {
await setAuthToken(token, undefined, undefined, {
host: effectiveHost,
});

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/lib/api/preprod-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
string,
tuple,
} from "valibot";
import { formatAuthHeader } from "../auth-header.js";
import { customFetch } from "../custom-ca.js";
import { getAuthToken } from "../db/auth.js";
import { ApiError, TimeoutError, ValidationError } from "../errors.js";
Expand Down Expand Up @@ -154,7 +155,7 @@ export async function downloadBuildArtifact(
if (isRegionOrigin(url, regionUrl)) {
const token = getAuthToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
headers.Authorization = formatAuthHeader(token);
}
}

Expand Down Expand Up @@ -565,7 +566,7 @@ export async function openSnapshotArchive(
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
headers.Authorization = formatAuthHeader(token);
}
const response = await customFetch(url, { headers });
if (!response.ok) {
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/lib/auth-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Validated Authorization values for the selected Sentry credential. */

import { MalformedAuthTokenError } from "./errors.js";

/** Bearer tokens are opaque, but cannot contain whitespace or non-ASCII bytes. */
const INVALID_TOKEN_CHARACTER_PATTERN = /[^\x21-\x7e]/;

// biome-ignore lint/suspicious/noControlCharactersInRegex: pasted ASCII controls are the padding this rule removes.
const TOKEN_PADDING_PATTERN = /[\s\x00-\x1f\x7f]/;

/** Remove surrounding whitespace and ASCII controls without validating a candidate. */
export function trimAuthToken(token: string): string {
// Scan only the edges; a trailing regex can backtrack over long internal runs.
let start = 0;
let end = token.length;
while (start < end && TOKEN_PADDING_PATTERN.test(token.charAt(start))) {
start += 1;
}
while (end > start && TOKEN_PADDING_PATTERN.test(token.charAt(end - 1))) {
end -= 1;
}
return token.slice(start, end);
}

/** Trim padding and validate the credential selected for storage or a request. */
export function normalizeAuthToken(token: string): string {
const normalized = trimAuthToken(token);
if (!normalized || INVALID_TOKEN_CHARACTER_PATTERN.test(normalized)) {
throw new MalformedAuthTokenError();
}
return normalized;
}

/** Normalize and validate a credential before constructing its Authorization value. */
export function formatAuthHeader(token: string): string {
return `Bearer ${normalizeAuthToken(token)}`;
}
83 changes: 41 additions & 42 deletions packages/cli/src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { createHash } from "node:crypto";
import { normalizeAuthToken, trimAuthToken } from "../auth-header.js";
import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js";
import { getEnv } from "../env.js";
import { getEnvTokenHost } from "../env-token-host.js";
Expand Down Expand Up @@ -91,23 +92,11 @@ export type AuthConfig = {
};

/**
* 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.
* Read the trimmed env token even when stored OAuth takes priority.
* Does not validate credentials that may never be used.
*/
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;
return getEnvToken()?.token;
}

/**
Expand All @@ -120,13 +109,21 @@ export function getRawEnvToken(): string | undefined {
* which check the DB first when `SENTRY_FORCE_ENV_TOKEN` is not set.
*/
function getEnvToken(): { token: string; source: AuthSource } | undefined {
// Preserve presence rules: whitespace is unset, but control-only credentials
// must remain selected so validation cannot silently fall back to another identity.
const authToken = getEnv().SENTRY_AUTH_TOKEN?.trim();
if (authToken) {
return { token: authToken, source: "env:SENTRY_AUTH_TOKEN" };
return {
token: trimAuthToken(authToken) || authToken,
source: "env:SENTRY_AUTH_TOKEN",
};
}
const sentryToken = getEnv().SENTRY_TOKEN?.trim();
if (sentryToken) {
return { token: sentryToken, source: "env:SENTRY_TOKEN" };
return {
token: trimAuthToken(sentryToken) || sentryToken,
source: "env:SENTRY_TOKEN",
};
}
return;
}
Expand All @@ -146,14 +143,9 @@ export function isEnvTokenActive(): boolean {
* Falls back to "SENTRY_AUTH_TOKEN" if no env var is set.
*/
export function getActiveEnvVarName(): string {
// Match getRawEnvToken() priority: SENTRY_AUTH_TOKEN first, then SENTRY_TOKEN
if (getEnv().SENTRY_AUTH_TOKEN?.trim()) {
return "SENTRY_AUTH_TOKEN";
}
if (getEnv().SENTRY_TOKEN?.trim()) {
return "SENTRY_TOKEN";
}
return "SENTRY_AUTH_TOKEN";
return getEnvToken()?.source === "env:SENTRY_TOKEN"
? "SENTRY_TOKEN"
: "SENTRY_AUTH_TOKEN";
}

export function getAuthConfig(): AuthConfig | undefined {
Expand Down Expand Up @@ -387,12 +379,14 @@ export type SetAuthTokenOptions = {
host?: string;
};

/** Normalize an access token before storage; malformed input leaves the auth row unchanged. */
export function setAuthToken(
token: string,
expiresIn?: number,
newRefreshToken?: string,
options?: SetAuthTokenOptions
): void {
const normalizedToken = normalizeAuthToken(token);
withDbSpan("setAuthToken", () => {
const db = getDatabase();
const now = Date.now();
Expand Down Expand Up @@ -422,7 +416,7 @@ export function setAuthToken(
"auth",
{
id: 1,
token,
token: normalizedToken,
refresh_token: newRefreshToken ?? null,
expires_at: expiresAt,
issued_at: issuedAt,
Expand Down Expand Up @@ -609,30 +603,35 @@ async function performTokenRefresh(
const { refreshAccessToken } = await import("../oauth.js");
const { AuthError } = await import("../errors.js");

let tokenResponse: Awaited<ReturnType<typeof refreshAccessToken>>;
try {
const tokenResponse = await refreshAccessToken(storedRefreshToken);
const now = Date.now();
const expiresAt = now + tokenResponse.expires_in * 1000;

await setAuthToken(
tokenResponse.access_token,
tokenResponse.expires_in,
tokenResponse.refresh_token ?? storedRefreshToken
);

return {
token: tokenResponse.access_token,
refreshed: true,
expiresAt,
expiresIn: tokenResponse.expires_in,
};
tokenResponse = await refreshAccessToken(storedRefreshToken);
} catch (error) {
// Only clear auth on explicit rejection, not network errors
if (error instanceof AuthError) {
await clearAuth();
}
throw error;
}

// Validate before SQLite can truncate NUL-containing credentials or replace
// the stored credentials with a malformed response. Leave those values unchanged.
const token = normalizeAuthToken(tokenResponse.access_token);
const now = Date.now();
const expiresAt = now + tokenResponse.expires_in * 1000;

await setAuthToken(
token,
tokenResponse.expires_in,
tokenResponse.refresh_token ?? storedRefreshToken
);

return {
token,
refreshed: true,
expiresAt,
expiresIn: tokenResponse.expires_in,
};
}

/** Get a valid token, refreshing if needed. Use force=true after 401 responses. */
Expand Down
40 changes: 34 additions & 6 deletions packages/cli/src/lib/db/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { join } from "node:path";

const _require = createRequire(import.meta.url);

import { normalizeAuthToken } from "../auth-header.js";
import { MalformedAuthTokenError } from "../errors.js";
import { logger } from "../logger.js";
import { getConfigDir } from "./index.js";
import type { Database } from "./sqlite.js";
Expand Down Expand Up @@ -76,7 +78,7 @@ function deleteOldConfig(): boolean {

type OldConfig = {
auth?: {
token?: string;
token?: unknown;
refreshToken?: string;
expiresAt?: number;
issuedAt?: number;
Expand Down Expand Up @@ -119,6 +121,7 @@ type OldConfig = {
};
};

/** Migrate once, retaining the original file and skipping auth if its access token is malformed. */
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one-time migration
export function migrateFromJson(db: Database): void {
// Check SQLite metadata first - this is the authoritative source
Expand All @@ -140,19 +143,38 @@ export function migrateFromJson(db: Database): void {
return;
}

let token: string | undefined;
let invalidAuthToken = false;
if (oldConfig.auth?.token !== undefined) {
try {
if (typeof oldConfig.auth.token !== "string") {
throw new MalformedAuthTokenError();
}
token = normalizeAuthToken(oldConfig.auth.token);
} catch (error) {
if (!(error instanceof MalformedAuthTokenError)) {
throw error;
}
// Do not block DB initialization (including login/logout) on a bad
// credential. Preserve the original file instead of binding a token
// that SQLite could truncate at an embedded NUL.
invalidAuthToken = true;
}
}

log.info("Migrating config to SQLite...");

db.exec("BEGIN TRANSACTION");

try {
if (oldConfig.auth?.token) {
if (token && oldConfig.auth) {
// Direct write (not via setAuthToken) — safe only because migration
// runs during DB bootstrap, before getIdentityFingerprint() memoizes.
db.query(`
INSERT OR REPLACE INTO auth (id, token, refresh_token, expires_at, issued_at, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
`).run(
oldConfig.auth.token,
token,
oldConfig.auth.refreshToken ?? null,
oldConfig.auth.expiresAt ?? null,
oldConfig.auth.issuedAt ?? null,
Expand Down Expand Up @@ -275,9 +297,15 @@ export function migrateFromJson(db: Database): void {
markMigrationCompleted(db);
db.exec("COMMIT");

// Best-effort cleanup of old file - if it fails, we're still safe
// because SQLite metadata is the authoritative source
deleteOldConfig();
if (invalidAuthToken) {
log.warn(
"Malformed authentication credentials were not migrated. The original config.json was kept. " +
"Run 'sentry auth login' to authenticate again, then remove the old file."
);
} else {
// Best-effort cleanup: SQLite metadata prevents re-import if it fails.
deleteOldConfig();
}
log.success("Migration complete.");
} catch (error) {
db.exec("ROLLBACK");
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/docs-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatAuthHeader } from "./auth-header.js";
import { customFetch } from "./custom-ca.js";
import { refreshToken } from "./db/auth.js";
import type { DocsProjectContext } from "./docs-context.js";
Expand Down Expand Up @@ -25,7 +26,7 @@ async function postDocs<T>(
const response = await customFetch(`${MASTRA_API_URL}${path}`, {
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${token}`,
Authorization: formatAuthHeader(token),
"Content-Type": "application/json",
},
method: "POST",
Expand Down
Loading
Loading