diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 13c240043..66ba7b413 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -281,7 +281,7 @@ Authenticate with Sentry - `sentry auth refresh` — Refresh your authentication token - `sentry auth status` — View authentication status - `sentry auth token` — Print the stored authentication token -- `sentry auth whoami` — Show the currently authenticated user +- `sentry auth whoami` — Show the currently authenticated identity → Full flags and examples: `references/auth.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/plugins/sentry-cli/skills/sentry-cli/references/auth.md index 97129a7df..9d7231c46 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -88,7 +88,7 @@ sentry auth token ### `sentry auth whoami` -Show the currently authenticated user +Show the currently authenticated identity **Flags:** - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index f7a5eb4e1..0e83f2279 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -13,6 +13,7 @@ import { ENV_SOURCE_PREFIX, getActiveEnvVarName, getAuthConfig, + getAuthToken, getRawEnvToken, isAuthenticated, } from "../../lib/db/auth.js"; @@ -30,6 +31,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { classifySentryToken } from "../../lib/token-type.js"; type StatusFlags = { readonly "show-token": boolean; @@ -216,8 +218,14 @@ export const statusCommand = buildCommand({ yield new CommandOutput(data); if (fromEnv) { + const effectiveToken = getAuthToken(); + const isOrgToken = + effectiveToken && + classifySentryToken(effectiveToken) === "org-auth-token"; return { - hint: "Run `sentry auth whoami` to see which user this token belongs to", + hint: isOrgToken + ? "Run `sentry auth whoami` to see which organization this token belongs to" + : "Run `sentry auth whoami` to see which user this token belongs to", }; } }, diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 43bfb5df7..167165843 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -1,9 +1,9 @@ /** * sentry auth whoami * - * Display the currently authenticated user's identity by fetching live from - * the /auth/ endpoint. Unlike `sentry auth status`, this command only shows - * who you are — no token details, no defaults, no org verification. + * Display the currently authenticated identity. For user-scoped tokens (OAuth, + * PAT), this fetches the user from `/auth/`. For org auth tokens (`sntrys_`), + * it extracts the organization from the token's embedded claim. */ import type { SentryContext } from "../../context.js"; @@ -12,31 +12,53 @@ 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 { + formatOrgTokenIdentity, + formatUserIdentity, + type OrgTokenIdentity, +} from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; +import { parseSntrysClaim } from "../../lib/token-claims.js"; import { classifySentryToken } from "../../lib/token-type.js"; +const log = logger.withTag("auth.whoami"); + type WhoamiFlags = { readonly json: boolean; readonly fresh: boolean; readonly fields?: string[]; }; +/** + * Discriminate between user identity and org-token identity for the human + * formatter. The `type` field is only present on org-token output. + */ +function formatWhoami( + data: OrgTokenIdentity | Record +): string { + if ("type" in data && data.type === "org-auth-token") { + return formatOrgTokenIdentity(data as OrgTokenIdentity); + } + return formatUserIdentity(data as Parameters[0]); +} + export const whoamiCommand = buildCommand({ docs: { - brief: "Show the currently authenticated user", + brief: "Show the currently authenticated identity", fullDescription: - "Fetch and display the identity of the currently authenticated user.\n\n" + - "This calls the Sentry API live (not cached) so the result always reflects " + - "the current token. Works with all token types: OAuth, API tokens, and OAuth App tokens.", + "Display the identity behind the current authentication token.\n\n" + + "For user-scoped tokens (OAuth, personal access tokens), this fetches " + + "the user from the Sentry API. For organization auth tokens (`sntrys_`), " + + "it shows which organization the token belongs to.", }, output: { - human: formatUserIdentity, + human: formatWhoami, }, parameters: { flags: { @@ -47,24 +69,37 @@ 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 claim = parseSntrysClaim(token); + if (!claim) { + // Malformed sntrys_ token — claim parsing failed. Fall back to the + // original error since we can't extract any useful info. + 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", + ] + ); + } + + log.warn( + "This is an organization auth token — not tied to a specific user." ); + + const data: OrgTokenIdentity = { + type: "org-auth-token", + organization: claim.org, + url: claim.url, + regionUrl: claim.regionUrl, + }; + yield new CommandOutput(data); + return { + hint: "Run `sentry auth login` for user-scoped authentication", + }; } const user = await getCurrentUser(); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 65483486e..bdae0a41e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1610,6 +1610,31 @@ export function formatUserIdentity(user: UserIdentityInput): string { return `user ${finalId}`; } +/** Data shape yielded by `whoami` for `sntrys_` org auth tokens. */ +export type OrgTokenIdentity = { + type: "org-auth-token"; + organization?: string; + url: string; + regionUrl?: string; +}; + +/** + * Format org-auth-token identity for `sentry auth whoami`. + * Renders "Organization: ()" or just the URL when slug is unknown. + */ +export function formatOrgTokenIdentity(data: OrgTokenIdentity): string { + const rows: Array = []; + rows.push(["Token type", "Organization auth token"]); + if (data.organization) { + rows.push(["Organization", data.organization]); + } + rows.push(["Instance", data.url]); + if (data.regionUrl) { + rows.push(["Region", data.regionUrl]); + } + return renderMarkdown(mdKvTable(rows)); +} + // Token Formatting /** diff --git a/src/lib/token-claims.ts b/src/lib/token-claims.ts index d43f9ee8f..b478ed37b 100644 --- a/src/lib/token-claims.ts +++ b/src/lib/token-claims.ts @@ -20,10 +20,17 @@ const SNTRYS_PREFIX = "sntrys_"; /** 2 KB cap. Real tokens are ~150-300 bytes; cap defends the auth hot path. */ const MAX_TOKEN_LENGTH = 2048; +/** Return a non-empty string from a parsed claim field, or `undefined`. */ +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + /** Subset of `sntrys_` payload fields we care about. */ export type SntrysClaim = { url: string; regionUrl?: string; + /** Organization slug embedded in the token at issuance time. */ + org?: string; }; /** @@ -77,10 +84,8 @@ export function parseSntrysClaim( return; } - const regionUrl = - typeof obj.region_url === "string" && obj.region_url - ? obj.region_url - : undefined; + const regionUrl = optionalString(obj.region_url); + const org = optionalString(obj.org); - return { url, regionUrl }; + return { url, regionUrl, org }; } diff --git a/test/commands/auth/status.test.ts b/test/commands/auth/status.test.ts index e220c2d97..378b16002 100644 --- a/test/commands/auth/status.test.ts +++ b/test/commands/auth/status.test.ts @@ -63,6 +63,7 @@ function createContext() { describe("statusCommand.func", () => { let getAuthConfigSpy: ReturnType; + let getAuthTokenSpy: ReturnType; let isAuthenticatedSpy: ReturnType; let getUserInfoSpy: ReturnType; let getDefaultOrgSpy: ReturnType; @@ -73,6 +74,7 @@ describe("statusCommand.func", () => { beforeEach(async () => { getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig"); + getAuthTokenSpy = spyOn(dbAuth, "getAuthToken"); isAuthenticatedSpy = spyOn(dbAuth, "isAuthenticated"); getUserInfoSpy = spyOn(dbUser, "getUserInfo"); getDefaultOrgSpy = spyOn(dbDefaults, "getDefaultOrganization"); @@ -81,6 +83,7 @@ describe("statusCommand.func", () => { listOrgsSpy = spyOn(apiClient, "listOrganizationsUncached"); // Defaults that most tests override + getAuthTokenSpy.mockReturnValue("fake-oauth-token"); getUserInfoSpy.mockReturnValue(null); getDefaultOrgSpy.mockReturnValue(null); getDefaultProjectSpy.mockReturnValue(null); @@ -92,6 +95,7 @@ describe("statusCommand.func", () => { afterEach(() => { getAuthConfigSpy.mockRestore(); + getAuthTokenSpy.mockRestore(); isAuthenticatedSpy.mockRestore(); getUserInfoSpy.mockRestore(); getDefaultOrgSpy.mockRestore(); @@ -266,6 +270,34 @@ describe("statusCommand.func", () => { expect(getOutput()).toContain("sntrys_env_token_123_long_enough"); }); + + test("hint says 'organization' for sntrys_ env tokens", async () => { + getAuthConfigSpy.mockReturnValue({ + token: "sntrys_env_token_123", + source: "env:SENTRY_AUTH_TOKEN", + }); + isAuthenticatedSpy.mockReturnValue(true); + getAuthTokenSpy.mockReturnValue("sntrys_env_token_123"); + + const { context, getOutput } = createContext(); + await func.call(context, humanFlags); + + expect(getOutput()).toContain("organization"); + }); + + test("hint says 'user' for non-sntrys_ env tokens", async () => { + getAuthConfigSpy.mockReturnValue({ + token: "some_oauth_env_token", + source: "env:SENTRY_AUTH_TOKEN", + }); + isAuthenticatedSpy.mockReturnValue(true); + getAuthTokenSpy.mockReturnValue("some_oauth_env_token"); + + const { context, getOutput } = createContext(); + await func.call(context, humanFlags); + + expect(getOutput()).toContain("which user"); + }); }); describe("env var token (SENTRY_TOKEN)", () => { diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index 697942587..e2f65d7a9 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -27,6 +27,7 @@ import { CliError, ResolutionError, } from "../../../src/lib/errors.js"; +import { mintSntrysToken } from "../../helpers.js"; type WhoamiFlags = { readonly json: boolean }; @@ -55,6 +56,27 @@ const ID_ONLY_USER = { */ const OAUTH_TOKEN = "17faa5dfa5e64d5a9b3e8bf7c4d5e6f7a8b9c0d1e2f3a4b567ee"; +/** Well-formed sntrys_ token with parseable claim. */ +const ORG_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.acme.com", + region_url: "https://us.sentry.acme.com", + org: "acme", +}); + +/** Well-formed sntrys_ token without org field (older tokens). */ +const ORG_TOKEN_NO_ORG = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + region_url: "https://us.sentry.io", +}); + +/** sntrys_ token whose claim lacks iat — parseSntrysClaim returns undefined. */ +const MALFORMED_ORG_TOKEN = mintSntrysToken({ + url: "https://sentry.acme.com", + org: "acme", +}); + function createContext() { const output: string[] = []; const context = { @@ -147,15 +169,68 @@ describe("whoamiCommand.func", () => { }); }); - describe("org auth token short-circuit", () => { - test("throws ResolutionError and skips API call", async () => { - getAuthTokenSpy.mockReturnValue("sntrys_abc123def456"); + describe("org auth token — well-formed claim", () => { + beforeEach(() => { + getAuthTokenSpy.mockReturnValue(ORG_TOKEN); + }); + + test("yields org identity instead of calling the API", async () => { + const { context, getOutput } = createContext(); + await func.call(context, { json: false }); + + expect(getCurrentUserSpy).not.toHaveBeenCalled(); + expect(setUserInfoSpy).not.toHaveBeenCalled(); + const out = getOutput(); + expect(out).toContain("Organization auth token"); + expect(out).toContain("acme"); + expect(out).toContain("https://sentry.acme.com"); + }); + + test("JSON output includes type, organization, url, and regionUrl", async () => { + const { context, getOutput } = createContext(); + await func.call(context, { json: true }); + + const parsed = JSON.parse(getOutput()); + expect(parsed.type).toBe("org-auth-token"); + expect(parsed.organization).toBe("acme"); + expect(parsed.url).toBe("https://sentry.acme.com"); + expect(parsed.regionUrl).toBe("https://us.sentry.acme.com"); + }); + }); + + describe("org auth token — well-formed claim without org field", () => { + beforeEach(() => { + getAuthTokenSpy.mockReturnValue(ORG_TOKEN_NO_ORG); + }); + + test("yields output without organization row", async () => { + const { context, getOutput } = createContext(); + await func.call(context, { json: false }); + + expect(getCurrentUserSpy).not.toHaveBeenCalled(); + const out = getOutput(); + expect(out).toContain("Organization auth token"); + expect(out).toContain("https://sentry.io"); + expect(out).not.toContain("acme"); + }); + + test("JSON output omits organization when claim has no org", async () => { + const { context, getOutput } = createContext(); + await func.call(context, { json: true }); + + const parsed = JSON.parse(getOutput()); + expect(parsed.type).toBe("org-auth-token"); + expect(parsed).not.toHaveProperty("organization"); + expect(parsed.url).toBe("https://sentry.io"); + }); + }); + + describe("org auth token — malformed claim", () => { + test("throws ResolutionError when claim parsing fails", async () => { + getAuthTokenSpy.mockReturnValue(MALFORMED_ORG_TOKEN); 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); @@ -166,7 +241,7 @@ describe("whoamiCommand.func", () => { }); test("error message points to auth status and org list", async () => { - getAuthTokenSpy.mockReturnValue("sntrys_abc"); + getAuthTokenSpy.mockReturnValue(MALFORMED_ORG_TOKEN); const { context } = createContext(); diff --git a/test/lib/token-claims.test.ts b/test/lib/token-claims.test.ts index 95d60c3d1..9cbe0ff2e 100644 --- a/test/lib/token-claims.test.ts +++ b/test/lib/token-claims.test.ts @@ -27,6 +27,7 @@ describe("parseSntrysClaim", () => { expect(parseSntrysClaim(token)).toEqual({ url: "https://sentry.acme.com", regionUrl: "https://us.sentry.acme.com", + org: "acme", }); }); @@ -39,6 +40,7 @@ describe("parseSntrysClaim", () => { expect(parseSntrysClaim(token)).toEqual({ url: "https://sentry.acme.com", regionUrl: undefined, + org: "acme", }); }); @@ -153,6 +155,32 @@ describe("parseSntrysClaim", () => { expect(parseSntrysClaim(token)?.regionUrl).toBeUndefined(); }); + test("returns org as undefined when org field is missing from payload", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + + test("ignores non-string org (treats as absent)", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + org: 42, + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + + test("ignores empty-string org (treats as absent)", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + org: "", + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + test("does NOT throw on adversarial inputs", () => { // Catch-all: any input must either return undefined or a valid // claim object — never throw. @@ -184,6 +212,7 @@ describe("parseSntrysClaim", () => { expect(parseSntrysClaim(forged)).toEqual({ url: "https://evil.com", regionUrl: undefined, + org: "victim", }); }); });