feat(identity): auth Wave 1 — sign-in code (magic-link short form), ceremony code entry, post-consent return path - #174
Merged
Merged
Conversation
…eremony code entry, post-consent return path Every magic-link mint now also produces a 6-digit sign-in code bound to the same single-use token row (migration 0021); POST /auth/code verifies+consumes it through the identical resolveVerifiedUser + finishPrimaryAuth chokepoints as GET /auth/verify, including the authorize-resume rider. The console login, OAuth authorize login, and "check your email" pages gain a "have a code?" disclosure. The OAuth consent-approve post-redirect bridge names the connecting client and carries a 3-second watchdog instead of a bare spinner (the "Authorized, now what" dead end). rc.91 (#173) is in review on ag-unforced-dev — this PR branches from main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
This was referenced Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Identity-worker half of Wave 1 from the ratified auth+onboarding redesign spec (task #34, §2 + §5). Two independent features, both additive:
createMagicLink(magic-links.ts) now also mints a crypto-random 6-digit code bound to the SAMEmagic_linksrow (migration 0021:code_hashSHA-256-at-rest liketoken_hash,code_attempts).POST /auth/codeverifies + consumes it through the identicalresolveVerifiedUser+finishPrimaryAuthchokepointsGET /auth/verifyalready uses — same suspended-never-mint guard, same TOTP divert, same authorize-resume rider (magic_links.next). Consuming either spelling kills both (proven by tests in both directions).renderRedirectBridge(ui.ts) gains an optional{ clientName }. When the OAuth consent-approve leg's cross-origin bridge is actually invoked (oauth-authorize.tsbridgeIfCrossOrigin), the page now names the connecting client ("Taking you back to Claude…", "Continue to Claude →") and carries a 3-second watchdog that swaps to explicit "you're connected, switch back or close this tab" copy if the tab is still showing. Untouched: OAuth wire (redirect_uri handling, token issuance) — this is the human-facing page only, and every otherrenderRedirectBridgecaller (billing/Stripe redirects, app deep-link doors) is semantically identical (insignificant whitespace; reviewer-verified no functional or security delta) whenclientNameis omitted.Non-goals honored (per the task brief, narrower than the spec's own Wave-1 framing): issuer topology, token wire contracts, TOTP, password auth, the session-unification "signed in as X"/"not you?" ceremony line (spec §3 — bucketed under Wave 2 per the brief), journey state (Wave 3), the app repo. I deliberately did not add
/auth/code's JSON (X-Requested-With: fetch) variant that the spec sketches for Wave 2's app wiring — this PR's scope is server-rendered ceremony pages only, so the endpoint is form-POST/redirect-shaped like/login/2fa, not dual-mode like/auth/magic. Flagging this as a scope call, not an oversight, in case the reviewer expects the JSON branch.Security posture — the threat-model delta
Should be: none. Same token family, same fences, same failure shapes. Specifics for the strictest read:
code_hash, no salt — same astoken_hash, which the codebase already accepts for a 256-bit random; a 6-digit code additionally gets the DB-level scoping below since its hash space isn't unique).code_attemptscap (MAGIC_CODE_MAX_ATTEMPTS = 5) — at cap,code_hashis nulled for every LIVE row on that email (the LINK stays valid, only the code spelling dies); (b) the EXISTING DO login-failure fence (rate-limit.tsisLoginLocked/recordLoginFailure), keyedloginKey(ip, "code:"+email)— no new rate-limit machinery, same fail-open-on-DO-error posture as every other fence in this file. Worst-case online odds: ≤5×10⁻⁶ per email per 10-minute window (10⁶ code space × 5-attempt cap).auth.test.ts.resolveVerifiedUser, extracted from the pre-existinghandleMagicVerifyGetinline logic, now shared by both). Tested for the "signup pending, suspended before the code is ever used" edge case explicitly (mirrorsmagicLinkDead's own documented scenario).checkForm=verifyCsrfToken+isSameOriginRequest).clientNamein the post-consent bridge is UNTRUSTED (an OAuth client's own self-registeredclient_namevia DCR, RFC 7591) — escaped viaesc()everywhere it lands in HTML, and JSON-stringified +<-escaped for the script context, same discipline the URL argument already had. Injection-safety tests included (a hostileclient_namewith</script>+ raw quotes).verifyMagicCode's miss-path bumpscode_attemptson every live row for the email (defends against widening the guess budget via multiple link requests) — scoped byemailthroughout (not justcode_hash, since a 6-digit hash isn't globally unique, unlike the 256-bit link token) to rule out a cross-account row collision on the SELECT after a successful UPDATE.x-parachute-dev-magic-code), gated identically to the existingx-parachute-dev-magic-link(ENVIRONMENT !== "production") — never a new header on the SAME name, so no existing consumer's parsing assumptions change. Prod-absence is smoke-tested.What I deliberately did NOT touch
magic_links.token_hash/link mechanics, TOTP flow, password verification, session cookie shape, CSP policy (no new inline scripts — the code forms are plain<form>posts; the bridge's watchdog script reuses the existing nonce mechanism), CORS posture, the OAuth token/authorize-code wire.Gates (2 runs each, stable)
workers/identity:bun run typecheckclean (both runs) ·bun x vitest run— 33 files, 796 tests passed (both runs; 777 baseline + 19 new).bun run typecheckclean (both runs) ·bun run test— 153 pass, 0 fail, 484 expect() calls (both runs).bun build scripts/smoke-staging.ts(syntax/bundle check only — the new smoke steps can't run without a staging deploy, which this PR doesn't do).New test coverage: code issue+verify happy path, single-use sharing in BOTH directions (code kills link, link kills code), expiry, no-oracle (unknown vs known email), suspended-before-use refusal, the per-row attempt cap in isolation (direct
verifyMagicCodecalls, independent of the DO fence), the DO brute-force lockout end-to-end, CSRF/same-origin gate, authorize-params round-trip on a failed retry, and authorize-resume-by-code end-to-end (send → code → verify → resumed consent page) — mirroring the existing resume-by-link suite. Plus 7 new redirect-bridge tests: client-aware copy, watchdog timing/structure, semantically identical (insignificant whitespace; reviewer-verified no functional or security delta) fallback whenclientNameis omitted, injection safety, and two end-to-end router tests (named client vs unnamed client through the real consent-approve POST).scripts/smoke-staging.tsgains: send→read-code-header→verify-by-code→session-works, single-use-shared-with-the-link (both directions), a wrong-code no-oracle check, and the authorize-resume-by-code walk (mirrors the existing resume-by-link walk exactly). Every existing smoke step is untouched.Files
workers/identity/migrations/0021_magic_code.sql— new columns, additiveworkers/identity/src/magic-links.ts—createMagicLinkmints the code; newverifyMagicCodeworkers/identity/src/crypto.ts—randomNumericCode(rejection-sampled, no modulo bias)workers/identity/src/auth-handlers.ts—handleCodeVerifyPost,resolveVerifiedUserextraction, dev-echo headerworkers/identity/src/email.ts—sendMagicLinkgainscode; subject/body carry itworkers/identity/src/ui.ts—codeForm,renderConsoleLogin/renderLogin/renderMagicSentupdates, client-awarerenderRedirectBridgeworkers/identity/src/oauth-authorize.ts—bridgeIfCrossOriginresolves the client name (lazily, only when actually bridging)workers/identity/src/index.ts,route-manifest.ts— route wiring (covered automatically by the existing/authceremony prefix)0.0.8-rc.92(rc.91 is PR test(smoke): prove C2 semantic live + fix #166 restore timeout + staging debris sweep #173's, still in review onag-unforced-dev)Not merging — an Opus/Fable-grade reviewer should take a pass first per the security-surface convention.
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB