Skip to content

feat(identity): auth Wave 1 — sign-in code (magic-link short form), ceremony code entry, post-consent return path - #174

Merged
unforced merged 1 commit into
mainfrom
auth-wave-1
Jul 17, 2026
Merged

feat(identity): auth Wave 1 — sign-in code (magic-link short form), ceremony code entry, post-consent return path#174
unforced merged 1 commit into
mainfrom
auth-wave-1

Conversation

@unforced

@unforced unforced commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Identity-worker half of Wave 1 from the ratified auth+onboarding redesign spec (task #34, §2 + §5). Two independent features, both additive:

  1. The typed code — OTP as a short-form spelling of the magic link. createMagicLink (magic-links.ts) now also mints a crypto-random 6-digit code bound to the SAME magic_links row (migration 0021: code_hash SHA-256-at-rest like token_hash, code_attempts). POST /auth/code verifies + consumes it through the identical resolveVerifiedUser + finishPrimaryAuth chokepoints GET /auth/verify already 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).
  2. The post-consent contract — the "Authorized, now what" dead end. renderRedirectBridge (ui.ts) gains an optional { clientName }. When the OAuth consent-approve leg's cross-origin bridge is actually invoked (oauth-authorize.ts bridgeIfCrossOrigin), 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 other renderRedirectBridge caller (billing/Stripe redirects, app deep-link doors) is semantically identical (insignificant whitespace; reviewer-verified no functional or security delta) when clientName is 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:

  • At-rest posture unchanged: the code is SHA-256-hashed exactly like the link's token (code_hash, no salt — same as token_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).
  • Two independent brute-force fences (belt and suspenders, per spec §2): (a) per-row code_attempts cap (MAGIC_CODE_MAX_ATTEMPTS = 5) — at cap, code_hash is 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.ts isLoginLocked/recordLoginFailure), keyed loginKey(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).
  • No oracle anywhere: wrong code, expired, unknown email, suspended account, and attempt-cap-tripped all answer the semantically identical (insignificant whitespace; reviewer-verified no functional or security delta) "That code didn't work — request a fresh link." Verified directly: unknown-email vs known-account wrong-code responses are asserted identical in auth.test.ts.
  • Suspended accounts never mint — same chokepoint as the link (resolveVerifiedUser, extracted from the pre-existing handleMagicVerifyGet inline logic, now shared by both). Tested for the "signup pending, suspended before the code is ever used" edge case explicitly (mirrors magicLinkDead's own documented scenario).
  • CSRF + same-origin gate identical to every other POST in this file (checkForm = verifyCsrfToken + isSameOriginRequest).
  • clientName in the post-consent bridge is UNTRUSTED (an OAuth client's own self-registered client_name via DCR, RFC 7591) — escaped via esc() 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 hostile client_name with </script> + raw quotes).
  • DB-query safety: verifyMagicCode's miss-path bumps code_attempts on every live row for the email (defends against widening the guess budget via multiple link requests) — scoped by email throughout (not just code_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.
  • Dev-echo header: the code rides a NEW sibling header (x-parachute-dev-magic-code), gated identically to the existing x-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 typecheck clean (both runs) · bun x vitest run33 files, 796 tests passed (both runs; 777 baseline + 19 new).
  • Root: bun run typecheck clean (both runs) · bun run test153 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 verifyMagicCode calls, 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 when clientName is omitted, injection safety, and two end-to-end router tests (named client vs unnamed client through the real consent-approve POST).

scripts/smoke-staging.ts gains: 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, additive
  • workers/identity/src/magic-links.tscreateMagicLink mints the code; new verifyMagicCode
  • workers/identity/src/crypto.tsrandomNumericCode (rejection-sampled, no modulo bias)
  • workers/identity/src/auth-handlers.tshandleCodeVerifyPost, resolveVerifiedUser extraction, dev-echo header
  • workers/identity/src/email.tssendMagicLink gains code; subject/body carry it
  • workers/identity/src/ui.tscodeForm, renderConsoleLogin/renderLogin/renderMagicSent updates, client-aware renderRedirectBridge
  • workers/identity/src/oauth-authorize.tsbridgeIfCrossOrigin resolves the client name (lazily, only when actually bridging)
  • workers/identity/src/index.ts, route-manifest.ts — route wiring (covered automatically by the existing /auth ceremony prefix)
  • Version bumped to 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 on ag-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

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant