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
23 changes: 23 additions & 0 deletions src/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
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 {
applyFreshFlag,
FRESH_ALIASES,
FRESH_FLAG,
} from "../../lib/list-command.js";
import { classifySentryToken } from "../../lib/token-type.js";

type WhoamiFlags = {
readonly json: boolean;
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,20 @@ export async function apiRequestToRegion<T>(
} 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,
Expand Down
40 changes: 40 additions & 0 deletions src/lib/token-type.ts
Original file line number Diff line number Diff line change
@@ -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";
}
75 changes: 72 additions & 3 deletions test/commands/auth/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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 = {
Expand All @@ -66,19 +76,25 @@ function createContext() {

describe("whoamiCommand.func", () => {
let isAuthenticatedSpy: ReturnType<typeof spyOn>;
let getAuthTokenSpy: ReturnType<typeof spyOn>;
let getCurrentUserSpy: ReturnType<typeof spyOn>;
let setUserInfoSpy: ReturnType<typeof spyOn>;
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();
});
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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);
Expand Down
103 changes: 103 additions & 0 deletions test/lib/token-type.property.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading