feat(auth): harden OAuth state generation and callback validation (closes #1562)

generateOAuthState() now throws when crypto.getRandomValues is unavailable
instead of silently degrading to a non-cryptographic Math.random() fallback —
OAuth state is a CSRF token and must be unpredictable.

The web /oauth/callback handler now rejects a returned `state` param that does
not parse to the expected 64-char-hex authId shape (a forgery indicator),
surfacing a clear error toast instead of proceeding with an undefined session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
cliffhall 2026-07-01 18:23:38 -04:00
commit 2b84281346

View file

@ -1589,3 +1589,56 @@ describe("App history pin/replay", () => {
);
});
});
// The `/oauth/callback` handler must reject a returned `state` that does not
// parse to the expected 64-char-hex authId shape (a forgery indicator) instead
// of silently proceeding. See #1562.
describe("App OAuth callback state validation", () => {
const originalUrl = window.location.href;
beforeEach(() => {
notificationsMock.show.mockClear();
window.sessionStorage.clear();
});
afterEach(() => {
window.history.replaceState({}, "", originalUrl);
});
it("rejects an unparseable state param with a clear error toast", async () => {
window.history.replaceState(
{},
"",
"/oauth/callback?code=abc123&state=not-a-valid-state",
);
renderWithMantine(<App />);
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "OAuth callback rejected",
color: "red",
}),
),
);
});
it("does not reject when the state param parses to a valid authId", async () => {
// A well-formed 64-char-hex state is accepted; the handler proceeds past
// the rejection guard (it fails later for other reasons in this harness,
// but never shows the 'OAuth callback rejected' toast).
window.history.replaceState(
{},
"",
`/oauth/callback?code=abc123&state=${"a".repeat(64)}`,
);
renderWithMantine(<App />);
await waitFor(() => expect(notificationsMock.show).toHaveBeenCalled());
expect(notificationsMock.show).not.toHaveBeenCalledWith(
expect.objectContaining({ title: "OAuth callback rejected" }),
);
});
});