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>
This commit is contained in:
parent
1a0a212139
commit
7e22f1260b
8 changed files with 320 additions and 0 deletions
54
CONTEXT.md
54
CONTEXT.md
|
|
@ -124,3 +124,57 @@ webhooks to the right user via `provider_user_id`. Unknown
|
||||||
`(user_id, provider, workout_id)`.
|
`(user_id, provider, workout_id)`.
|
||||||
- `sync_pushes` — push state per `(user_id, route_id, provider)` →
|
- `sync_pushes` — push state per `(user_id, route_id, provider)` →
|
||||||
`remote_id`, `last_pushed_version`.
|
`remote_id`, `last_pushed_version`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
User identity in the Journal. Two **authentication methods** are supported
|
||||||
|
and are intentionally the entire surface (see ADR-0005): **passkey**
|
||||||
|
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
|
||||||
|
(email) as a fallback for users without passkey support or for cross-device
|
||||||
|
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
|
||||||
|
already deliver the one-tap UX, and adding centralized identity providers
|
||||||
|
would conflict with the privacy-first ethos and ActivityPub federation.
|
||||||
|
|
||||||
|
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
|
||||||
|
method. It is a **session transport** for native clients: users still
|
||||||
|
authenticate via passkey or magic-link in a WebView, then the mobile app
|
||||||
|
exchanges the resulting authorization code for long-lived bearer tokens.
|
||||||
|
The peer of OAuth2 transport is the cookie session, not passkey or
|
||||||
|
magic-link.
|
||||||
|
|
||||||
|
### completeAuth
|
||||||
|
The single chokepoint for the post-verify orchestration of every web
|
||||||
|
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
|
||||||
|
ADR-0004). Called by every route handler that has just verified a
|
||||||
|
user's identity (passkey login finish, magic-link code verify, magic
|
||||||
|
link consumer). Does three things in order:
|
||||||
|
|
||||||
|
1. If `isNewRegistration`, records the accepted Terms version
|
||||||
|
(`recordTermsAcceptance`).
|
||||||
|
2. Creates the session cookie via `createSession`.
|
||||||
|
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
|
||||||
|
header attached.
|
||||||
|
|
||||||
|
Identity-method-specific work (WebAuthn ceremony verification, magic
|
||||||
|
token consumption) stays in the per-method functions and runs *before*
|
||||||
|
`completeAuth`. The chokepoint deliberately knows nothing about how
|
||||||
|
identity was proved.
|
||||||
|
|
||||||
|
### Terms gate
|
||||||
|
Cross-cutting middleware enforcing that `users.terms_version` matches
|
||||||
|
the current `TERMS_VERSION` constant before any non-allow-listed
|
||||||
|
authenticated request succeeds. Two enforcement points:
|
||||||
|
|
||||||
|
- **Web (cookie sessions)**: the root loader redirects stale-terms users
|
||||||
|
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
|
||||||
|
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
|
||||||
|
allow-list, so OAuth code issuance is gated by this same redirect
|
||||||
|
before mobile sees an authorization code.
|
||||||
|
- **API (bearer tokens)**: `requireApiUser` returns
|
||||||
|
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
|
||||||
|
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
|
||||||
|
|
||||||
|
`completeAuth` only **records** terms on registration; it does not
|
||||||
|
enforce them. Enforcement remains middleware's job.
|
||||||
|
|
|
||||||
26
docs/adr/0004-centralize-auth-completion.md
Normal file
26
docs/adr/0004-centralize-auth-completion.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Centralize web auth completion in `completeAuth`
|
||||||
|
|
||||||
|
Every successful web authentication today (passkey login, passkey
|
||||||
|
register, magic-link code verify, magic-link click-through verify)
|
||||||
|
inlines the same three-step orchestration in its route handler:
|
||||||
|
record the accepted Terms version on registration, mint the session
|
||||||
|
cookie, and redirect to `returnTo` (or `/`). With four call sites
|
||||||
|
re-implementing the same sequence, a bug in any of them — a missed
|
||||||
|
terms write, a wrong cookie option, an open-redirect-prone returnTo
|
||||||
|
— must be fixed in four places, and a future fifth flow (e.g. an
|
||||||
|
admin-impersonation login, a one-time recovery flow) re-derives the
|
||||||
|
same boilerplate.
|
||||||
|
|
||||||
|
We extract the orchestration into a single function
|
||||||
|
`completeAuth({ userId, isNewRegistration, termsVersion?, request,
|
||||||
|
returnTo? })` at `apps/journal/app/lib/auth/completion.ts`. Per-method
|
||||||
|
identity verification (WebAuthn ceremony, magic-token consumption)
|
||||||
|
stays in its own function and runs *before* `completeAuth` —
|
||||||
|
the chokepoint deliberately knows nothing about how identity was
|
||||||
|
proved. Terms enforcement (the gate) remains middleware (root loader
|
||||||
|
for web, `requireApiUser` for API); `completeAuth` only *records*
|
||||||
|
terms on registration. The OAuth-code-issuance flow at
|
||||||
|
`/oauth/authorize` is **not** routed through `completeAuth` — it
|
||||||
|
operates on an already-authenticated user and shares only the
|
||||||
|
trailing redirect, not the full sequence (see ADR-0005 for the
|
||||||
|
broader scope decision).
|
||||||
40
docs/adr/0005-no-authmethod-polymorphism.md
Normal file
40
docs/adr/0005-no-authmethod-polymorphism.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# No `AuthMethod` polymorphism — passkey + magic-link is the entire surface
|
||||||
|
|
||||||
|
A natural temptation when reshaping auth is to extract an
|
||||||
|
`AuthMethod` interface (analogous to the connected-services
|
||||||
|
`Importer` / `RoutePusher` / `WebhookReceiver` capability seams) so
|
||||||
|
passkey and magic-link become two adapters of a common shape, with
|
||||||
|
future Google/Apple/SAML/etc. as additional adapters. We are
|
||||||
|
explicitly **not** doing that, and recording the negative decision
|
||||||
|
here so future architecture passes don't re-suggest it.
|
||||||
|
|
||||||
|
Two reasons. First, the *number of adapters is fixed*: passkey is
|
||||||
|
trails.cool's preferred identity method and magic-link is its
|
||||||
|
fallback for browsers without WebAuthn support and cross-device sign-
|
||||||
|
in. There are no plans for additional methods. Social sign-in (Google,
|
||||||
|
Apple) would conflict with the privacy-first ethos in `CLAUDE.md`
|
||||||
|
(third-party identity providers see every login), would fight the
|
||||||
|
ActivityPub federation model (centralized OIDC vs.
|
||||||
|
`user@domain` identity), and would add account-linking edge cases
|
||||||
|
without a UX win — passkeys already deliver the one-tap convenience
|
||||||
|
that justifies social sign-in elsewhere. With two adapters and no
|
||||||
|
forecast third, an `AuthMethod` interface is theoretical
|
||||||
|
polymorphism with no future second customer; the deletion test fails.
|
||||||
|
|
||||||
|
Second, the *interface shape would be shallow*. WebAuthn is a
|
||||||
|
multi-step ceremony (start → finish, with challenge persistence
|
||||||
|
between calls); magic-link is a request-then-verify flow with a
|
||||||
|
15-minute token in the database. Forcing both into a unified
|
||||||
|
`startAuth(input) → finishAuth(response)` interface produces an
|
||||||
|
adapter where each implementation barely shares a type signature —
|
||||||
|
the two flows have genuinely different shapes, and the seam would
|
||||||
|
fill with optional methods and discriminated unions. The real
|
||||||
|
repetition lives in the *post-verify orchestration* (record terms,
|
||||||
|
mint session, redirect), which ADR-0004 addresses with a single
|
||||||
|
`completeAuth` function — no polymorphism required.
|
||||||
|
|
||||||
|
OAuth2/PKCE in the `mobile-app` change is sometimes mistaken for a
|
||||||
|
third auth method. It isn't: mobile users still authenticate via
|
||||||
|
passkey or magic-link in a WebView, and OAuth2 only exchanges the
|
||||||
|
resulting code for long-lived bearer tokens. OAuth2 is **session
|
||||||
|
transport** (peer of: HTTP-only cookie), not identity proof.
|
||||||
2
openspec/changes/unify-auth-completion/.openspec.yaml
Normal file
2
openspec/changes/unify-auth-completion/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-08
|
||||||
103
openspec/changes/unify-auth-completion/design.md
Normal file
103
openspec/changes/unify-auth-completion/design.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`apps/journal/app/lib/auth.server.ts` is 489 lines covering passkey ceremonies, magic-link token lifecycle, session cookie storage, terms recording, and email-change tokens. Four route handlers (`api.auth.register.ts`, `api.auth.login.ts` × 2 branches, `auth.verify.tsx`) each repeat the same trailing sequence after their per-method identity verification:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (isNewRegistration) await recordTermsAcceptance(userId, termsVersion);
|
||||||
|
const headers = await createSession(userId, request);
|
||||||
|
return redirect(safeReturnTo(returnTo) ?? "/", { headers });
|
||||||
|
```
|
||||||
|
|
||||||
|
The repetition is the *entire* asymmetry between methods at the post-verify layer. WebAuthn vs. magic-link diverge before this point (challenge persistence, token consumption, etc.) but converge here — every flow does the same thing.
|
||||||
|
|
||||||
|
ADRs already recorded:
|
||||||
|
- ADR-0004: centralize this orchestration in a single `completeAuth` function.
|
||||||
|
- ADR-0005: do **not** extract an `AuthMethod` interface; passkey + magic-link is the entire surface and OAuth2 is transport, not a peer.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- One function (`completeAuth`) is the only place where a successful web auth records terms + creates session + redirects. Bug fixes apply once.
|
||||||
|
- File reorganization: `apps/journal/app/lib/auth/` directory containing `completion.ts` and `session.ts`. The existing `auth.server.ts` shrinks but stays for the per-method functions (passkey ceremony, magic-token lifecycle).
|
||||||
|
- `returnTo` sanitization centralized inside `completeAuth` so no caller can ship an open-redirect.
|
||||||
|
- Refactor is behaviour-preserving: existing e2e auth tests pass without modification.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No `AuthMethod` interface (per ADR-0005).
|
||||||
|
- The OAuth-code issuance flow at `/oauth/authorize` is NOT migrated. It operates on already-authenticated users; only the trailing redirect overlaps, not enough leverage to fold in.
|
||||||
|
- The add-passkey post-login ceremony does NOT need `completeAuth` (no session creation — user is already logged in).
|
||||||
|
- The magic-link request-token step does NOT need `completeAuth` (only emits a token; verification happens later).
|
||||||
|
- Session transport (cookie vs. bearer) — orthogonal; not touched.
|
||||||
|
- Connected-devices UX — separate spec.
|
||||||
|
- Any change to the Terms gate enforcement points (root loader for web, `requireApiUser` for API).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Single function, no mode flag
|
||||||
|
`completeAuth` covers exactly the four web post-verify call sites listed in the proposal. There is no `redirectMode: 'cookie' | 'oauth-code'` flag because OAuth-code issuance is genuinely a different flow (already-authenticated user, no session creation, only a code + redirect). Folding both into one function would force a discriminated union and obscure that they don't share much. The OAuth-code path stays in `oauth.authorize.tsx` unchanged.
|
||||||
|
|
||||||
|
### Decision 2: Function signature
|
||||||
|
```ts
|
||||||
|
async function completeAuth(input: {
|
||||||
|
userId: string;
|
||||||
|
isNewRegistration: boolean;
|
||||||
|
termsVersion?: string; // required if isNewRegistration; ignored otherwise
|
||||||
|
request: Request;
|
||||||
|
returnTo?: string | null;
|
||||||
|
}): Promise<Response>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a `Response` (a `redirect` from React Router with the session `Set-Cookie` attached). Callers do `return await completeAuth(...)` from their action / loader. No headers-vs-redirect split: the chokepoint owns both halves.
|
||||||
|
|
||||||
|
`termsVersion` is optional at the type level but required-when-`isNewRegistration` at runtime. Asserting it inside the function (throw on `isNewRegistration && !termsVersion`) keeps the call sites tidy. Alternative: a discriminated union forcing the compiler to require `termsVersion` when `isNewRegistration: true`. Lean toward the runtime assertion — three call sites, two of which set `isNewRegistration: false` — the type ergonomics aren't worth the union complexity.
|
||||||
|
|
||||||
|
### Decision 3: File layout
|
||||||
|
New directory `apps/journal/app/lib/auth/` containing:
|
||||||
|
- `completion.ts` — `completeAuth` + `safeReturnTo` helper (open-redirect rejection).
|
||||||
|
- `session.ts` — `sessionStorage`, `createSession`, `getSessionUser`, `destroySession`. Moved from `auth.server.ts`.
|
||||||
|
|
||||||
|
`auth.server.ts` keeps the per-method functions (passkey ceremony, magic-token lifecycle, terms recording, email-change). It re-exports the moved session helpers from their new home so existing imports keep working without a touch:
|
||||||
|
```ts
|
||||||
|
// auth.server.ts
|
||||||
|
export { sessionStorage, createSession, getSessionUser, destroySession } from "./auth/session.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
That keeps the migration small. A future change can rip the re-exports and update callers across the app — out of scope here.
|
||||||
|
|
||||||
|
### Decision 4: `returnTo` sanitization centralizes inside `completeAuth`
|
||||||
|
Today, several callers do their own `returnTo` checks of varying rigor (some check `startsWith("/") && !startsWith("//")`, others don't). Move the check into `safeReturnTo` inside `completion.ts`; every caller passes `returnTo` raw and gets the safe version applied uniformly. Reject anything that isn't a same-origin path → fall back to `/`.
|
||||||
|
|
||||||
|
### Decision 5: Tests
|
||||||
|
Unit tests for `completeAuth` in `completion.test.ts`:
|
||||||
|
- new-registration writes `users.terms_version` + `users.terms_accepted_at` (mock the DB or use an in-memory test DB — match existing test patterns).
|
||||||
|
- non-registration skips the terms write.
|
||||||
|
- response includes a `Set-Cookie` header from `sessionStorage.commitSession`.
|
||||||
|
- `returnTo` of `/foo` redirects to `/foo`; `returnTo` of `//evil.com/x` redirects to `/`; `returnTo` of `https://evil.com` redirects to `/`; missing `returnTo` redirects to `/`.
|
||||||
|
|
||||||
|
Existing `e2e/auth.test.ts` (passkey register + login + logout) MUST pass without modification — that's the behaviour-preservation proof.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk]** Re-exporting from `auth.server.ts` to maintain backwards compatibility means there are temporarily two paths to the same symbols. → **Mitigation:** comment marks the re-exports as transitional. A follow-up PR (small) can update import paths app-wide and remove the re-exports.
|
||||||
|
|
||||||
|
- **[Risk]** The runtime assertion (`isNewRegistration && !termsVersion → throw`) is a programmer error, not a user-visible failure. If a future caller forgets to pass `termsVersion`, tests should catch it; if they don't, registration breaks loudly. → **Accepted:** the type-level alternative (discriminated union) costs more than it pays back at three internal call sites.
|
||||||
|
|
||||||
|
- **[Trade-off]** Centralizing `returnTo` sanitization changes behaviour at any call sites that *don't* currently sanitize. The change is strictly safer (rejecting more inputs as `/`) but worth confirming there are no integration tests that pass a deliberately-unsafe `returnTo` and expect it to work.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Single PR ("spec apply") containing:
|
||||||
|
|
||||||
|
1. Create `apps/journal/app/lib/auth/session.ts` with the moved helpers.
|
||||||
|
2. Add re-exports in `auth.server.ts`.
|
||||||
|
3. Create `apps/journal/app/lib/auth/completion.ts` with `completeAuth` + `safeReturnTo`.
|
||||||
|
4. Write `completion.test.ts` (unit tests).
|
||||||
|
5. Migrate the 4 callers to `completeAuth`.
|
||||||
|
6. Verify `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
||||||
|
|
||||||
|
**Rollback**: revert the PR. No DB or schema changes; the file moves are pure code-organization, the call-site changes are mechanical.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Q1**: Does `sessionStorage` get its `secret` from `process.env.SESSION_SECRET` directly, or from a config helper? Move-as-is and revisit only if it complicates the `session.ts` file. → Decide during implementation.
|
||||||
|
- **Q2**: Should the `auth.server.ts` re-exports be marked `@deprecated` JSDoc-style so editors warn on use? Light touch — yes, with a comment pointing at `./auth/session.ts`. Decide during implementation.
|
||||||
32
openspec/changes/unify-auth-completion/proposal.md
Normal file
32
openspec/changes/unify-auth-completion/proposal.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Every successful web authentication today inlines the same three-step orchestration in its route handler: record the accepted Terms version on registration (`recordTermsAcceptance`), mint the session cookie (`createSession`), and `redirect(returnTo ?? "/", { headers })`. Four call sites — passkey register-finish, passkey login-finish, magic-link verify-code, and the magic-link click-through consumer — re-implement the same sequence. A bug in any one of them must be fixed in four places, and a future fifth flow (admin impersonation, recovery, etc.) re-derives the same boilerplate.
|
||||||
|
|
||||||
|
This change extracts the orchestration into a single function `completeAuth` and folds in the small file-organization win that comes with it (a new `apps/journal/app/lib/auth/` directory). It does **not** introduce an `AuthMethod` interface — see ADR-0005 for why polymorphism here is theoretical with no future adapter.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **New module** `apps/journal/app/lib/auth/completion.ts` exporting `completeAuth({ userId, isNewRegistration, termsVersion?, request, returnTo? }) → Promise<Response>`. Records terms on registration (only), creates the session, returns a `redirect(...)` Response with the `Set-Cookie` header attached.
|
||||||
|
- **New module** `apps/journal/app/lib/auth/session.ts` carrying the cookie session storage (`sessionStorage`, `createSession`, `getSessionUser`, `destroySession`) extracted from `auth.server.ts`. The existing path keeps re-exports for backwards compatibility — caller migration is a follow-up if anything.
|
||||||
|
- **Caller migration**: 4 route handlers (`api.auth.register.ts` passkey-finish branch, `api.auth.login.ts` passkey-finish-passkey branch, `api.auth.login.ts` magic-link verify-code branch, `auth.verify.tsx` magic-link click-through) replace their inlined orchestration with a single `completeAuth(...)` call.
|
||||||
|
- **`returnTo` sanitization** centralizes inside `completeAuth` (was already same-origin checked in callers; now there's one place).
|
||||||
|
- **Tests**: new `completion.test.ts` covering register-records-terms, non-register-skips-terms, returnTo default, header attachment, returnTo open-redirect rejection. Existing `e2e/auth.test.ts` continues to pass without modification — that's the proof the refactor is behaviour-preserving.
|
||||||
|
- **Negative scope (recorded in ADR-0005)**: no `AuthMethod` interface, no adapter pattern. Passkey + magic-link is the entire identity surface; OAuth2/PKCE is session transport, not a third method.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none — this change reshapes existing capabilities, no new user-visible behaviour.)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `authentication-methods`: requirement-level addition codifying that every post-verify web flow goes through the single completion chokepoint that records terms (on register), creates the session, and redirects. User-visible behaviour unchanged.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Code**: 1 new directory (`apps/journal/app/lib/auth/`), 2 new files (`completion.ts`, `session.ts`), `auth.server.ts` slims down by ~80 lines (the moved session helpers), 4 route handlers shrink. No DB changes. No schema changes.
|
||||||
|
- **Tests**: 1 new unit test file (`completion.test.ts`). Existing e2e auth test should pass unmodified.
|
||||||
|
- **Specs**: 1 spec gets a small ADDED requirement (`authentication-methods/spec.md`).
|
||||||
|
- **Decision references**: `docs/adr/0004-centralize-auth-completion.md`, `docs/adr/0005-no-authmethod-polymorphism.md`, `CONTEXT.md` (Authentication section).
|
||||||
|
- **Out of scope** (separate concerns): the OAuth-code issuance flow at `/oauth/authorize` (operates on already-authenticated users; only shares the trailing redirect), the magic-link request-token step (emits a token, no session created), the add-passkey post-login ceremony (no session creation). Connected-devices UX is its own spec/change. Session-transport changes (cookie ↔ bearer) are orthogonal and not touched here.
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
## 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 `/`)
|
||||||
|
|
||||||
|
#### Scenario: Magic-link 6-digit-code verify completes through the chokepoint
|
||||||
|
- **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 })`
|
||||||
|
|
||||||
|
#### Scenario: Magic-link click-through verify completes through the chokepoint
|
||||||
|
- **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
|
||||||
30
openspec/changes/unify-auth-completion/tasks.md
Normal file
30
openspec/changes/unify-auth-completion/tasks.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
## 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 new-registration writes terms, non-registration skips terms, returnTo defaults to `/`, returnTo `//evil.com` rejected, returnTo `https://evil.com` rejected, response includes Set-Cookie.
|
||||||
|
- [ ] 2.2 Create `apps/journal/app/lib/auth/completion.ts` exporting `completeAuth({ userId, isNewRegistration, termsVersion?, request, returnTo? }) → Promise<Response>` and a private `safeReturnTo(value)` helper. Implementation: assert `termsVersion` when `isNewRegistration`; if `isNewRegistration`, `recordTermsAcceptance(userId, termsVersion)`; `createSession(userId, request)`; `redirect(safeReturnTo(returnTo) ?? "/", { headers })`.
|
||||||
|
- [ ] 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, isNewRegistration: true, termsVersion, 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, isNewRegistration: false, 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue