Skip to content

Commit 058b105

Browse files
betegonclaude
andauthored
fix(init): enrich 401 Unauthorized errors with actionable guidance (#971)
When `sentry init` runs without an org argument it calls `listOrganizations()` to show a picker. If the token is invalid or expired that call returns 401. Before this change users saw: ``` ✗ Failed to list organizations: 401 Unauthorized ✗ Setup failed. ``` No hint about what to do. Compare that to 403, which already had `enrich403Detail()` pointing at the token settings page or suggesting re-auth. 401 was just falling through with the raw HTTP status. **After (env-var token):** ``` ✗ Could not list organizations (401 Unauthorized). Your SENTRY_AUTH_TOKEN token is invalid or has expired. Regenerate it at: https://sentry.io/settings/auth-tokens/ ✗ Setup failed. ``` **After (OAuth):** ``` ✗ Could not list organizations (401 Unauthorized). Not authenticated or your session has expired. Re-authenticate with: sentry auth login ✗ Setup failed. ``` ## What changed - `enrich401Detail()` in `infrastructure.ts` — mirrors `enrich403Detail()`, applied in both `throwApiError()` and `throwRawApiError()` - `resolveOrgSlug()` in `preflight.ts` — extended the existing 403 catch to also handle 401, returning `{ ok: false }` so the error surfaces through `ensureOrg` with the full enriched message (same flow as 403, same Sentry culprit attribution) - Fixed `throwRawApiError`'s JSON-stringify fallback to exclude 401 alongside 403 — otherwise `{"detail":null}` responses would produce noisy prefixes in the enriched message - Fixed a stale `sentry login` → `sentry auth login` in the empty-orgs fallback ## Test plan - Set `SENTRY_AUTH_TOKEN` to a garbage value, run `sentry init` with no org arg — should print the token hint + settings URL - `sentry auth logout`, run `sentry init` with no org arg — should print the session-expired + re-auth hint - `bun test test/lib/api/infrastructure.test.ts` — 17 tests pass Ref: CLI-1SD Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent ea8942e commit 058b105

6 files changed

Lines changed: 279 additions & 111 deletions

File tree

.lore.md

Lines changed: 0 additions & 84 deletions
Large diffs are not rendered by default.

src/commands/issue/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,7 @@ function build403Detail(originalDetail: unknown): string {
11651165
: `Your ${getActiveEnvVarName()} token may lack the required scopes`;
11661166
lines.push(
11671167
` • ${leader} (${scopeList})`,
1168-
" • Check token scopes at: https://sentry.io/settings/auth-tokens/"
1168+
" • Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
11691169
);
11701170
} else {
11711171
lines.push(" • Re-authenticate with: sentry auth login");

src/lib/api/infrastructure.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function enrich403Detail(rawDetail: string | undefined): string {
6464
);
6565
}
6666
lines.push(
67-
"Check token scopes at: https://sentry.io/settings/auth-tokens/"
67+
"Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
6868
);
6969
} else {
7070
lines.push(
@@ -75,6 +75,70 @@ function enrich403Detail(rawDetail: string | undefined): string {
7575
return lines.join("\n ");
7676
}
7777

78+
/**
79+
* Enrich a 401 Unauthorized error detail with actionable guidance.
80+
*
81+
* 401 means the token is missing, invalid, or expired — the identity cannot
82+
* be determined at all. Distinct from 403 (identity known, lacks permission).
83+
* Scope hints do not apply; the fix is always to re-authenticate or regenerate
84+
* the token.
85+
*
86+
* The Sentry API returns distinct `detail` strings we can branch on:
87+
* `"Token expired"` when the token is past its expiry date, `"Invalid token"`
88+
* when it is not found or malformed. We use this to give a more precise message
89+
* for env-var token users.
90+
*
91+
* For OAuth users the token lifecycle is transparent — `sentry-client.ts`
92+
* intercepts 401s and refreshes automatically. A 401 that reaches this function
93+
* means refresh failed and the user needs to re-authenticate via the browser.
94+
*
95+
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
96+
*/
97+
function enrich401Detail(rawDetail: string | undefined): string {
98+
const lines: string[] = [];
99+
if (rawDetail) {
100+
lines.push(rawDetail, "");
101+
}
102+
if (isEnvTokenActive()) {
103+
const expired = rawDetail?.toLowerCase().includes("expired");
104+
lines.push(
105+
`Your ${getActiveEnvVarName()} token ${expired ? "has expired" : "is not recognized or has been revoked"}.`,
106+
"Create a new token at: https://sentry.io/settings/account/api/auth-tokens/"
107+
);
108+
} else {
109+
lines.push(
110+
"Not authenticated or your session has expired.",
111+
"Re-authenticate with: sentry auth login"
112+
);
113+
}
114+
return lines.join("\n ");
115+
}
116+
117+
/**
118+
* Select and apply status-specific detail enrichment.
119+
*
120+
* Extracted from {@link throwApiError} and {@link throwRawApiError} to keep
121+
* their cognitive complexity within the linter limit. 403 and 401 get
122+
* actionable guidance; all other statuses pass the raw detail through.
123+
*
124+
* `hasUsableDetail` controls whether the raw detail string is forwarded to
125+
* the enrichment functions — passing `undefined` when false lets them render
126+
* without a noisy `{"detail":null}` prefix.
127+
*/
128+
function enrichDetail(
129+
status: number,
130+
detail: string | undefined,
131+
hasUsableDetail: boolean
132+
): string | undefined {
133+
if (status === 403) {
134+
return enrich403Detail(hasUsableDetail ? detail : undefined);
135+
}
136+
if (status === 401) {
137+
return enrich401Detail(hasUsableDetail ? detail : undefined);
138+
}
139+
return detail;
140+
}
141+
78142
/**
79143
* Parse Sentry's RFC 5988 Link response header to extract pagination cursors.
80144
*
@@ -127,10 +191,9 @@ export function throwApiError(
127191
? (error as { detail: unknown }).detail
128192
: undefined;
129193
const hasUsableDetail = rawDetail !== null && rawDetail !== undefined;
130-
// When the API returns `{ detail: null }` or `{ detail: undefined }`,
131-
// fall back to stringifying the whole error object for non-403 errors
132-
// (useful for debugging). For 403s, pass undefined to enrich403Detail
133-
// so the enrichment stands alone without a noisy `{}` prefix.
194+
// Enrichment functions (enrich403Detail, enrich401Detail) render better
195+
// when rawDetail is undefined — they stand alone without a noisy `{}`
196+
// prefix. For all other statuses, stringify the full error as a debug aid.
134197
const detail = hasUsableDetail
135198
? stringifyUnknown(rawDetail)
136199
: stringifyUnknown(error);
@@ -139,7 +202,7 @@ export function throwApiError(
139202
throw new ApiError(
140203
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
141204
status,
142-
is403 ? enrich403Detail(hasUsableDetail ? detail : undefined) : detail,
205+
enrichDetail(status, detail, hasUsableDetail),
143206
undefined,
144207
is403
145208
);
@@ -443,13 +506,13 @@ async function throwRawApiError(
443506
const text = await response.text();
444507
try {
445508
const parsed = JSON.parse(text) as { detail?: string };
446-
// Prefer the explicit `detail` field; fall back to the full JSON
447-
// for non-403 errors (useful for debugging). For 403s, pass
448-
// undefined so enrich403Detail stands alone without a noisy
449-
// `{"detail":null}` prefix.
509+
// Enriched statuses (403, 401) pass undefined when there is no
510+
// usable string detail so the enrichment renders without a noisy
511+
// `{"detail":null}` prefix. Other statuses get the full JSON as
512+
// a debug aid.
450513
if (typeof parsed.detail === "string") {
451514
detail = parsed.detail;
452-
} else if (response.status !== 403) {
515+
} else if (response.status !== 403 && response.status !== 401) {
453516
detail = JSON.stringify(parsed);
454517
}
455518
} catch {
@@ -476,7 +539,7 @@ async function throwRawApiError(
476539
throw new ApiError(
477540
`API request failed: ${response.status} ${response.statusText}`,
478541
response.status,
479-
is403 ? enrich403Detail(detail) : detail,
542+
enrichDetail(response.status, detail, detail !== undefined),
480543
endpoint,
481544
is403
482545
);

src/lib/init/preflight.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,38 @@ async function resolveTeam(
342342
}
343343
}
344344

345+
/**
346+
* Format a 403/401 ApiError from listOrganizations() into a { ok: false }
347+
* result, or re-throw if the error is something else.
348+
*
349+
* 403: token lacks org:read scope — user can bypass by supplying the org slug
350+
* directly. 401: token is invalid/expired — supplying an org won't help, only
351+
* re-authenticating will.
352+
*/
353+
function handleOrgListError(error: unknown): { ok: false; error: string } {
354+
if (error instanceof ApiError && error.status === 403) {
355+
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
356+
if (error.detail) {
357+
lines.push(error.detail, "");
358+
}
359+
lines.push(
360+
"Specify the org on the command line: sentry init <org-slug>/",
361+
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
362+
);
363+
return { ok: false, error: lines.join("\n ") };
364+
}
365+
if (error instanceof ApiError && error.status === 401) {
366+
const lines: string[] = [
367+
"Could not list organizations (401 Unauthorized).",
368+
];
369+
if (error.detail) {
370+
lines.push(error.detail);
371+
}
372+
return { ok: false, error: lines.join("\n ") };
373+
}
374+
throw error;
375+
}
376+
345377
async function resolveOrgSlug(
346378
cwd: string,
347379
yes: boolean,
@@ -356,23 +388,12 @@ async function resolveOrgSlug(
356388
try {
357389
orgs = await listOrganizations();
358390
} catch (error) {
359-
if (error instanceof ApiError && error.status === 403) {
360-
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
361-
if (error.detail) {
362-
lines.push(error.detail, "");
363-
}
364-
lines.push(
365-
"Specify the org on the command line: sentry init <org-slug>/",
366-
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
367-
);
368-
return { ok: false, error: lines.join("\n ") };
369-
}
370-
throw error;
391+
return handleOrgListError(error);
371392
}
372393
if (orgs.length === 0) {
373394
return {
374395
ok: false,
375-
error: "Not authenticated. Run 'sentry login' first.",
396+
error: "Not authenticated. Run 'sentry auth login' first.",
376397
};
377398
}
378399
if (orgs.length === 1 && orgs[0]) {

test/lib/api/infrastructure.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe("throwApiError", () => {
166166
);
167167
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
168168
expect(apiError.detail).toContain(
169-
"https://sentry.io/settings/auth-tokens/"
169+
"https://sentry.io/settings/account/api/auth-tokens/"
170170
);
171171
}
172172
});
@@ -336,4 +336,130 @@ describe("throwApiError", () => {
336336
});
337337
});
338338
});
339+
340+
describe("401 enrichment", () => {
341+
// Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true
342+
// by default in these tests.
343+
344+
test("uses 'not recognized or has been revoked' for invalid token", () => {
345+
const mockResponse = new Response("", {
346+
status: 401,
347+
statusText: "Unauthorized",
348+
});
349+
350+
try {
351+
throwApiError(
352+
{ detail: "Invalid token" },
353+
mockResponse,
354+
"Failed to list organizations"
355+
);
356+
} catch (error) {
357+
const apiError = error as ApiError;
358+
expect(apiError.status).toBe(401);
359+
expect(apiError.message).toBe(
360+
"Failed to list organizations: 401 Unauthorized"
361+
);
362+
expect(apiError.detail).toContain("Invalid token");
363+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
364+
expect(apiError.detail).toContain("not recognized or has been revoked");
365+
expect(apiError.detail).toContain(
366+
"https://sentry.io/settings/account/api/auth-tokens/"
367+
);
368+
}
369+
});
370+
371+
test("uses 'has expired' for Token expired detail", () => {
372+
const mockResponse = new Response("", {
373+
status: 401,
374+
statusText: "Unauthorized",
375+
});
376+
377+
try {
378+
throwApiError(
379+
{ detail: "Token expired" },
380+
mockResponse,
381+
"Failed to list organizations"
382+
);
383+
} catch (error) {
384+
const apiError = error as ApiError;
385+
expect(apiError.status).toBe(401);
386+
expect(apiError.detail).toContain("Token expired");
387+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
388+
expect(apiError.detail).toContain("has expired");
389+
expect(apiError.detail).not.toContain("not recognized");
390+
expect(apiError.detail).toContain(
391+
"https://sentry.io/settings/account/api/auth-tokens/"
392+
);
393+
}
394+
});
395+
396+
test("falls back to 'not recognized' when detail is absent", () => {
397+
const mockResponse = new Response("", {
398+
status: 401,
399+
statusText: "Unauthorized",
400+
});
401+
402+
try {
403+
throwApiError(
404+
{ detail: undefined },
405+
mockResponse,
406+
"Failed to list organizations"
407+
);
408+
} catch (error) {
409+
const apiError = error as ApiError;
410+
expect(apiError.status).toBe(401);
411+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
412+
expect(apiError.detail).toContain("not recognized or has been revoked");
413+
expect(apiError.detail).not.toMatch(/^undefined/);
414+
expect(apiError.detail).not.toContain("{}");
415+
}
416+
});
417+
418+
describe("with OAuth token (no env var)", () => {
419+
let savedAuthToken: string | undefined;
420+
let savedToken: string | undefined;
421+
422+
beforeEach(() => {
423+
savedAuthToken = process.env.SENTRY_AUTH_TOKEN;
424+
savedToken = process.env.SENTRY_TOKEN;
425+
delete process.env.SENTRY_AUTH_TOKEN;
426+
delete process.env.SENTRY_TOKEN;
427+
});
428+
429+
afterEach(() => {
430+
if (savedAuthToken !== undefined) {
431+
process.env.SENTRY_AUTH_TOKEN = savedAuthToken;
432+
} else {
433+
delete process.env.SENTRY_AUTH_TOKEN;
434+
}
435+
if (savedToken !== undefined) {
436+
process.env.SENTRY_TOKEN = savedToken;
437+
} else {
438+
delete process.env.SENTRY_TOKEN;
439+
}
440+
});
441+
442+
test("suggests re-authentication for OAuth tokens", () => {
443+
const mockResponse = new Response("", {
444+
status: 401,
445+
statusText: "Unauthorized",
446+
});
447+
448+
try {
449+
throwApiError(
450+
{ detail: "Authentication credentials were not provided." },
451+
mockResponse,
452+
"Failed to list organizations"
453+
);
454+
} catch (error) {
455+
const apiError = error as ApiError;
456+
expect(apiError.status).toBe(401);
457+
expect(apiError.detail).toContain("session has expired");
458+
expect(apiError.detail).toContain("sentry auth login");
459+
// Should NOT mention SENTRY_AUTH_TOKEN
460+
expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN");
461+
}
462+
});
463+
});
464+
});
339465
});

test/lib/init/preflight.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,48 @@ describe("resolveInitContext", () => {
317317
expect(feedbackOutcomes(calls)).toEqual(["cancelled"]);
318318
});
319319

320+
test("surfaces 403 guidance when listOrganizations is forbidden", async () => {
321+
resolveOrgPrefetchedSpy.mockResolvedValue(null);
322+
listOrganizationsSpy.mockRejectedValueOnce(
323+
new ApiError(
324+
"Failed to list organizations",
325+
403,
326+
"You do not have permission."
327+
)
328+
);
329+
330+
const { ui, calls } = createMockUI();
331+
await expect(
332+
resolveInitContext(makeOptions({ yes: true }), ui)
333+
).rejects.toThrow("403 Forbidden");
334+
335+
const errorCall = calls.find(
336+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
337+
c.kind === "log.error"
338+
);
339+
expect(errorCall?.message).toContain("403 Forbidden");
340+
expect(errorCall?.message).toContain("sentry init <org-slug>/");
341+
});
342+
343+
test("surfaces 401 guidance when listOrganizations is unauthorized", async () => {
344+
resolveOrgPrefetchedSpy.mockResolvedValue(null);
345+
listOrganizationsSpy.mockRejectedValueOnce(
346+
new ApiError("Failed to list organizations", 401, "Token expired")
347+
);
348+
349+
const { ui, calls } = createMockUI();
350+
await expect(
351+
resolveInitContext(makeOptions({ yes: true }), ui)
352+
).rejects.toThrow("401 Unauthorized");
353+
354+
const errorCall = calls.find(
355+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
356+
c.kind === "log.error"
357+
);
358+
expect(errorCall?.message).toContain("401 Unauthorized");
359+
expect(errorCall?.message).toContain("Token expired");
360+
});
361+
320362
test("includes the auth token in the resolved context", async () => {
321363
const { ui } = createMockUI();
322364
const context = await resolveInitContext(makeOptions(), ui);

0 commit comments

Comments
 (0)