trails/openspec/changes/unify-auth-completion/specs/authentication-methods/spec.md
Ullrich Schäfer 7e22f1260b
Add unify-auth-completion architecture artifacts
Extract the post-verify orchestration shared across passkey
register-finish, passkey login-finish, magic-link verify-code, and
magic-link click-through into a single completeAuth function. Two
ADRs record the decision:

- ADR-0004: centralize web auth completion (record terms + create
  session + redirect) in apps/journal/app/lib/auth/completion.ts.
- ADR-0005: explicitly no AuthMethod polymorphism. Passkey + magic-
  link is the entire identity surface; OAuth2/PKCE is session
  transport, not a peer method. Recorded as a negative decision so
  future architecture passes don't re-suggest extracting the
  interface.

CONTEXT.md gains an Authentication section covering completeAuth, the
two methods, the OAuth2-as-transport distinction, and where the Terms
gate enforcement lives (root loader for web, requireApiUser for API
per the just-merged mobile-terms-gate).

OpenSpec change unify-auth-completion captures the proposal, design
(5 decisions including the negative-scope choices), spec delta on
authentication-methods, and 14 tasks. Implementation follows on this
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:18:06 +02:00

3.2 KiB

ADDED Requirements

Requirement: Single web auth completion chokepoint

Every successful web authentication flow — passkey register-finish, passkey login-finish, magic-link 6-digit-code verify, magic-link click-through verify — SHALL complete by calling a single completeAuth function at apps/journal/app/lib/auth/completion.ts. The function SHALL be the sole place where a successful web authentication records the accepted Terms version (only on registration), mints the cookie session, and constructs the redirect to returnTo (or / when absent or rejected).

Per-method identity verification (WebAuthn ceremony, magic-token consumption, 6-digit-code consumption) SHALL run in its own function and produce a userId before completeAuth is invoked. completeAuth SHALL NOT know how identity was proved.

The Terms gate (root-loader redirect for cookie sessions; requireApiUser 403 for bearer-token API requests) SHALL remain the enforcement point for stale terms_version. completeAuth only records terms on new registrations; it does not enforce them.

OAuth-code issuance at /oauth/authorize SHALL NOT be routed through completeAuth — that flow operates on an already-authenticated user and shares only the trailing redirect, not the full sequence.

Scenario: Passkey register-finish completes through the chokepoint

  • WHEN a visitor submits a successful WebAuthn step: "finish" registration response
  • THEN the route handler verifies the credential, creates the user row, and calls completeAuth({ userId, isNewRegistration: true, termsVersion, request, returnTo })
  • AND completeAuth writes users.terms_version and users.terms_accepted_at, mints the session cookie, and returns a Response redirecting to returnTo (or /)

Scenario: Passkey login-finish completes through the chokepoint

  • WHEN a visitor submits a successful WebAuthn step: "finish-passkey" login response
  • THEN the route handler verifies the credential and calls completeAuth({ userId, isNewRegistration: false, request, returnTo })
  • AND completeAuth skips the terms write (only registrations record terms), mints the session cookie, and returns a Response redirecting to returnTo (or /)
  • WHEN a visitor submits a valid 6-digit code via step: "verify-code"
  • THEN the route handler consumes the magic token (marks used_at) and calls completeAuth({ userId, isNewRegistration: false, request, returnTo })
  • WHEN a visitor opens /auth/verify?token=<token> with a valid, unused, unexpired token
  • THEN the route handler consumes the magic token and calls completeAuth({ userId, isNewRegistration: false, request, returnTo })

Scenario: returnTo is sanitized inside completeAuth

  • WHEN completeAuth is called with a returnTo value that is not a same-origin absolute path (e.g. starts with //, an absolute URL, or is malformed)
  • THEN the redirect target falls back to / rather than honoring the unsafe value
  • AND every caller benefits from the same check rather than reimplementing it