From 3fae3416e5a8578d65d875ad621797e3660d76d9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 24 Apr 2026 16:16:10 +0000 Subject: [PATCH 1/3] fix(auth): translate whoami 400 + short-circuit org auth tokens (CLI-1AZ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/0/auth/ historically returned 400 Bad Request for valid Bearer tokens because Sentry's AuthIndexEndpoint excluded UserAuthTokenAuthentication, so tokens were silently ignored and the handler returned 400 with an empty body. Fixed server-side by getsentry/sentry#112853 (shipped 26.4.1), but CLIs in the wild must still degrade gracefully during rollout — and org auth tokens (sntrys_...) continue to hit this 400 forever because the fix only added UserAuthTokenAuthentication (not OrgAuthTokenAuthentication) and UserAuthTokenAuthentication.accepts_auth explicitly rejects the sntrys_ prefix. - Short-circuit whoami when the active token is an org auth token — whoami is semantically meaningless for org-scoped tokens (no user to return). Emit a clear CliError pointing to auth status / org list before any API call. - Translate ApiError(400) from /auth/ into AuthError('invalid') with skipAutoAuth: true and a helpful message (sentry auth login, sentry auth status). Silent refresh wouldn't help (token is valid, endpoint is refusing to parse it); triggering auto-login on whoami itself would loop. - Add allowlisted response-header capture in apiRequestToRegion 4xx/5xx path (content-type, content-length, server, cf-ray, x-sentry-error, www-authenticate) so future empty-detail 400s are triageable without a user-side repro. - New src/lib/token-type.ts helper with classifySentryToken() for prefix-based classification (org-auth-token / user-auth-token / oauth-or-legacy). Drives Sentry issue CLI-1AZ down to zero once rollout completes and keeps org-token users on a clear path forever. --- src/commands/auth/whoami.ts | 69 ++++++++++++++++- src/lib/api/infrastructure.ts | 14 ++++ src/lib/token-type.ts | 40 ++++++++++ test/commands/auth/whoami.test.ts | 110 ++++++++++++++++++++++++++- test/lib/token-type.property.test.ts | 103 +++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 src/lib/token-type.ts create mode 100644 test/lib/token-type.property.test.ts diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 2051b64e0..3f968fdec 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -9,7 +9,9 @@ import type { SentryContext } from "../../context.js"; import { getCurrentUser } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { getAuthToken } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; +import { ApiError, AuthError, CliError } from "../../lib/errors.js"; import { formatUserIdentity } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -17,6 +19,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { classifySentryToken } from "../../lib/token-type.js"; type WhoamiFlags = { readonly json: boolean; @@ -24,6 +27,43 @@ type WhoamiFlags = { readonly fields?: string[]; }; +/** + * Translate an `ApiError` from `/auth/` into something actionable. + * + * The Sentry backend historically returned `400 Bad Request` (not 401/403) + * from `GET /api/0/auth/` for valid Bearer tokens because + * `AuthIndexEndpoint` excluded `UserAuthTokenAuthentication` from its + * authenticators — tokens were silently ignored and the handler returned + * 400 with an empty body. Fixed server-side by getsentry/sentry#112853, + * but CLIs in the wild must still degrade gracefully while the fix rolls + * out across SaaS tiers and self-hosted. + * + * We translate 400 into an `AuthError("invalid")` with `skipAutoAuth: true` + * — a silent token refresh wouldn't help (the token is valid, the endpoint + * is refusing to parse it), and triggering auto-login on the whoami + * command itself would loop. Non-400 errors rethrow unchanged so existing + * 401/403/5xx handling applies. + */ +function translateWhoamiApiError(error: unknown): never { + if (error instanceof ApiError && error.status === 400) { + throw new AuthError( + "invalid", + [ + "Sentry returned 400 Bad Request for whoami.", + "", + "This usually means the auth endpoint temporarily rejected the token.", + "A known server-side fix is rolling out (getsentry/sentry#112853).", + "", + "Try:", + " sentry auth status — verify your token via a different endpoint", + " sentry auth login — refresh or re-authenticate", + ].join("\n"), + { skipAutoAuth: true } + ); + } + throw error; +} + export const whoamiCommand = buildCommand({ docs: { brief: "Show the currently authenticated user", @@ -44,7 +84,34 @@ export const whoamiCommand = buildCommand({ async *func(this: SentryContext, flags: WhoamiFlags) { applyFreshFlag(flags); - const user = await getCurrentUser(); + // Org auth tokens (`sntrys_...`) are not user-scoped — there is no + // single user to return for them. The backend `/auth/` endpoint also + // rejects this prefix (`UserAuthTokenAuthentication.accepts_auth` + // excludes it, and `OrgAuthTokenAuthentication` is not wired up to + // this endpoint). Short-circuit with a clear message instead of + // letting the request fail with a confusing 400. + const token = getAuthToken(); + if (token && classifySentryToken(token) === "org-auth-token") { + throw new CliError( + [ + "Organization auth tokens (sntrys_...) are not tied to a user.", + "", + "The `whoami` command only works with user-scoped credentials", + "(OAuth tokens from `sentry auth login` or personal access tokens).", + "", + "Try:", + " sentry auth status — show which token is active and its scope", + " sentry org list — list organizations this token can access", + ].join("\n") + ); + } + + let user: Awaited>; + try { + user = await getCurrentUser(); + } catch (error) { + translateWhoamiApiError(error); + } // Keep cached user info up to date. Non-fatal: display must succeed even // if the DB write fails (read-only filesystem, corrupted database, etc.). diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 1c1b0d644..aad1ab112 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -267,6 +267,20 @@ export async function apiRequestToRegion( } catch { detail = response.statusText; } + // Attach a small allowlisted subset of response headers to the Sentry + // event as context. This lets us distinguish Sentry-app 4xx/5xx (which + // ship a `{"detail": "..."}` JSON body and `content-type: application/json`) + // from CDN / WAF / edge 4xx (Cloudflare / proxy) that return empty or HTML + // bodies — a gap that previously made empty-`detail` events like CLI-1AZ + // impossible to triage without user-side repro. + Sentry.setContext("api_response_headers", { + "content-type": response.headers.get("content-type"), + "content-length": response.headers.get("content-length"), + server: response.headers.get("server"), + "cf-ray": response.headers.get("cf-ray"), + "x-sentry-error": response.headers.get("x-sentry-error"), + "www-authenticate": response.headers.get("www-authenticate"), + }); throw new ApiError( `API request failed: ${response.status} ${response.statusText}`, response.status, diff --git a/src/lib/token-type.ts b/src/lib/token-type.ts new file mode 100644 index 000000000..d97f4065c --- /dev/null +++ b/src/lib/token-type.ts @@ -0,0 +1,40 @@ +/** + * Sentry token classification. + * + * Classifies a raw Bearer token by its well-known server-side prefix. The + * prefixes come from `getsentry/sentry` `src/sentry/types/token.py` and the + * `SENTRY_ORG_AUTH_TOKEN_PREFIX` constant in the backend authentication + * module. + * + * This is used to short-circuit operations that are semantically + * inapplicable to certain token types (e.g., `sentry auth whoami` on an + * org auth token, which is not tied to a single user) without a round-trip + * to the API. + */ + +/** Sentry token kind inferred from the token's literal prefix. */ +export type SentryTokenKind = + /** `sntrys_...` — organization-scoped auth token, not tied to a user. */ + | "org-auth-token" + /** `sntryu_...` — user-scoped personal access token. */ + | "user-auth-token" + /** Any other shape: OAuth access tokens or legacy (pre-prefix) user tokens. */ + | "oauth-or-legacy"; + +/** + * Classify a Sentry Bearer token by its prefix. + * + * Prefix comparison is case-sensitive — the server emits these prefixes in + * lowercase only, so a mixed- or upper-case prefix is either user error + * (should 401 on the server) or a legacy/OAuth token that doesn't follow + * the prefix convention. + */ +export function classifySentryToken(token: string): SentryTokenKind { + if (token.startsWith("sntrys_")) { + return "org-auth-token"; + } + if (token.startsWith("sntryu_")) { + return "user-auth-token"; + } + return "oauth-or-legacy"; +} diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index 11a6d9c24..f3ebf7140 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -22,7 +22,7 @@ import * as apiClient from "../../../src/lib/api-client.js"; import * as dbAuth from "../../../src/lib/db/auth.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as dbUser from "../../../src/lib/db/user.js"; -import { AuthError } from "../../../src/lib/errors.js"; +import { ApiError, AuthError, CliError } from "../../../src/lib/errors.js"; type WhoamiFlags = { readonly json: boolean }; @@ -45,6 +45,12 @@ const ID_ONLY_USER = { id: "7", }; +/** + * OAuth-style token used when the test doesn't care about token type and + * just needs `getAuthToken()` to return something non-org/non-user-PAT. + */ +const OAUTH_TOKEN = "17faa5dfa5e64d5a9b3e8bf7c4d5e6f7a8b9c0d1e2f3a4b567ee"; + function createContext() { const output: string[] = []; const context = { @@ -66,19 +72,25 @@ function createContext() { describe("whoamiCommand.func", () => { let isAuthenticatedSpy: ReturnType; + let getAuthTokenSpy: ReturnType; let getCurrentUserSpy: ReturnType; let setUserInfoSpy: ReturnType; let func: WhoamiFunc; beforeEach(async () => { isAuthenticatedSpy = spyOn(dbAuth, "isAuthenticated"); + getAuthTokenSpy = spyOn(dbAuth, "getAuthToken"); getCurrentUserSpy = spyOn(apiClient, "getCurrentUser"); setUserInfoSpy = spyOn(dbUser, "setUserInfo"); + // Default token type: OAuth (not org, not PAT). Tests that need a + // different type override this mock within their own block. + getAuthTokenSpy.mockReturnValue(OAUTH_TOKEN); func = (await whoamiCommand.loader()) as unknown as WhoamiFunc; }); afterEach(() => { isAuthenticatedSpy.mockRestore(); + getAuthTokenSpy.mockRestore(); getCurrentUserSpy.mockRestore(); setUserInfoSpy.mockRestore(); }); @@ -93,6 +105,10 @@ describe("whoamiCommand.func", () => { getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue( undefined ); + // With no stored auth, getAuthToken returns undefined, and the + // natural AuthError bubbles up from getCurrentUser(). + getAuthTokenSpy.mockReturnValue(undefined); + getCurrentUserSpy.mockRejectedValue(new AuthError("not_authenticated")); }); afterEach(() => { @@ -110,8 +126,6 @@ describe("whoamiCommand.func", () => { await expect(func.call(context, { json: false })).rejects.toBeInstanceOf( AuthError ); - - expect(getCurrentUserSpy).not.toHaveBeenCalled(); }); test("does not call setUserInfo when not authenticated", async () => { @@ -129,6 +143,96 @@ describe("whoamiCommand.func", () => { }); }); + describe("org auth token short-circuit", () => { + test("throws CliError with actionable message and skips API call", async () => { + getAuthTokenSpy.mockReturnValue("sntrys_abc123def456"); + + const { context } = createContext(); + + // The thrown error must be a CliError (so the framework formats it), + // and must NOT be an AuthError (no auto-login trigger). + const promise = func.call(context, { json: false }); + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(promise).rejects.not.toBeInstanceOf(AuthError); + + expect(getCurrentUserSpy).not.toHaveBeenCalled(); + expect(setUserInfoSpy).not.toHaveBeenCalled(); + }); + + test("error message points to auth status and org list", async () => { + getAuthTokenSpy.mockReturnValue("sntrys_abc"); + + const { context } = createContext(); + + try { + await func.call(context, { json: false }); + throw new Error("expected CliError"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + const msg = (err as CliError).message; + expect(msg).toContain("Organization auth tokens"); + expect(msg.toLowerCase()).toContain("user"); + expect(msg).toContain("sentry auth status"); + expect(msg).toContain("sentry org list"); + } + }); + }); + + describe("user PAT (sntryu_) passes through", () => { + test("sntryu_ token calls getCurrentUser normally", async () => { + getAuthTokenSpy.mockReturnValue("sntryu_personaltoken"); + getCurrentUserSpy.mockResolvedValue(FULL_USER); + setUserInfoSpy.mockReturnValue(undefined); + + const { context, getOutput } = createContext(); + await func.call(context, { json: false }); + + expect(getCurrentUserSpy).toHaveBeenCalled(); + expect(getOutput()).toContain("Jane Doe"); + }); + }); + + describe("400 Bad Request on /auth/", () => { + test("translates ApiError(400) to AuthError(invalid) with skipAutoAuth", async () => { + getCurrentUserSpy.mockRejectedValue( + new ApiError("API request failed: 400 Bad Request", 400, "", "/auth/") + ); + + const { context } = createContext(); + + try { + await func.call(context, { json: false }); + throw new Error("expected AuthError"); + } catch (err) { + expect(err).toBeInstanceOf(AuthError); + const authErr = err as AuthError; + expect(authErr.reason).toBe("invalid"); + expect(authErr.skipAutoAuth).toBe(true); + expect(authErr.message).toContain("sentry auth login"); + expect(authErr.message).toContain("sentry auth status"); + } + }); + + test("non-400 ApiError rethrows unchanged", async () => { + const original = new ApiError( + "API request failed: 500 Internal Server Error", + 500, + "boom", + "/auth/" + ); + getCurrentUserSpy.mockRejectedValue(original); + + const { context } = createContext(); + + try { + await func.call(context, { json: false }); + throw new Error("expected ApiError"); + } catch (err) { + expect(err).toBe(original); + } + }); + }); + describe("human output", () => { test("displays name and email for full user", async () => { isAuthenticatedSpy.mockReturnValue(true); diff --git a/test/lib/token-type.property.test.ts b/test/lib/token-type.property.test.ts new file mode 100644 index 000000000..b63a65f3c --- /dev/null +++ b/test/lib/token-type.property.test.ts @@ -0,0 +1,103 @@ +/** + * Property-Based Tests for Sentry Token Classification + * + * Uses fast-check to verify classifySentryToken's prefix matching is + * correct across arbitrary suffixes and prefix variations. + */ + +import { describe, expect, test } from "bun:test"; +import { assert as fcAssert, property, string } from "fast-check"; +import { classifySentryToken } from "../../src/lib/token-type.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("classifySentryToken", () => { + describe("org-auth-token", () => { + test("any string starting with sntrys_ classifies as org-auth-token", () => { + fcAssert( + property(string(), (suffix) => { + expect(classifySentryToken(`sntrys_${suffix}`)).toBe( + "org-auth-token" + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("bare sntrys_ prefix classifies as org-auth-token", () => { + expect(classifySentryToken("sntrys_")).toBe("org-auth-token"); + }); + }); + + describe("user-auth-token", () => { + test("any string starting with sntryu_ classifies as user-auth-token", () => { + fcAssert( + property(string(), (suffix) => { + expect(classifySentryToken(`sntryu_${suffix}`)).toBe( + "user-auth-token" + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("bare sntryu_ prefix classifies as user-auth-token", () => { + expect(classifySentryToken("sntryu_")).toBe("user-auth-token"); + }); + }); + + describe("oauth-or-legacy", () => { + test("strings without the sntry prefix classify as oauth-or-legacy", () => { + // Reject anything that could happen to start with sntrys_ or sntryu_. + // "sntry" alone is fine — the underscore + discriminator is what matters. + fcAssert( + property(string(), (value) => { + if (value.startsWith("sntrys_") || value.startsWith("sntryu_")) { + return; + } + expect(classifySentryToken(value)).toBe("oauth-or-legacy"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty string classifies as oauth-or-legacy", () => { + expect(classifySentryToken("")).toBe("oauth-or-legacy"); + }); + + test("typical OAuth access token shape classifies as oauth-or-legacy", () => { + // OAuth access tokens are long hex / base64-ish strings without + // the sntrys_/sntryu_ prefix. + expect( + classifySentryToken( + "17faa5dfa5e64d5a9b3e8bf7c4d5e6f7a8b9c0d1e2f3a4b567ee" + ) + ).toBe("oauth-or-legacy"); + }); + }); + + describe("case sensitivity", () => { + test("uppercase SNTRYS_ is not matched (prefix is literal lowercase)", () => { + // Sentry server emits only lowercase prefixes. An uppercase variant + // would indicate either user error or a non-Sentry token, and must + // not be treated as an org token (which triggers whoami short-circuit). + expect(classifySentryToken("SNTRYS_abc")).toBe("oauth-or-legacy"); + expect(classifySentryToken("SNTRYU_abc")).toBe("oauth-or-legacy"); + expect(classifySentryToken("Sntrys_abc")).toBe("oauth-or-legacy"); + }); + }); + + describe("boundary cases", () => { + test("prefix without trailing underscore does not match", () => { + // `sntrys` and `sntryu` without `_` are not valid Sentry token prefixes. + expect(classifySentryToken("sntrys")).toBe("oauth-or-legacy"); + expect(classifySentryToken("sntryu")).toBe("oauth-or-legacy"); + expect(classifySentryToken("sntrysabc")).toBe("oauth-or-legacy"); + expect(classifySentryToken("sntryuabc")).toBe("oauth-or-legacy"); + }); + + test("prefix appearing mid-string does not match", () => { + expect(classifySentryToken("xsntrys_abc")).toBe("oauth-or-legacy"); + expect(classifySentryToken("abc_sntryu_def")).toBe("oauth-or-legacy"); + }); + }); +}); From ba2c95896df6597415a53d031f8e10bfd2797f50 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 24 Apr 2026 17:26:17 +0000 Subject: [PATCH 2/3] fix(auth): use ResolutionError for org-token short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's check:errors linter rejects CliError with ad-hoc 'Try:' strings — switch to ResolutionError (which already has structured headline/hint/ suggestions semantics) per AGENTS.md error hierarchy guidance. --- src/commands/auth/whoami.ts | 19 ++++++++----------- test/commands/auth/whoami.test.ts | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 3f968fdec..af8c42659 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -11,7 +11,7 @@ import { getCurrentUser } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; -import { ApiError, AuthError, CliError } from "../../lib/errors.js"; +import { ApiError, AuthError, ResolutionError } from "../../lib/errors.js"; import { formatUserIdentity } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -92,17 +92,14 @@ export const whoamiCommand = buildCommand({ // letting the request fail with a confusing 400. const token = getAuthToken(); if (token && classifySentryToken(token) === "org-auth-token") { - throw new CliError( + throw new ResolutionError( + "Organization auth tokens (sntrys_...)", + "are not tied to a user — `whoami` needs a user-scoped credential", + "sentry auth status", [ - "Organization auth tokens (sntrys_...) are not tied to a user.", - "", - "The `whoami` command only works with user-scoped credentials", - "(OAuth tokens from `sentry auth login` or personal access tokens).", - "", - "Try:", - " sentry auth status — show which token is active and its scope", - " sentry org list — list organizations this token can access", - ].join("\n") + "Use an OAuth token from `sentry auth login` or a personal access token", + "Run `sentry org list` to list organizations this token can access", + ] ); } diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index f3ebf7140..f1ab154cc 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -22,7 +22,12 @@ import * as apiClient from "../../../src/lib/api-client.js"; import * as dbAuth from "../../../src/lib/db/auth.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as dbUser from "../../../src/lib/db/user.js"; -import { ApiError, AuthError, CliError } from "../../../src/lib/errors.js"; +import { + ApiError, + AuthError, + CliError, + ResolutionError, +} from "../../../src/lib/errors.js"; type WhoamiFlags = { readonly json: boolean }; @@ -144,14 +149,16 @@ describe("whoamiCommand.func", () => { }); describe("org auth token short-circuit", () => { - test("throws CliError with actionable message and skips API call", async () => { + test("throws ResolutionError and skips API call", async () => { getAuthTokenSpy.mockReturnValue("sntrys_abc123def456"); const { context } = createContext(); - // The thrown error must be a CliError (so the framework formats it), - // and must NOT be an AuthError (no auto-login trigger). + // ResolutionError extends CliError; must NOT be an AuthError so the + // framework doesn't trigger the auto-login flow for a valid-but-wrong + // token type. const promise = func.call(context, { json: false }); + await expect(promise).rejects.toBeInstanceOf(ResolutionError); await expect(promise).rejects.toBeInstanceOf(CliError); await expect(promise).rejects.not.toBeInstanceOf(AuthError); @@ -166,10 +173,10 @@ describe("whoamiCommand.func", () => { try { await func.call(context, { json: false }); - throw new Error("expected CliError"); + throw new Error("expected ResolutionError"); } catch (err) { - expect(err).toBeInstanceOf(CliError); - const msg = (err as CliError).message; + expect(err).toBeInstanceOf(ResolutionError); + const msg = (err as ResolutionError).message; expect(msg).toContain("Organization auth tokens"); expect(msg.toLowerCase()).toContain("user"); expect(msg).toContain("sentry auth status"); From b05de76d3cea34a99b479df009c9442421c842e0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 25 Apr 2026 14:50:00 +0000 Subject: [PATCH 3/3] fix(auth): drop vestigial 400 translation in whoami MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side fix (getsentry/sentry#112853) is rolled out — OAuth and user PAT 400s on /auth/ are no longer happening. The only persistent 400 case is org auth tokens, which we already short-circuit before the API call. The 400 → AuthError translation was both unhelpful (the 'rolling out' claim is no longer accurate) and unreachable in normal operation. Anomalous future 400s remain diagnosable via the response-header telemetry context added to apiRequestToRegion in this PR. --- src/commands/auth/whoami.ts | 53 ++++--------------------------- test/commands/auth/whoami.test.ts | 42 ------------------------ 2 files changed, 6 insertions(+), 89 deletions(-) diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index af8c42659..43bfb5df7 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -11,7 +11,7 @@ import { getCurrentUser } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; -import { ApiError, AuthError, ResolutionError } from "../../lib/errors.js"; +import { ResolutionError } from "../../lib/errors.js"; import { formatUserIdentity } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -27,43 +27,6 @@ type WhoamiFlags = { readonly fields?: string[]; }; -/** - * Translate an `ApiError` from `/auth/` into something actionable. - * - * The Sentry backend historically returned `400 Bad Request` (not 401/403) - * from `GET /api/0/auth/` for valid Bearer tokens because - * `AuthIndexEndpoint` excluded `UserAuthTokenAuthentication` from its - * authenticators — tokens were silently ignored and the handler returned - * 400 with an empty body. Fixed server-side by getsentry/sentry#112853, - * but CLIs in the wild must still degrade gracefully while the fix rolls - * out across SaaS tiers and self-hosted. - * - * We translate 400 into an `AuthError("invalid")` with `skipAutoAuth: true` - * — a silent token refresh wouldn't help (the token is valid, the endpoint - * is refusing to parse it), and triggering auto-login on the whoami - * command itself would loop. Non-400 errors rethrow unchanged so existing - * 401/403/5xx handling applies. - */ -function translateWhoamiApiError(error: unknown): never { - if (error instanceof ApiError && error.status === 400) { - throw new AuthError( - "invalid", - [ - "Sentry returned 400 Bad Request for whoami.", - "", - "This usually means the auth endpoint temporarily rejected the token.", - "A known server-side fix is rolling out (getsentry/sentry#112853).", - "", - "Try:", - " sentry auth status — verify your token via a different endpoint", - " sentry auth login — refresh or re-authenticate", - ].join("\n"), - { skipAutoAuth: true } - ); - } - throw error; -} - export const whoamiCommand = buildCommand({ docs: { brief: "Show the currently authenticated user", @@ -86,10 +49,11 @@ export const whoamiCommand = buildCommand({ // Org auth tokens (`sntrys_...`) are not user-scoped — there is no // single user to return for them. The backend `/auth/` endpoint also - // rejects this prefix (`UserAuthTokenAuthentication.accepts_auth` + // rejects this prefix: `UserAuthTokenAuthentication.accepts_auth` // excludes it, and `OrgAuthTokenAuthentication` is not wired up to - // this endpoint). Short-circuit with a clear message instead of - // letting the request fail with a confusing 400. + // this endpoint (getsentry/sentry#112853 added user-token auth only). + // Short-circuit with a clear message instead of letting the request + // fail with a confusing 400. const token = getAuthToken(); if (token && classifySentryToken(token) === "org-auth-token") { throw new ResolutionError( @@ -103,12 +67,7 @@ export const whoamiCommand = buildCommand({ ); } - let user: Awaited>; - try { - user = await getCurrentUser(); - } catch (error) { - translateWhoamiApiError(error); - } + const user = await getCurrentUser(); // Keep cached user info up to date. Non-fatal: display must succeed even // if the DB write fails (read-only filesystem, corrupted database, etc.). diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index f1ab154cc..697942587 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -23,7 +23,6 @@ import * as dbAuth from "../../../src/lib/db/auth.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as dbUser from "../../../src/lib/db/user.js"; import { - ApiError, AuthError, CliError, ResolutionError, @@ -199,47 +198,6 @@ describe("whoamiCommand.func", () => { }); }); - describe("400 Bad Request on /auth/", () => { - test("translates ApiError(400) to AuthError(invalid) with skipAutoAuth", async () => { - getCurrentUserSpy.mockRejectedValue( - new ApiError("API request failed: 400 Bad Request", 400, "", "/auth/") - ); - - const { context } = createContext(); - - try { - await func.call(context, { json: false }); - throw new Error("expected AuthError"); - } catch (err) { - expect(err).toBeInstanceOf(AuthError); - const authErr = err as AuthError; - expect(authErr.reason).toBe("invalid"); - expect(authErr.skipAutoAuth).toBe(true); - expect(authErr.message).toContain("sentry auth login"); - expect(authErr.message).toContain("sentry auth status"); - } - }); - - test("non-400 ApiError rethrows unchanged", async () => { - const original = new ApiError( - "API request failed: 500 Internal Server Error", - 500, - "boom", - "/auth/" - ); - getCurrentUserSpy.mockRejectedValue(original); - - const { context } = createContext(); - - try { - await func.call(context, { json: false }); - throw new Error("expected ApiError"); - } catch (err) { - expect(err).toBe(original); - } - }); - }); - describe("human output", () => { test("displays name and email for full user", async () => { isAuthenticatedSpy.mockReturnValue(true);