Skip to content

Commit 8f2035d

Browse files
fix(cli): Name the scopes Sentry accepts when a 403 says which are missing (#2165)
Stacked on #2163. Split out of #2160. About 1% of `release set-commits` runs hit a 403 on the repos list: over 14 days, about 41k on 0.45 and 126 on 0.49. The repos list accepts `org:ci` but not `project:releases`, so a personal token made for releases fails there with no hint why. Since getsentry/sentry#118612, Sentry lists the accepted scopes in `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. The CLI now shows them. ### Before `release set-commits`, with a personal token in `SENTRY_AUTH_TOKEN` that lacks `org:ci`: ``` Error: Failed to list repositories: 403 Forbidden You do not have permission to perform this action. Your SENTRY_AUTH_TOKEN token may lack the required scope for this operation. Check token scopes at: <sentry-url>/settings/account/api/auth-tokens/ ``` ### After ``` Error: Failed to list repositories: 403 Forbidden You do not have permission to perform this action. Your SENTRY_AUTH_TOKEN token lacks the scope this request needs. Any one of these works: org:admin, org:ci, org:integrations, org:read, org:write. An org auth token has org:ci. Create one in your organization's Settings > Auth Tokens. Check token scopes at: <sentry-url>/settings/account/api/auth-tokens/ ``` The second line depends on the credential that sent the request: - **Org auth token, on an endpoint `org:ci` can't satisfy:** `Org auth tokens only have org:ci. Use a personal token with one of these scopes instead.` - **`sentry auth login` session:** `Your login lacks the scope this request needs. Any one of these works: …`, followed by `auth login --force`, or `auth refresh --scope <default scopes>,<needed scope>` when none of the default scopes is accepted. Self-hosted servers older than getsentry/sentry#118612 don't send the header, so they keep the "may lack the required scope" message. <!-- junior-request-attribution:start --> via @betegon. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3A28e783cd48c13b214d69caf6d8977a0c --> -- [View Session in Sentry](https://sentry.sentry.io/explore/conversations/agent%3A28e783cd48c13b214d69caf6d8977a0c/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: bete <28983162+betegon@users.noreply.github.com>
1 parent 263382d commit 8f2035d

3 files changed

Lines changed: 182 additions & 8 deletions

File tree

‎packages/cli/src/lib/api-scope.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,34 @@ function extractFromText(text: string): string[] {
145145
}
146146
return [...new Set(matches.map((m) => m.toLowerCase()))];
147147
}
148+
149+
const INSUFFICIENT_SCOPE_ERROR_RE = /\berror="insufficient_scope"/i;
150+
const CHALLENGE_SCOPE_RE = /\bscope="([^"]*)"/i;
151+
const SCOPE_NAME_RE = /^[a-z_]+:[a-z_]+$/i;
152+
153+
/**
154+
* Read the scopes from an RFC 6750 `insufficient_scope` challenge.
155+
*
156+
* Sentry answers a token that lacks every scope an endpoint accepts with a
157+
* 403 and `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
158+
* (getsentry/sentry#118612). The header lists each scope the endpoint
159+
* accepts, and any one of them is enough. Other 403s, such as a token for
160+
* another organization, come without it.
161+
*
162+
* @param header - The `WWW-Authenticate` response header
163+
* @returns The accepted scopes in the server's order. Empty when the header
164+
* is missing or is not an `insufficient_scope` challenge.
165+
*/
166+
export function parseInsufficientScopeChallenge(
167+
header: string | null | undefined,
168+
): string[] {
169+
if (!(header && INSUFFICIENT_SCOPE_ERROR_RE.test(header))) {
170+
return [];
171+
}
172+
const scopes = header.match(CHALLENGE_SCOPE_RE)?.[1] ?? "";
173+
return [
174+
...new Set(
175+
scopes.split(/\s+/).filter((scope) => SCOPE_NAME_RE.test(scope)),
176+
),
177+
];
178+
}

‎packages/cli/src/lib/api/infrastructure.ts‎

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@ import {
2727
import * as Sentry from "@sentry/node";
2828
import { type GenericSchema, safeParse } from "valibot";
2929

30-
import { extractRequiredScopes } from "../api-scope.js";
30+
import {
31+
extractRequiredScopes,
32+
parseInsufficientScopeChallenge,
33+
SENTRY_SCOPES,
34+
} from "../api-scope.js";
3135
import { getUrlLoginCommand, sentry } from "../app-identity.js";
3236
import {
3337
type CredentialContext,
3438
getActiveEnvVarName,
39+
getCredentialContext,
3540
isEnvTokenActive,
3641
} from "../db/auth.js";
3742
import { getConfiguredSentryUrl } from "../constants.js";
@@ -53,13 +58,73 @@ import {
5358
} from "../sentry-client.js";
5459
import { getSdkClientConfig } from "../sentry-sdk-client.js";
5560
import { isSentrySaasUrl } from "../sentry-urls.js";
61+
import { OAUTH_SCOPES } from "../oauth.js";
62+
import { parseSntrysClaim } from "../token-claims.js";
5663

5764
/** User-facing detail for a 401/403, and the reason to record for it. */
5865
type EnrichedDetail = {
5966
detail: string;
6067
denialReason: ApiDenialReason;
6168
};
6269

70+
/** The scope org auth tokens have, and that Sentry's CI endpoints accept. */
71+
const ORG_CI_SCOPE = "org:ci";
72+
73+
/** How to name the token a request sent: its env var, or the stored one. */
74+
function tokenHolder(credential: CredentialContext | undefined): string {
75+
return credential?.source.startsWith("env:")
76+
? `Your ${getActiveEnvVarName()}`
77+
: "Your stored";
78+
}
79+
80+
/** Guidance for a 403 whose challenge named the scopes the endpoint accepts. */
81+
function acceptedScopeLines(acceptedScopes: string[]): string[] {
82+
const scopeList = acceptedScopes.join(", ");
83+
// Advise on the credential the request sent, which can be a stored login
84+
// even when an env var also holds a token.
85+
const credential = getCredentialContext();
86+
if (credential?.refreshable) {
87+
// `auth refresh --scope` grants exactly the listed scopes, so never name
88+
// one scope alone: that would take the rest of the login's access away.
89+
// Same rule as the elevation in `scope-recovery.ts`.
90+
const lines = [
91+
`Your login lacks the scope this request needs. Any one of these works: ${scopeList}.`,
92+
];
93+
if (acceptedScopes.some((s) => OAUTH_SCOPES.includes(s))) {
94+
lines.push(
95+
`Log in again with the CLI's default scopes: ${sentry`auth login --force`}`,
96+
);
97+
return lines;
98+
}
99+
// Sentry lists accepted scopes alphabetically, so `admin` comes before
100+
// `write`. SENTRY_SCOPES orders each namespace read, write, admin, so its
101+
// first match is the least privileged scope `auth refresh` accepts.
102+
const extra = SENTRY_SCOPES.find((s) => acceptedScopes.includes(s));
103+
if (extra) {
104+
lines.push(
105+
`Log in again with the default scopes plus ${extra}: ${sentry`auth refresh --scope ${[...OAUTH_SCOPES, extra].join(",")}`}`,
106+
);
107+
}
108+
return lines;
109+
}
110+
const lines = [
111+
`${tokenHolder(credential)} token lacks the scope this request needs. Any one of these works: ${scopeList}.`,
112+
];
113+
if (parseSntrysClaim(credential?.token)) {
114+
lines.push(
115+
"Org auth tokens only have org:ci. Use a personal token with one of these scopes instead.",
116+
);
117+
} else if (acceptedScopes.includes(ORG_CI_SCOPE)) {
118+
lines.push(
119+
"An org auth token has org:ci. Create one in your organization's Settings > Auth Tokens.",
120+
);
121+
}
122+
lines.push(
123+
"Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/",
124+
);
125+
return lines;
126+
}
127+
63128
/**
64129
* Enrich a 403 Forbidden error detail with actionable guidance.
65130
*
@@ -69,11 +134,18 @@ type EnrichedDetail = {
69134
* scope/re-auth enrichment entirely — suggesting re-authentication for this
70135
* error would be actively wrong and has caused user confusion (CLI-SERVER-E).
71136
*
72-
* All other 403s fall through to the existing logic:
137+
* Then, in order:
138+
* - an `insufficient_scope` challenge → name the scopes the endpoint accepts
73139
* - env-var tokens → suggest checking token scopes
74140
* - OAuth tokens → suggest re-authentication
141+
*
142+
* @param acceptedScopes - Scopes from the response's `insufficient_scope`
143+
* challenge; any one of them works
75144
*/
76-
function enrich403Detail(rawDetail: string | undefined): EnrichedDetail {
145+
function enrich403Detail(
146+
rawDetail: string | undefined,
147+
acceptedScopes: string[],
148+
): EnrichedDetail {
77149
// Org-level policy — re-auth and token scope advice do not apply here.
78150
if (rawDetail?.includes("disabled this feature")) {
79151
return {
@@ -92,6 +164,11 @@ function enrich403Detail(rawDetail: string | undefined): EnrichedDetail {
92164
lines.push(rawDetail, "");
93165
}
94166

167+
if (acceptedScopes.length > 0) {
168+
lines.push(...acceptedScopeLines(acceptedScopes));
169+
return { detail: lines.join("\n "), denialReason: "insufficient_scope" };
170+
}
171+
95172
const scopes = extractRequiredScopes(rawDetail);
96173

97174
if (isEnvTokenActive()) {
@@ -195,12 +272,16 @@ function enrich401Detail(rawDetail: string | undefined): EnrichedDetail {
195272
* without a noisy `{"detail":null}` prefix.
196273
*/
197274
function enrichDetail(
198-
status: number,
275+
response: Response,
199276
detail: string | undefined,
200277
hasUsableDetail: boolean,
201278
): { detail: string | undefined; denialReason?: ApiDenialReason } {
279+
const { status } = response;
202280
if (status === 403) {
203-
return enrich403Detail(hasUsableDetail ? detail : undefined);
281+
return enrich403Detail(
282+
hasUsableDetail ? detail : undefined,
283+
parseInsufficientScopeChallenge(response.headers.get("www-authenticate")),
284+
);
204285
}
205286
if (status === 401) {
206287
return enrich401Detail(hasUsableDetail ? detail : undefined);
@@ -397,7 +478,7 @@ export function throwApiError(
397478
? stringifyUnknown(rawDetail)
398479
: stringifyUnknown(error);
399480

400-
const enriched = enrichDetail(status, detail, hasUsableDetail);
481+
const enriched = enrichDetail(response, detail, hasUsableDetail);
401482
throw new ApiError(
402483
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
403484
status,
@@ -942,7 +1023,7 @@ export async function throwRawApiError(
9421023
"x-sentry-error": response.headers.get("x-sentry-error"),
9431024
"www-authenticate": response.headers.get("www-authenticate"),
9441025
});
945-
const enriched = enrichDetail(response.status, detail, detail !== undefined);
1026+
const enriched = enrichDetail(response, detail, detail !== undefined);
9461027
throw new ApiError(
9471028
`${context}: ${response.status} ${response.statusText}`,
9481029
response.status,

‎packages/cli/test/lib/api/infrastructure.test.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
unwrapPaginatedResult,
1313
unwrapResult,
1414
} from "../../../src/lib/api/infrastructure.js";
15-
import { setAuthToken } from "../../../src/lib/db/auth.js";
15+
import { resetAuthTokenCache, setAuthToken } from "../../../src/lib/db/auth.js";
1616
import { ApiError, HostScopeError } from "../../../src/lib/errors.js";
17+
import { OAUTH_SCOPES } from "../../../src/lib/oauth.js";
1718
import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js";
1819

1920
describe("SDK error compatibility", () => {
@@ -816,6 +817,67 @@ describe("throwApiError", () => {
816817
// isTextualContentType + rawApiRequest binary handling (getsentry/cli#1303)
817818
// ---------------------------------------------------------------------------
818819

820+
describe("403 scope challenge guidance", () => {
821+
useTestConfigDir("api-denial-");
822+
useEnvSandbox([
823+
"SENTRY_AUTH_TOKEN",
824+
"SENTRY_TOKEN",
825+
"SENTRY_FORCE_ENV_TOKEN",
826+
]);
827+
828+
const ORG_TOKEN = `sntrys_${Buffer.from(JSON.stringify({ iat: 1, url: "https://sentry.io", org: "acme" })).toString("base64")}_secret`;
829+
830+
/** The detail of a 403 sent with `credential`, with an optional challenge. */
831+
function detailFor(
832+
credential: "personal" | "org" | "login",
833+
scopes?: string,
834+
): string | undefined {
835+
if (credential === "login") {
836+
setAuthToken("oauth-access-token", 3600, "oauth-refresh-token");
837+
} else {
838+
process.env.SENTRY_AUTH_TOKEN =
839+
credential === "org" ? ORG_TOKEN : "sntryu_example";
840+
process.env.SENTRY_FORCE_ENV_TOKEN = "1";
841+
}
842+
resetAuthTokenCache();
843+
const body = { detail: "Denied" };
844+
const headers: Record<string, string> = scopes
845+
? {
846+
"WWW-Authenticate": `Bearer error="insufficient_scope", scope="${scopes}"`,
847+
}
848+
: {};
849+
const response = Response.json(body, { status: 403, headers });
850+
try {
851+
throwApiError(body, response, "Request failed");
852+
} catch (error) {
853+
return (error as ApiError).detail;
854+
}
855+
throw new Error("expected throwApiError to throw");
856+
}
857+
858+
test.each([
859+
[
860+
"personal",
861+
"org:admin org:ci org:read",
862+
"Your SENTRY_AUTH_TOKEN token lacks the scope this request needs. Any one of these works: org:admin, org:ci, org:read.",
863+
],
864+
["personal", "org:admin org:ci org:read", "An org auth token has org:ci."],
865+
["org", "org:admin org:billing", "Org auth tokens only have org:ci."],
866+
["login", "org:admin org:read", "auth login --force"],
867+
[
868+
"login",
869+
"org:admin org:billing org:write",
870+
`auth refresh --scope ${[...OAUTH_SCOPES, "org:write"].join(",")}`,
871+
],
872+
] as const)("%s token, accepted %s: %s", (credential, scopes, expected) => {
873+
expect(detailFor(credential, scopes)).toContain(expected);
874+
});
875+
876+
test("keeps the generic hint for a 403 with no challenge", () => {
877+
expect(detailFor("personal")).toContain("may lack the required scope");
878+
});
879+
});
880+
819881
describe("isTextualContentType", () => {
820882
test.each([
821883
[null, true],

0 commit comments

Comments
 (0)