trails/apps/journal/app/lib/auth/completion.ts
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

76 lines
3 KiB
TypeScript

// completeAuth — the single chokepoint that every successful web auth
// flow uses to mint the cookie session and produce the response. See
// ADR-0004 + CONTEXT.md (Authentication section).
//
// Two response shapes, picked via `mode`:
// - 'redirect' (loader callers / direct browser navigation):
// 302 redirect to the sanitized target, Set-Cookie attached.
// - 'json' (action callers from imperative fetch in client forms):
// 200 JSON { ok: true, redirectTo } with Set-Cookie. Client reads
// redirectTo and does window.location = redirectTo.
//
// Both modes share identity-agnostic behaviour: createSession + same-
// origin sanitization + Set-Cookie. Per ADR-0005, this function knows
// nothing about how identity was proved (passkey, magic-link, etc.) —
// the caller does its own per-method verification first.
//
// Terms recording is NOT here: both registration paths (finishRegistration
// for passkey, registerWithMagicLink for magic-link) record terms at
// user-creation time, before any path can reach completeAuth.
import { redirect } from "react-router";
import { createSession } from "./session.ts";
export type CompleteAuthMode = "redirect" | "json";
export interface CompleteAuthInput {
userId: string;
request: Request;
/**
* Optional caller-supplied redirect target. Validated as a same-origin
* path; anything else falls back to "/". Pass through whatever you
* received from the client — sanitization is centralized here.
*/
returnTo?: string | null;
/**
* Response shape:
* - 'redirect' (default): 302 redirect Response; right for loaders
* (direct browser navigation, e.g. auth.verify.tsx loader).
* - 'json': 200 JSON `{ ok: true, redirectTo }`; right for action
* handlers called by imperative fetch from client form code
* (e.g. api.auth.register, api.auth.login). Client navigates
* using `data.redirectTo`.
*/
mode?: CompleteAuthMode;
}
/**
* Same-origin path check. Accepts only paths that start with a single
* "/" — rejects:
* - Empty / null / undefined
* - Protocol-relative ("//evil.com/x")
* - Absolute URLs ("https://evil.com")
* - Anything not starting with "/"
*/
function safeReturnTo(value: string | null | undefined): string | null {
if (typeof value !== "string" || value.length === 0) return null;
if (!value.startsWith("/")) return null;
if (value.startsWith("//")) return null;
return value;
}
export async function completeAuth(input: CompleteAuthInput): Promise<Response> {
const cookie = await createSession(input.userId, input.request);
const target = safeReturnTo(input.returnTo) ?? "/";
const headers = { "Set-Cookie": cookie };
if (input.mode === "json") {
// `step: "done"` retained for compatibility with existing client
// form checks (auth.register.tsx, auth.login.tsx). New code should
// read `redirectTo` and navigate there.
return Response.json(
{ ok: true, step: "done", redirectTo: target },
{ headers },
);
}
return redirect(target, { headers });
}