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:
parent
f94729f5f7
commit
efd11373b8
3 changed files with 76 additions and 6 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
21
src/oauth.ts
21
src/oauth.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue