trails/openspec/changes/unify-auth-completion/tasks.md
Ullrich Schäfer d64c47614d
Implement completeAuth chokepoint + caller migration
Implements all of unify-auth-completion (12/14 tasks done; manual
smoke + archive-time spec sync remain).

Design refinement during implementation: completeAuth supports two
response shapes via a `mode` parameter:
- mode: 'redirect' (loaders / direct browser navigation; auth.verify.tsx)
- mode: 'json' (action handlers called by imperative fetch from
  client forms; api.auth.login, api.auth.register)

Both modes share createSession + safeReturnTo + Set-Cookie. JSON mode
carries `{ ok: true, step: "done", redirectTo }` (the `step` field
preserves the existing client-form check).

Why two modes: passkey ceremonies are inherently imperative
(start → browser WebAuthn API → finish), so action handlers can't
move to <Form>/useFetcher. Picking option (B) from the design grill —
the chokepoint owns destination selection while clients navigate —
required this dual shape. The 3 hardcoded client-side targets
(returnTo ?? "/", "/", "/?add-passkey=1") collapse into 1 server-side
sanitization pass (safeReturnTo) inside completeAuth.

New module:
- apps/journal/app/lib/auth/session.ts: cookie session storage
  (sessionStorage, createSession, getSessionUser, destroySession)
  moved from auth.server.ts. Legacy import path kept via re-exports
  with @deprecated JSDoc.
- apps/journal/app/lib/auth/completion.ts: completeAuth + safeReturnTo.
- apps/journal/app/lib/auth/completion.test.ts: 10 contract tests
  covering both modes, returnTo sanitization (path-relative, protocol-
  relative, absolute-URL, malformed), Set-Cookie attachment, redirect
  status, JSON shape.

Caller migration:
- api.auth.register.ts passkey-finish → completeAuth(json)
- api.auth.login.ts finish-passkey → completeAuth(json)
- api.auth.login.ts verify-code → completeAuth(json)
- auth.verify.tsx magic-link consumer → completeAuth(redirect)

Client form updates:
- auth.login.tsx: pass returnTo in fetch body, read result.redirectTo
  on done.
- auth.register.tsx: pass returnTo: "/?add-passkey=1" for the magic-
  link verify-code path (preserves the post-register passkey prompt
  via the chokepoint's safeReturnTo, instead of hardcoding it
  client-side).

Verified:
- pnpm typecheck && pnpm lint: green across all 15 workspaces.
- pnpm --filter @trails-cool/journal test: 126 passed.
- pnpm test:e2e auth: 4/4 passed without modification — confirms the
  refactor is behaviour-preserving for the user-facing flows that
  matter most (passkey register + login).

Spec delta in openspec/changes/unify-auth-completion/specs/ applies at
/opsx:archive time.

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

2.9 KiB

1. New auth module structure

  • 1.1 Create apps/journal/app/lib/auth/ directory.
  • 1.2 Create apps/journal/app/lib/auth/session.ts and move sessionStorage, createSession, getSessionUser, destroySession from auth.server.ts (preserve behaviour, including process.env.SESSION_SECRET source).
  • 1.3 In auth.server.ts, re-export the moved helpers from ./auth/session.ts so existing imports keep working unchanged. Add a JSDoc @deprecated-style comment pointing at the new path.

2. completeAuth chokepoint

  • 2.1 Write apps/journal/app/lib/auth/completion.test.ts (TDD red): scenarios for returnTo defaults to /, returnTo //evil.com rejected, returnTo https://evil.com rejected, response is a 302/303 redirect, response includes Set-Cookie naming __session.
  • 2.2 Create apps/journal/app/lib/auth/completion.ts exporting completeAuth({ userId, request, returnTo? }) → Promise<Response> and a private safeReturnTo(value) helper. Implementation: createSession(userId, request); redirect(safeReturnTo(returnTo) ?? "/", { headers: { "Set-Cookie": cookie } }). Terms recording is not here — both registration paths already record terms at user creation, so the chokepoint is purely session + redirect.
  • 2.3 Run completion tests green.

3. Caller migration

  • 3.1 apps/journal/app/routes/api.auth.register.ts passkey-finish branch — replace inlined session+redirect with return completeAuth({ userId, request, returnTo }). Drop now-unused imports.
  • 3.2 apps/journal/app/routes/api.auth.login.ts passkey step: "finish-passkey" branch — replace with return completeAuth({ userId, request, returnTo }).
  • 3.3 apps/journal/app/routes/api.auth.login.ts magic-link step: "verify-code" branch — replace with return completeAuth(...).
  • 3.4 apps/journal/app/routes/auth.verify.tsx magic-link click-through consumer — replace with return completeAuth(...).
  • 3.5 Confirm no other callers of createSession remain inside auth route handlers (they should all flow through completeAuth). getSessionUser and destroySession continue to be called directly from non-completion sites — that's expected.

4. Verification

  • 4.1 pnpm typecheck && pnpm lint && pnpm test green.
  • 4.2 pnpm test:e2e (auth flows) green without modification — proves behaviour-preserving refactor.
  • 4.3 Manual sanity: register with passkey locally, login with passkey, log out, re-login via magic-link 6-digit code, click-through magic link from auth.verify.tsx. Confirm session cookie set + correct redirect each time.

5. Documentation + follow-up

  • 5.1 At archive time, apply the spec delta in specs/authentication-methods/ to openspec/specs/.
  • 5.2 (Optional follow-up — not part of this change) update import paths app-wide from auth.server.ts to ./auth/session.ts and drop the re-exports. Track separately.