diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 2051b64e0..43bfb5df7 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 { ResolutionError } 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; @@ -44,6 +47,26 @@ export const whoamiCommand = buildCommand({ async *func(this: SentryContext, flags: WhoamiFlags) { applyFreshFlag(flags); + // 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 (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( + "Organization auth tokens (sntrys_...)", + "are not tied to a user — `whoami` needs a user-scoped credential", + "sentry auth status", + [ + "Use an OAuth token from `sentry auth login` or a personal access token", + "Run `sentry org list` to list organizations this token can access", + ] + ); + } + const user = await getCurrentUser(); // Keep cached user info up to date. Non-fatal: display must succeed even 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..697942587 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -22,7 +22,11 @@ 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 { + AuthError, + CliError, + ResolutionError, +} from "../../../src/lib/errors.js"; type WhoamiFlags = { readonly json: boolean }; @@ -45,6 +49,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 +76,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 +109,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 +130,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 +147,57 @@ describe("whoamiCommand.func", () => { }); }); + describe("org auth token short-circuit", () => { + test("throws ResolutionError and skips API call", async () => { + getAuthTokenSpy.mockReturnValue("sntrys_abc123def456"); + + const { context } = createContext(); + + // 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); + + 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 ResolutionError"); + } catch (err) { + 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"); + 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("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"); + }); + }); +});