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
2 changes: 1 addition & 1 deletion plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion plugins/sentry-cli/skills/sentry-cli/references/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
10 changes: 9 additions & 1 deletion src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ENV_SOURCE_PREFIX,
getActiveEnvVarName,
getAuthConfig,
getAuthToken,
getRawEnvToken,
isAuthenticated,
} from "../../lib/db/auth.js";
Expand All @@ -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;
Expand Down Expand Up @@ -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",
};
}
},
Expand Down
83 changes: 59 additions & 24 deletions src/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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, unknown>
): string {
if ("type" in data && data.type === "org-auth-token") {
return formatOrgTokenIdentity(data as OrgTokenIdentity);
}
return formatUserIdentity(data as Parameters<typeof formatUserIdentity>[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: {
Expand All @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <slug> (<url>)" or just the URL when slug is unknown.
*/
export function formatOrgTokenIdentity(data: OrgTokenIdentity): string {
const rows: Array<readonly [string, string]> = [];
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

/**
Expand Down
15 changes: 10 additions & 5 deletions src/lib/token-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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 };
}
32 changes: 32 additions & 0 deletions test/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function createContext() {

describe("statusCommand.func", () => {
let getAuthConfigSpy: ReturnType<typeof spyOn>;
let getAuthTokenSpy: ReturnType<typeof spyOn>;
let isAuthenticatedSpy: ReturnType<typeof spyOn>;
let getUserInfoSpy: ReturnType<typeof spyOn>;
let getDefaultOrgSpy: ReturnType<typeof spyOn>;
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -92,6 +95,7 @@ describe("statusCommand.func", () => {

afterEach(() => {
getAuthConfigSpy.mockRestore();
getAuthTokenSpy.mockRestore();
isAuthenticatedSpy.mockRestore();
getUserInfoSpy.mockRestore();
getDefaultOrgSpy.mockRestore();
Expand Down Expand Up @@ -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)", () => {
Expand Down
Loading
Loading