diff --git a/src/app.ts b/src/app.ts index 09d8266..670320a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1332,8 +1332,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise= 100 && status <= 599 ? status : null, }, createdAt: nowIso, }); diff --git a/src/oauth.ts b/src/oauth.ts index 564b7b8..0d3872c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -59,8 +59,19 @@ export interface OAuthProviderRuntime { }): Promise; } -function buildErrorMessage(status: number, body: string, fallback: string): string { - return `${fallback} (status ${status}): ${body || "empty response"}`; +/** + * A provider answered with a non-2xx status. It carries the status and never + * the response body: a provider's body can quote the request, and whatever + * catches this may record or print it. + */ +export class ProviderHttpError extends Error { + readonly status: number; + + constructor(status: number, failure: string) { + super(`${failure} (status ${status}).`); + this.name = "ProviderHttpError"; + this.status = status; + } } async function fetchJson(input: string | URL, init: RequestInit, fallbackError: string): Promise { @@ -68,7 +79,7 @@ async function fetchJson(input: string | URL, init: RequestInit, fallbackErro const text = await response.text(); if (!response.ok) { - throw new Error(buildErrorMessage(response.status, text, fallbackError)); + throw new ProviderHttpError(response.status, fallbackError); } return (text ? JSON.parse(text) : {}) as T; @@ -926,10 +937,10 @@ async function evaluateDiscordEntitlements(options: { }; } - const text = await response.text(); if (!response.ok) { - throw new Error(buildErrorMessage(response.status, text, "Discord guild member lookup failed")); + throw new ProviderHttpError(response.status, "Discord guild member lookup failed"); } + const text = await response.text(); const member = (text ? JSON.parse(text) : {}) as { nick?: string; diff --git a/tests/app.test.ts b/tests/app.test.ts index 3840756..a529150 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1511,7 +1511,64 @@ describe("the failed-callback audit event", () => { mode: "sign_in", errorClass: "Error", errorCode: "ENOTFOUND", + providerStatus: null, }); expect(JSON.stringify(events)).not.toContain("CANARY"); }); }); + +describe("the failed-callback audit event for a provider's HTTP refusal", () => { + async function auditAfterTokenExchangeAnswers(response: () => Response) { + globalThis.fetch = async () => response(); + const store = new InMemoryStore(); + const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() }); + apps.push(app); + const stateToken = await startDiscordSignIn(app); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + }); + expect(callback.statusCode).toBe(502); + const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; + return events.filter((event) => event.eventType === "oauth_callback_failed"); + } + + it("keeps the provider's status as a number and never its body", async () => { + const failed = await auditAfterTokenExchangeAnswers( + () => new Response('{"error":"invalid_grant","echo":"CANARYbody"}', { status: 401 }) + ); + + expect(failed).toHaveLength(1); + expect(failed[0]!.eventPayloadJson).toEqual({ + provider: "discord", + mode: "sign_in", + errorClass: "ProviderHttpError", + errorCode: null, + providerStatus: 401, + }); + expect(JSON.stringify(failed)).not.toContain("CANARY"); + }); + + it("keeps no status that is not an HTTP status", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + discord: { + ...createMockDiscordRuntime(), + async exchangeAuthorizationCode() { + throw Object.assign(new Error("refused"), { status: 9999 }); + }, + }, + }, + }); + apps.push(app); + const stateToken = await startDiscordSignIn(app); + await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` }); + + const events = [...(store as unknown as { auditEvents: Map }> }).auditEvents.values()]; + const failed = events.find((event) => event.eventType === "oauth_callback_failed"); + expect(failed?.eventPayloadJson.providerStatus).toBeNull(); + }); +});