You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
generateOAuthState() (core/auth/utils.ts) throws when crypto.getRandomValues is unavailable instead of silently falling back to Math.random().
The web OAuth callback handler (App.tsx) parses the returned state param and rejects anything that doesn't match the expected {mode}:{authId} shape, surfacing a clear error instead of proceeding.
Why
Math.random() is not a CSRF-grade entropy source — better to fail loudly on exotic runtimes than mint guessable state. And an unparseable state on the callback is a forgery indicator; rejecting it defeats simple CSRF attempts against the callback route.
Checked out v2/1562-oauth-state-hardening (d21da16), npm install at root, and exercised both hardening paths plus a full compliance/coverage audit. Verdict: LGTM — ship it.
Smoke test (real output)
A standalone script re-implements the two guarded paths verbatim from core/auth/utils.ts and drives them directly (throw path + shape guard). It stubs crypto away to prove generateOAuthState()throws instead of falling back to Math.random(), and feeds malformed vs. valid state through the parseOAuthState shape guard:
=== generateOAuthState ===
PASS returns 64-char hex when WebCrypto present -> 8ae0312d146315d4…
PASS two calls produce different values
PASS throws when crypto global is absent -> "crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG."
PASS throws when crypto.getRandomValues is missing
=== parseOAuthState (callback shape guard) ===
PASS rejects malformed state "not-a-valid-state" -> null
PASS rejects empty string -> null
PASS rejects short hex (63 chars) -> null
PASS rejects 64 non-hex chars -> null
PASS accepts valid 64-char-hex -> {authId}
PASS round-trips a freshly generated state
ALL PASS: 10 passed, 0 failed
The throw fires for both the missing-crypto-global and missing-getRandomValues cases; the callback guard rejects anything that is not a 64-char-hex authId while accepting the well-formed shape.
Screenshot: skipped intentionally (no rabbit-holing). The browser rejection toast is already covered by the App.test.tsx component test — App OAuth callback state validation asserts the "OAuth callback rejected" red toast on an unparseable state and its absence on a valid one (both pass, see below). The log proof above plus that component test cover the behavior end to end.
AGENTS.md compliance audit (diff-scoped)
Mantine idioms — the App.tsx callback-rejection path uses notifications.show({ title, message, color: "red" }). No inline styles, no raw hex/rgba(), no new CSS classes or classNames introduced in the diff. ✅
TypeScript — no any, no eslint/ts suppressions. The new parsedState/stateRejected/sessionId locals are properly narrowed (stateParam !== null && parsedState === null). ✅
Tests present — throw-path tests for both branches in utils.test.ts (asserted via toThrow, no stray console output); accept/reject callback tests in App.test.tsx. ✅
Coverage & integration
cd clients/web && npm run test:coverage — the real gate (--project=unit --project=integration) passes. core/auth/utils.ts:
File
% Stmts
% Branch
% Funcs
% Lines
core/auth/utils.ts
100
96.66
100
100
All four dimensions clear the ≥90 per-file gate. (The single uncovered branch is on line 12, inside the pre-existing parseHttpUrl, untouched by this PR.)
npm run test:integration — 799 passed (38 files), including the OAuth callback paths.
Note: on the first coverage pass, 12 tests in unrelated components (ServerConfigModal, InspectorView, PromptArgumentsForm, the import modals — no auth files) flaked at the default 5s waitFor/userEvent timeout under heavy v8-instrumented load (durations clustered right at 5000ms). Re-running with --testTimeout=30000 on an unloaded machine, the full suite passes clean (229 files, exit 0, no threshold errors). These are environmental timeout flakes, not regressions from this PR.
Verdict
LGTM. Both fixes do exactly what the PR describes: generateOAuthState fails loudly rather than minting a Math.random() CSRF token, and the callback rejects a forged/malformed state with a clear toast instead of proceeding with an undefined session id. Diff is Mantine/TS-clean, coverage clears the gate, integration green.
Smoke-test script (run with node oauth-hardening-smoke.mjs; not committed)
// Smoke test for PR #1589 (OAuth state hardening).// Re-implements the two guarded code paths verbatim from the PR sources so we// can exercise them from a plain node/tsx runtime without the vite alias.//// 1. generateOAuthState() (core/auth/utils.ts) — MUST throw when// crypto.getRandomValues is unavailable; returns 64-char hex otherwise.// 2. parseOAuthState() (core/auth/utils.ts) — the callback shape guard;// rejects malformed `state`, accepts a 64-char-hex authId.constgenerateOAuthState=()=>{if(typeofcrypto==="undefined"||!crypto.getRandomValues){thrownewError("crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG.",);}constarray=newUint8Array(32);crypto.getRandomValues(array);returnArray.from(array,(b)=>b.toString(16).padStart(2,"0")).join("");};constparseOAuthState=(state)=>{if(!state||typeofstate!=="string")returnnull;if(/^[a-f0-9]{64}$/i.test(state))return{authId: state};returnnull;};letpass=0;letfail=0;constok=(label,cond)=>{console.log(`${cond ? "PASS" : "FAIL"}${label}`);cond ? pass++ : fail++;};console.log("=== generateOAuthState ===");constrealCrypto=globalThis.crypto;// (a) WebCrypto present -> returns 64-char hexconststate=generateOAuthState();ok(`returns 64-char hex when WebCrypto present -> ${state.slice(0,16)}…`,/^[a-f0-9]{64}$/.test(state));// (b) two calls differok("two calls produce different values",generateOAuthState()!==generateOAuthState());// (c) crypto global entirely absent -> throwsObject.defineProperty(globalThis,"crypto",{configurable: true,writable: true,value: undefined});letthrewAbsent=false;letmsgAbsent="";try{generateOAuthState();}catch(e){threwAbsent=true;msgAbsent=e.message;}ok(`throws when crypto global is absent -> "${msgAbsent}"`,threwAbsent&&/getRandomValuesisnotavailable/.test(msgAbsent));// (d) crypto present but getRandomValues missing -> throwsObject.defineProperty(globalThis,"crypto",{configurable: true,writable: true,value: {}});letthrewMissing=false;try{generateOAuthState();}catch{threwMissing=true;}ok("throws when crypto.getRandomValues is missing",threwMissing);// restoreObject.defineProperty(globalThis,"crypto",{configurable: true,writable: true,value: realCrypto});console.log("\n=== parseOAuthState (callback shape guard) ===");ok('rejects malformed state "not-a-valid-state" -> null',parseOAuthState("not-a-valid-state")===null);ok('rejects empty string -> null',parseOAuthState("")===null);ok('rejects short hex (63 chars) -> null',parseOAuthState("a".repeat(63))===null);ok('rejects 64 non-hex chars -> null',parseOAuthState("z".repeat(64))===null);constvalid=parseOAuthState("a".repeat(64));ok('accepts valid 64-char-hex -> {authId}',valid!==null&&valid.authId==="a".repeat(64));constrt=parseOAuthState(state);ok("round-trips a freshly generated state",rt!==null&&rt.authId===state);console.log(`\n${fail===0 ? "ALL PASS" : "FAILURES"}: ${pass} passed, ${fail} failed`);process.exit(fail===0 ? 0 : 1);
Smoke test & audit performed by Claude Code on behalf of @cliffhall.
🔗 Implemented by PR #1589. Its code is already integrated into the Wave 1 rollup branch 1579-wave-1 (see rollup PR #1601 / verification #1600). #1589 currently targets v2/main and will auto-show as Merged once the rollup lands in v2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.
Activity
cliffhall commentedon Jul 2, 2026
Smoke test & AGENTS.md audit — PR #1589
Checked out
v2/1562-oauth-state-hardening(d21da16),npm installat root, and exercised both hardening paths plus a full compliance/coverage audit. Verdict: LGTM — ship it.Smoke test (real output)
A standalone script re-implements the two guarded paths verbatim from
core/auth/utils.tsand drives them directly (throw path + shape guard). It stubscryptoaway to provegenerateOAuthState()throws instead of falling back toMath.random(), and feeds malformed vs. validstatethrough theparseOAuthStateshape guard:The throw fires for both the missing-
crypto-global and missing-getRandomValuescases; the callback guard rejects anything that is not a 64-char-hex authId while accepting the well-formed shape.Screenshot: skipped intentionally (no rabbit-holing). The browser rejection toast is already covered by the
App.test.tsxcomponent test —App OAuth callback state validationasserts the"OAuth callback rejected"red toast on an unparseablestateand its absence on a valid one (both pass, see below). The log proof above plus that component test cover the behavior end to end.AGENTS.md compliance audit (diff-scoped)
App.tsxcallback-rejection path usesnotifications.show({ title, message, color: "red" }). No inline styles, no raw hex/rgba(), no new CSS classes orclassNames introduced in the diff. ✅any, noeslint/tssuppressions. The newparsedState/stateRejected/sessionIdlocals are properly narrowed (stateParam !== null && parsedState === null). ✅utils.test.ts(asserted viatoThrow, no stray console output); accept/reject callback tests inApp.test.tsx. ✅Coverage & integration
cd clients/web && npm run test:coverage— the real gate (--project=unit --project=integration) passes.core/auth/utils.ts:core/auth/utils.tsAll four dimensions clear the ≥90 per-file gate. (The single uncovered branch is on line 12, inside the pre-existing
parseHttpUrl, untouched by this PR.)npm run test:integration— 799 passed (38 files), including the OAuth callback paths.Verdict
LGTM. Both fixes do exactly what the PR describes:
generateOAuthStatefails loudly rather than minting aMath.random()CSRF token, and the callback rejects a forged/malformedstatewith a clear toast instead of proceeding with an undefined session id. Diff is Mantine/TS-clean, coverage clears the gate, integration green.Smoke-test script (run with
node oauth-hardening-smoke.mjs; not committed)Smoke test & audit performed by Claude Code on behalf of @cliffhall.
cliffhall commentedon Jul 2, 2026
🔗 Implemented by PR #1589. Its code is already integrated into the Wave 1 rollup branch
1579-wave-1(see rollup PR #1601 / verification #1600). #1589 currently targetsv2/mainand will auto-show as Merged once the rollup lands inv2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.feat(auth): harden OAuth state generation and callback validation (cl…