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>
7.5 KiB
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:
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
completeAuthfunction. - ADR-0005: do not extract an
AuthMethodinterface; 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 containingcompletion.tsandsession.ts. The existingauth.server.tsshrinks but stays for the per-method functions (passkey ceremony, magic-token lifecycle). returnTosanitization centralized insidecompleteAuthso no caller can ship an open-redirect.- Refactor is behaviour-preserving: existing e2e auth tests pass without modification.
Non-Goals:
- No
AuthMethodinterface (per ADR-0005). - The OAuth-code issuance flow at
/oauth/authorizeis 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,
requireApiUserfor 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
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+safeReturnTohelper (open-redirect rejection).session.ts—sessionStorage,createSession,getSessionUser,destroySession. Moved fromauth.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:
// 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-Cookieheader fromsessionStorage.commitSession. returnToof/fooredirects to/foo;returnToof//evil.com/xredirects to/;returnToofhttps://evil.comredirects to/; missingreturnToredirects 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.tsto 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 passtermsVersion, 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
returnTosanitization 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-unsafereturnToand expect it to work.
Migration Plan
Single PR ("spec apply") containing:
- Create
apps/journal/app/lib/auth/session.tswith the moved helpers. - Add re-exports in
auth.server.ts. - Create
apps/journal/app/lib/auth/completion.tswithcompleteAuth+safeReturnTo. - Write
completion.test.ts(unit tests). - Migrate the 4 callers to
completeAuth. - Verify
pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2egreen.
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
sessionStorageget itssecretfromprocess.env.SESSION_SECRETdirectly, or from a config helper? Move-as-is and revisit only if it complicates thesession.tsfile. → Decide during implementation. - Q2: Should the
auth.server.tsre-exports be marked@deprecatedJSDoc-style so editors warn on use? Light touch — yes, with a comment pointing at./auth/session.ts. Decide during implementation.