Audit event keeps a provider's HTTP status, never its body

Self's ruling: the failed-callback audit event lost the provider's status
when it stopped storing the message. A provider's non-2xx answer is now a
ProviderHttpError carrying the status, and its message no longer holds the
response body, which nothing needs and which can quote the request. The
audit event adds providerStatus: an integer in 100-599, or null.

Tests drive the real Discord token exchange against a 401 whose body holds
the canary, and throw a status of 9999.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 16:03:57 +02:00
commit efd11373b8
3 changed files with 76 additions and 6 deletions

View file

@ -1332,8 +1332,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// This path catches store and provider failures: a Postgres or
// resolver error names a constraint, the host or the database, and
// JSON.parse quotes the provider's response body. The audit event
// keeps the error's class and code.
// keeps the error's class, its code, and a provider's HTTP status.
const code = (error as { code?: unknown }).code;
const status = (error as { status?: unknown }).status;
const errorClass = error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error";
const failure = "The provider callback could not be completed.";
const nowIso = new Date().toISOString();
@ -1346,6 +1347,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
errorClass,
errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null,
providerStatus: typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
},
createdAt: nowIso,
});

View file

@ -59,8 +59,19 @@ export interface OAuthProviderRuntime {
}): Promise<EntitlementEvaluation>;
}
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<T>(input: string | URL, init: RequestInit, fallbackError: string): Promise<T> {
@ -68,7 +79,7 @@ async function fetchJson<T>(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;

View file

@ -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<string, { eventType: string; eventPayloadJson: unknown }> }).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<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()];
const failed = events.find((event) => event.eventType === "oauth_callback_failed");
expect(failed?.eventPayloadJson.providerStatus).toBeNull();
});
});