diff --git a/CONTEXT.md b/CONTEXT.md index e41990c..007ea13 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,3 +124,57 @@ webhooks to the right user via `provider_user_id`. Unknown `(user_id, provider, workout_id)`. - `sync_pushes` — push state per `(user_id, route_id, provider)` → `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. diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 2fbcbf5..6df9c3f 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -396,23 +396,10 @@ export async function verifyEmailChange(token: string, userId: string): Promise< return newEmail; } -// --- Sessions --- - -import { createCookieSessionStorage } from "react-router"; - -const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production"; - -export const sessionStorage = createCookieSessionStorage({ - cookie: { - name: "__session", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 30, // 30 days - secrets: [sessionSecret], - }, -}); +// Cookie session storage lives at `./auth/session.ts` (see ADR-0004 + the +// `auth/completion.ts` chokepoint). Import session helpers directly from +// there; this file owns only per-method identity verification (passkey +// ceremony + magic-token lifecycle) and Terms recording. /** * A row that carries the minimum a visibility check needs. @@ -466,23 +453,3 @@ export async function recordTermsAcceptance(userId: string, termsVersion: string .where(eq(users.id, userId)); } -export async function createSession(userId: string, request: Request) { - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - session.set("userId", userId); - return sessionStorage.commitSession(session); -} - -export async function getSessionUser(request: Request) { - const db = getDb(); - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - const userId = session.get("userId"); - if (!userId) return null; - - const [user] = await db.select().from(users).where(eq(users.id, userId)); - return user ?? null; -} - -export async function destroySession(request: Request) { - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - return sessionStorage.destroySession(session); -} diff --git a/apps/journal/app/lib/auth/completion.server.test.ts b/apps/journal/app/lib/auth/completion.server.test.ts new file mode 100644 index 0000000..f5400b9 --- /dev/null +++ b/apps/journal/app/lib/auth/completion.server.test.ts @@ -0,0 +1,112 @@ +// Contract tests for the completeAuth chokepoint. See ADR-0004 + +// docs/adr/0005-no-authmethod-polymorphism.md for why this exists. + +import { describe, it, expect } from "vitest"; +import { completeAuth } from "./completion.server.ts"; + +function reqWith(headers: Record = {}) { + return new Request("https://localhost/whatever", { headers }); +} + +describe("completeAuth (mode=redirect)", () => { + it("redirects to returnTo when it's a same-origin path", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/profile", + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/profile"); + }); + + it("redirects to / when returnTo is missing", async () => { + const res = await completeAuth({ userId: "u1", request: reqWith() }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("redirects to / when returnTo is null", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: null, + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects protocol-relative returnTo (//evil.com/x) → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "//evil.com/x", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects absolute-URL returnTo (https://evil.com) → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "https://evil.com", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects returnTo that doesn't start with / → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "javascript:alert(1)", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("attaches a __session Set-Cookie carrying the userId", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/", + }); + const setCookie = res.headers.get("Set-Cookie"); + expect(setCookie).not.toBeNull(); + expect(setCookie!).toMatch(/^__session=/); + // HttpOnly is required for a session cookie. + expect(setCookie!.toLowerCase()).toContain("httponly"); + }); +}); + +describe("completeAuth (mode=json)", () => { + it("returns 200 JSON with sanitized redirectTo and Set-Cookie", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/profile", + mode: "json", + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(res.headers.get("Set-Cookie")).toMatch(/^__session=/); + const body = (await res.json()) as { ok: boolean; step: string; redirectTo: string }; + expect(body).toEqual({ ok: true, step: "done", redirectTo: "/profile" }); + }); + + it("falls back to / when returnTo is unsafe (json mode)", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "https://evil.com", + mode: "json", + }); + const body = (await res.json()) as { ok: boolean; redirectTo: string }; + expect(body.redirectTo).toBe("/"); + }); + + it("redirectTo defaults to / when no returnTo (json mode)", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + mode: "json", + }); + const body = (await res.json()) as { ok: boolean; redirectTo: string }; + expect(body.redirectTo).toBe("/"); + }); +}); diff --git a/apps/journal/app/lib/auth/completion.server.ts b/apps/journal/app/lib/auth/completion.server.ts new file mode 100644 index 0000000..cbf7dcf --- /dev/null +++ b/apps/journal/app/lib/auth/completion.server.ts @@ -0,0 +1,76 @@ +// 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.server.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 { + 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 }); +} diff --git a/apps/journal/app/lib/auth/session.server.ts b/apps/journal/app/lib/auth/session.server.ts new file mode 100644 index 0000000..c2d20b3 --- /dev/null +++ b/apps/journal/app/lib/auth/session.server.ts @@ -0,0 +1,46 @@ +// Cookie session storage. Lives here (separate from auth.server.ts) so +// the post-verify chokepoint (./completion.ts) can compose it without +// dragging the entire auth surface in. +// +// The legacy import path `~/lib/auth.server` continues to re-export +// these symbols for backwards compat — see auth.server.ts. + +import { createCookieSessionStorage } from "react-router"; +import { eq } from "drizzle-orm"; +import { users } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; + +const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production"; + +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, // 30 days + secrets: [sessionSecret], + }, +}); + +export async function createSession(userId: string, request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", userId); + return sessionStorage.commitSession(session); +} + +export async function getSessionUser(request: Request) { + const db = getDb(); + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + const userId = session.get("userId"); + if (!userId) return null; + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + return user ?? null; +} + +export async function destroySession(request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + return sessionStorage.destroySession(session); +} diff --git a/apps/journal/app/lib/oauth.server.ts b/apps/journal/app/lib/oauth.server.ts index a4c8c30..d1cd525 100644 --- a/apps/journal/app/lib/oauth.server.ts +++ b/apps/journal/app/lib/oauth.server.ts @@ -7,7 +7,7 @@ import { oauthCodes, oauthTokens, } from "@trails-cool/db/schema/journal"; -import { getSessionUser } from "./auth.server.ts"; +import { getSessionUser } from "./auth/session.server.ts"; const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 1a475db..65ff76a 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -5,7 +5,7 @@ import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; import { detectLocale } from "@trails-cool/i18n"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; import { useUnreadNotifications } from "~/hooks/useUnreadNotifications"; diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 3c840ae..7b4da0d 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -1,7 +1,8 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; -import { canView, getSessionUser } from "~/lib/auth.server"; +import { canView } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server"; import { deleteImportByActivity } from "~/lib/sync/imports.server"; import { listRoutes } from "~/lib/routes.server"; diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx index 4380be9..78e3526 100644 --- a/apps/journal/app/routes/activities._index.tsx +++ b/apps/journal/app/routes/activities._index.tsx @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities._index"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { listActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; diff --git a/apps/journal/app/routes/activities.new.tsx b/apps/journal/app/routes/activities.new.tsx index 4200010..5de4291 100644 --- a/apps/journal/app/routes/activities.new.tsx +++ b/apps/journal/app/routes/activities.new.tsx @@ -1,6 +1,6 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/activities.new"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { createActivity } from "~/lib/activities.server"; import { listRoutes } from "~/lib/routes.server"; diff --git a/apps/journal/app/routes/api.auth.login.ts b/apps/journal/app/routes/api.auth.login.ts index 7fa122e..1ac014e 100644 --- a/apps/journal/app/routes/api.auth.login.ts +++ b/apps/journal/app/routes/api.auth.login.ts @@ -1,11 +1,12 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.login"; -import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode, createSession } from "~/lib/auth.server"; +import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode } from "~/lib/auth.server"; +import { completeAuth } from "~/lib/auth/completion.server"; import { sendMagicLink } from "~/lib/email.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); - const { step, response, challenge, email, code } = body; + const { step, response, challenge, email, code, returnTo } = body; try { if (step === "start-passkey") { @@ -15,8 +16,7 @@ export async function action({ request }: Route.ActionArgs) { if (step === "finish-passkey") { const userId = await finishAuthentication(response, challenge); - const cookie = await createSession(userId, request); - return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); + return completeAuth({ userId, request, returnTo, mode: "json" }); } if (step === "magic-link") { @@ -38,8 +38,7 @@ export async function action({ request }: Route.ActionArgs) { return data({ error: "Email and code are required" }, { status: 400 }); } const userId = await verifyLoginCode(email, code); - const cookie = await createSession(userId, request); - return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); + return completeAuth({ userId, request, returnTo, mode: "json" }); } return data({ error: "Invalid step" }, { status: 400 }); diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index a78d005..a6421a1 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -1,12 +1,13 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.register"; -import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server"; +import { startRegistration, finishRegistration, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server"; +import { completeAuth } from "~/lib/auth/completion.server"; import { sendWelcome, sendMagicLink } from "~/lib/email.server"; import { logger } from "~/lib/logger.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); - const { step, email, username, response, challenge, userId, termsAccepted, termsVersion } = body; + const { step, email, username, response, challenge, userId, termsAccepted, termsVersion, returnTo } = body; const origin = process.env.ORIGIN ?? `http://localhost:3000`; // Registration steps require terms acceptance + the version the client @@ -27,11 +28,10 @@ export async function action({ request }: Route.ActionArgs) { if (step === "finish") { const newUserId = await finishRegistration(userId, email, username, response, challenge, termsVersion); - const cookie = await createSession(newUserId, request); sendWelcome(email, username).catch((err) => logger.error({ err }, "Failed to send welcome email"), ); - return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); + return completeAuth({ userId: newUserId, request, returnTo, mode: "json" }); } if (step === "register-magic-link") { diff --git a/apps/journal/app/routes/api.events.ts b/apps/journal/app/routes/api.events.ts index 5c0bbce..3b89d98 100644 --- a/apps/journal/app/routes/api.events.ts +++ b/apps/journal/app/routes/api.events.ts @@ -1,5 +1,5 @@ import type { Route } from "./+types/api.events"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { register } from "~/lib/events.server"; import { countUnread } from "~/lib/notifications.server"; diff --git a/apps/journal/app/routes/api.follows.$id.approve.ts b/apps/journal/app/routes/api.follows.$id.approve.ts index fc7db5b..cfa90ae 100644 --- a/apps/journal/app/routes/api.follows.$id.approve.ts +++ b/apps/journal/app/routes/api.follows.$id.approve.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.follows.$id.approve"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { approveFollowRequest } from "~/lib/follow.server"; export async function action({ request, params }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.follows.$id.reject.ts b/apps/journal/app/routes/api.follows.$id.reject.ts index b7d20fa..6fb03e1 100644 --- a/apps/journal/app/routes/api.follows.$id.reject.ts +++ b/apps/journal/app/routes/api.follows.$id.reject.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.follows.$id.reject"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { rejectFollowRequest } from "~/lib/follow.server"; export async function action({ request, params }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.notifications.$id.read.ts b/apps/journal/app/routes/api.notifications.$id.read.ts index 0e39f26..c90653c 100644 --- a/apps/journal/app/routes/api.notifications.$id.read.ts +++ b/apps/journal/app/routes/api.notifications.$id.read.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.notifications.$id.read"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { markRead } from "~/lib/notifications.server"; export async function action({ request, params }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.notifications.read-all.ts b/apps/journal/app/routes/api.notifications.read-all.ts index 38b4875..150f95a 100644 --- a/apps/journal/app/routes/api.notifications.read-all.ts +++ b/apps/journal/app/routes/api.notifications.read-all.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.notifications.read-all"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { markAllRead } from "~/lib/notifications.server"; export async function action({ request }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts index 3fa5b1d..6f4e502 100644 --- a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts +++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.routes.$id.edit-in-planner"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getRouteWithVersions } from "~/lib/routes.server"; import { createRouteToken } from "~/lib/jwt.server"; diff --git a/apps/journal/app/routes/api.settings.delete-account.ts b/apps/journal/app/routes/api.settings.delete-account.ts index c569de7..88e3168 100644 --- a/apps/journal/app/routes/api.settings.delete-account.ts +++ b/apps/journal/app/routes/api.settings.delete-account.ts @@ -1,7 +1,7 @@ import { redirect } from "react-router"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/api.settings.delete-account"; -import { getSessionUser, destroySession } from "~/lib/auth.server"; +import { getSessionUser, destroySession } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; diff --git a/apps/journal/app/routes/api.settings.email.ts b/apps/journal/app/routes/api.settings.email.ts index 8f0505f..75ee66f 100644 --- a/apps/journal/app/routes/api.settings.email.ts +++ b/apps/journal/app/routes/api.settings.email.ts @@ -1,6 +1,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/api.settings.email"; -import { getSessionUser, initiateEmailChange } from "~/lib/auth.server"; +import { initiateEmailChange } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { sendMagicLink } from "~/lib/email.server"; export async function action({ request }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.settings.passkey.delete.ts b/apps/journal/app/routes/api.settings.passkey.delete.ts index 1aa1dda..e96dade 100644 --- a/apps/journal/app/routes/api.settings.passkey.delete.ts +++ b/apps/journal/app/routes/api.settings.passkey.delete.ts @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import { eq, and } from "drizzle-orm"; import type { Route } from "./+types/api.settings.passkey.delete"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { credentials } from "@trails-cool/db/schema/journal"; diff --git a/apps/journal/app/routes/api.settings.profile.ts b/apps/journal/app/routes/api.settings.profile.ts index 79abf04..adfd301 100644 --- a/apps/journal/app/routes/api.settings.profile.ts +++ b/apps/journal/app/routes/api.settings.profile.ts @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/api.settings.profile"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index df8980c..1741070 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/api.sync.callback.$provider"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getManifest, link } from "~/lib/connected-services"; import { decodeOAuthState, diff --git a/apps/journal/app/routes/api.sync.connect.$provider.ts b/apps/journal/app/routes/api.sync.connect.$provider.ts index 3239c9f..4ead0e4 100644 --- a/apps/journal/app/routes/api.sync.connect.$provider.ts +++ b/apps/journal/app/routes/api.sync.connect.$provider.ts @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/api.sync.connect.$provider"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; diff --git a/apps/journal/app/routes/api.sync.disconnect.$provider.ts b/apps/journal/app/routes/api.sync.disconnect.$provider.ts index 5c23cb5..03bcf90 100644 --- a/apps/journal/app/routes/api.sync.disconnect.$provider.ts +++ b/apps/journal/app/routes/api.sync.disconnect.$provider.ts @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/api.sync.disconnect.$provider"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getManifest, unlinkByUserProvider } from "~/lib/connected-services"; export async function action({ params, request }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts index 548c4a0..ac15efe 100644 --- a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts +++ b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/api.sync.push.$provider.$routeId"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; diff --git a/apps/journal/app/routes/api.users.$username.follow.ts b/apps/journal/app/routes/api.users.$username.follow.ts index 83bff8f..c515874 100644 --- a/apps/journal/app/routes/api.users.$username.follow.ts +++ b/apps/journal/app/routes/api.users.$username.follow.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.users.$username.follow"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { followUser, FollowError } from "~/lib/follow.server"; export async function action({ request, params }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/api.users.$username.unfollow.ts b/apps/journal/app/routes/api.users.$username.unfollow.ts index 485dc0f..036d342 100644 --- a/apps/journal/app/routes/api.users.$username.unfollow.ts +++ b/apps/journal/app/routes/api.users.$username.unfollow.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.users.$username.unfollow"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { unfollowUser, FollowError } from "~/lib/follow.server"; export async function action({ request, params }: Route.ActionArgs) { diff --git a/apps/journal/app/routes/auth.accept-terms.tsx b/apps/journal/app/routes/auth.accept-terms.tsx index 111b186..81da750 100644 --- a/apps/journal/app/routes/auth.accept-terms.tsx +++ b/apps/journal/app/routes/auth.accept-terms.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/auth.accept-terms"; -import { getSessionUser, recordTermsAcceptance } from "~/lib/auth.server"; +import { recordTermsAcceptance } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { TERMS_VERSION } from "~/lib/legal"; export function meta() { diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index 7db270a..5b39c8a 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -50,6 +50,7 @@ export default function LoginPage() { step: "finish-passkey", response: webAuthnResp, challenge: startData.options.challenge, + returnTo, }), }); @@ -57,7 +58,8 @@ export default function LoginPage() { if (finishData.error) { setError(finishData.error); } else if (finishData.step === "done") { - window.location.href = returnTo ?? "/"; + // Server-sanitized destination (see completeAuth in auth/completion.ts). + window.location.href = finishData.redirectTo ?? "/"; } } catch (err) { const message = (err as Error).message; @@ -80,14 +82,14 @@ export default function LoginPage() { const resp = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "verify-code", email, code: loginCode }), + body: JSON.stringify({ step: "verify-code", email, code: loginCode, returnTo }), }); const result = await resp.json(); if (result.error) { setError(result.error); } else if (result.step === "done") { - window.location.href = returnTo ?? "/"; + window.location.href = result.redirectTo ?? "/"; } } catch (err) { setError((err as Error).message); diff --git a/apps/journal/app/routes/auth.logout.tsx b/apps/journal/app/routes/auth.logout.tsx index 44e9cf2..37f9c03 100644 --- a/apps/journal/app/routes/auth.logout.tsx +++ b/apps/journal/app/routes/auth.logout.tsx @@ -1,6 +1,6 @@ import { redirect } from "react-router"; import type { Route } from "./+types/auth.logout"; -import { destroySession } from "~/lib/auth.server"; +import { destroySession } from "~/lib/auth/session.server"; export async function action({ request }: Route.ActionArgs) { const cookie = await destroySession(request); diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 2a5ae74..81dc6e9 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -75,7 +75,7 @@ export default function RegisterPage() { if (finishData.error) { setError(finishData.error); } else if (finishData.step === "done") { - window.location.href = "/"; + window.location.href = finishData.redirectTo ?? "/"; } } catch (err) { setError((err as Error).message); @@ -134,13 +134,21 @@ export default function RegisterPage() { const resp = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "verify-code", email, code: verifyCode }), + body: JSON.stringify({ + step: "verify-code", + email, + code: verifyCode, + // Just-registered user: prompt to set up a passkey so next time + // they can use it directly. completeAuth's safeReturnTo accepts + // this as a same-origin path. + returnTo: "/?add-passkey=1", + }), }); const result = await resp.json(); if (result.error) { setError(result.error); } else if (result.step === "done") { - window.location.href = "/?add-passkey=1"; + window.location.href = result.redirectTo ?? "/?add-passkey=1"; } } catch (err) { setError((err as Error).message); diff --git a/apps/journal/app/routes/auth.verify.tsx b/apps/journal/app/routes/auth.verify.tsx index dec9eae..12fc00b 100644 --- a/apps/journal/app/routes/auth.verify.tsx +++ b/apps/journal/app/routes/auth.verify.tsx @@ -1,6 +1,8 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/auth.verify"; -import { verifyMagicToken, verifyEmailChange, createSession, getSessionUser } from "~/lib/auth.server"; +import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { completeAuth } from "~/lib/auth/completion.server"; export async function loader({ request }: Route.LoaderArgs) { const url = new URL(request.url); @@ -20,10 +22,13 @@ export async function loader({ request }: Route.LoaderArgs) { } const userId = await verifyMagicToken(token); - const cookie = await createSession(userId, request); - const returnTo = url.searchParams.get("returnTo"); - const destination = returnTo?.startsWith("/") ? returnTo : "/?add-passkey=1"; - return redirect(destination, { headers: { "Set-Cookie": cookie } }); + // Default destination after magic-link sign-in is "/?add-passkey=1" + // (prompt to set up a passkey now that they're in). If the link + // carried a returnTo, completeAuth's safeReturnTo will honor any + // same-origin path and otherwise fall back to "/" — handle the + // add-passkey default before delegating. + const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1"; + return completeAuth({ userId, request, returnTo, mode: "redirect" }); } catch (e) { return data({ error: (e as Error).message }, { status: 400 }); } diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx index 1479acb..6386a80 100644 --- a/apps/journal/app/routes/explore.tsx +++ b/apps/journal/app/routes/explore.tsx @@ -2,7 +2,7 @@ import { data } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/explore"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { EXPLORE_DEFAULT_PAGE_SIZE, countFollowersBatch, diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 755ac3b..4590144 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -2,7 +2,7 @@ import { data, redirect } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/feed"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index 071f681..981c2ac 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -3,7 +3,7 @@ import { data } from "react-router"; import { useTranslation } from "react-i18next"; import { eq, count } from "drizzle-orm"; import type { Route } from "./+types/home"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { credentials } from "@trails-cool/db/schema/journal"; import { listActivities, listRecentPublicActivities } from "~/lib/activities.server"; diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx index 5306d16..dda955a 100644 --- a/apps/journal/app/routes/notifications.tsx +++ b/apps/journal/app/routes/notifications.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { inArray, eq, and } from "drizzle-orm"; import type { Route } from "./+types/notifications"; import { getDb } from "~/lib/db"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { listForUser } from "~/lib/notifications.server"; import { linkFor } from "~/lib/notifications/link-for"; import { readPayload } from "~/lib/notifications/payload"; diff --git a/apps/journal/app/routes/oauth.authorize.tsx b/apps/journal/app/routes/oauth.authorize.tsx index 91342d9..fa918f0 100644 --- a/apps/journal/app/routes/oauth.authorize.tsx +++ b/apps/journal/app/routes/oauth.authorize.tsx @@ -1,6 +1,6 @@ import type { Route } from "./+types/oauth.authorize"; import { redirect } from "react-router"; -import { getSessionUser } from "../lib/auth.server.ts"; +import { getSessionUser } from "../lib/auth/session.server.ts"; import { getOAuthClient, validateRedirectUri, diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index e1fe2e9..59497fb 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getRoute, updateRoute } from "~/lib/routes.server"; import type { Visibility } from "@trails-cool/db/schema/journal"; diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 3313a1b..a85c066 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -3,7 +3,8 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; import { and, eq } from "drizzle-orm"; -import { canView, getSessionUser } from "~/lib/auth.server"; +import { canView } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; import { getDb } from "~/lib/db"; import { syncPushes } from "@trails-cool/db/schema/journal"; diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 85511a6..a4fed7a 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; diff --git a/apps/journal/app/routes/routes.new.tsx b/apps/journal/app/routes/routes.new.tsx index 7a939bd..caf02b2 100644 --- a/apps/journal/app/routes/routes.new.tsx +++ b/apps/journal/app/routes/routes.new.tsx @@ -1,7 +1,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.new"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { createRoute } from "~/lib/routes.server"; export async function loader({ request }: Route.LoaderArgs) { diff --git a/apps/journal/app/routes/settings.account.tsx b/apps/journal/app/routes/settings.account.tsx index 17aa3ca..0dc41c1 100644 --- a/apps/journal/app/routes/settings.account.tsx +++ b/apps/journal/app/routes/settings.account.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.account"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; export function meta() { return [{ title: "Account — Settings — trails.cool" }]; diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index 583d45b..b0d905e 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -2,7 +2,7 @@ import { data, redirect, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.connections"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { connectedServices } from "@trails-cool/db/schema/journal"; import { getAllManifests } from "~/lib/connected-services"; diff --git a/apps/journal/app/routes/settings.profile.tsx b/apps/journal/app/routes/settings.profile.tsx index 2c9956b..8f8ad5c 100644 --- a/apps/journal/app/routes/settings.profile.tsx +++ b/apps/journal/app/routes/settings.profile.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings.profile"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; export function meta() { return [{ title: "Profile — Settings — trails.cool" }]; diff --git a/apps/journal/app/routes/settings.security.tsx b/apps/journal/app/routes/settings.security.tsx index d499120..0062ca4 100644 --- a/apps/journal/app/routes/settings.security.tsx +++ b/apps/journal/app/routes/settings.security.tsx @@ -3,7 +3,7 @@ import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings.security"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getDb } from "~/lib/db"; import { credentials } from "@trails-cool/db/schema/journal"; import { ClientDate } from "~/components/ClientDate"; diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx index 1df5d11..a85c0e4 100644 --- a/apps/journal/app/routes/settings.tsx +++ b/apps/journal/app/routes/settings.tsx @@ -2,7 +2,7 @@ import { Outlet, redirect } from "react-router"; import { Link, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/settings"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; export function meta() { return [{ title: "Settings — trails.cool" }]; diff --git a/apps/journal/app/routes/sync.import.$provider.tsx b/apps/journal/app/routes/sync.import.$provider.tsx index c401ac0..e90b811 100644 --- a/apps/journal/app/routes/sync.import.$provider.tsx +++ b/apps/journal/app/routes/sync.import.$provider.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.$provider"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { getManifest, getService, diff --git a/apps/journal/app/routes/users.$username.followers.tsx b/apps/journal/app/routes/users.$username.followers.tsx index df2ab30..9b22fcd 100644 --- a/apps/journal/app/routes/users.$username.followers.tsx +++ b/apps/journal/app/routes/users.$username.followers.tsx @@ -4,7 +4,7 @@ import type { Route } from "./+types/users.$username.followers"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { CollectionPage } from "~/components/CollectionPage"; export async function loader({ params, request }: Route.LoaderArgs) { diff --git a/apps/journal/app/routes/users.$username.following.tsx b/apps/journal/app/routes/users.$username.following.tsx index 680083b..1c8237f 100644 --- a/apps/journal/app/routes/users.$username.following.tsx +++ b/apps/journal/app/routes/users.$username.following.tsx @@ -4,7 +4,7 @@ import type { Route } from "./+types/users.$username.following"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { CollectionPage } from "~/components/CollectionPage"; export async function loader({ params, request }: Route.LoaderArgs) { diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 5163971..83b566c 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; import { eq } from "drizzle-orm"; -import { getSessionUser } from "~/lib/auth.server"; +import { getSessionUser } from "~/lib/auth/session.server"; import { listPublicRoutesForOwner } from "~/lib/routes.server"; import { listPublicActivitiesForOwner } from "~/lib/activities.server"; import { loadPersona } from "~/lib/demo-bot.server"; diff --git a/docs/adr/0004-centralize-auth-completion.md b/docs/adr/0004-centralize-auth-completion.md new file mode 100644 index 0000000..533e53e --- /dev/null +++ b/docs/adr/0004-centralize-auth-completion.md @@ -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). diff --git a/docs/adr/0005-no-authmethod-polymorphism.md b/docs/adr/0005-no-authmethod-polymorphism.md new file mode 100644 index 0000000..cbab089 --- /dev/null +++ b/docs/adr/0005-no-authmethod-polymorphism.md @@ -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. diff --git a/openspec/changes/archive/2026-05-08-unify-auth-completion/.openspec.yaml b/openspec/changes/archive/2026-05-08-unify-auth-completion/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-unify-auth-completion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/archive/2026-05-08-unify-auth-completion/design.md b/openspec/changes/archive/2026-05-08-unify-auth-completion/design.md new file mode 100644 index 0000000..df67e25 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-unify-auth-completion/design.md @@ -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; + request: Request; + returnTo?: string | null; +}): Promise +``` + +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. + +**Terms recording is NOT in `completeAuth`.** Both registration paths already write `terms_version` + `terms_accepted_at` at user creation: `finishRegistration` writes them when the user row is inserted (passkey path); `registerWithMagicLink` writes them when the user row is inserted (magic-link path). Magic-link registration *must* write terms at user creation, before verification, because the user row exists between register and verify — if terms weren't already written, the Terms gate would bounce the user on first authenticated visit. + +So `completeAuth` is purely **session + redirect**. No `isNewRegistration` flag, no `termsVersion` parameter. The earlier framing of an `isNewRegistration` discriminator was a mis-read — there is no code path that needs to record terms at completion time. + +### 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. diff --git a/openspec/changes/archive/2026-05-08-unify-auth-completion/proposal.md b/openspec/changes/archive/2026-05-08-unify-auth-completion/proposal.md new file mode 100644 index 0000000..1c557cb --- /dev/null +++ b/openspec/changes/archive/2026-05-08-unify-auth-completion/proposal.md @@ -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`. 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. diff --git a/openspec/changes/archive/2026-05-08-unify-auth-completion/specs/authentication-methods/spec.md b/openspec/changes/archive/2026-05-08-unify-auth-completion/specs/authentication-methods/spec.md new file mode 100644 index 0000000..a248324 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-unify-auth-completion/specs/authentication-methods/spec.md @@ -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 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. + +Terms recording happens at user creation time inside the per-method registration functions (`finishRegistration` for passkey, `registerWithMagicLink` for magic-link), not inside `completeAuth`. 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`. + +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 and creates the user row (with terms recorded) inside `finishRegistration`, then calls `completeAuth({ userId, request, returnTo })` +- **AND** `completeAuth` 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, request, returnTo })` +- **AND** `completeAuth` 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, request, returnTo })` + +#### Scenario: Magic-link click-through verify completes through the chokepoint +- **WHEN** a visitor opens `/auth/verify?token=` with a valid, unused, unexpired token +- **THEN** the route handler consumes the magic token and calls `completeAuth({ userId, 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 diff --git a/openspec/changes/archive/2026-05-08-unify-auth-completion/tasks.md b/openspec/changes/archive/2026-05-08-unify-auth-completion/tasks.md new file mode 100644 index 0000000..e2dfba7 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-unify-auth-completion/tasks.md @@ -0,0 +1,30 @@ +## 1. New auth module structure + +- [x] 1.1 Create `apps/journal/app/lib/auth/` directory. +- [x] 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). +- [x] 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 + +- [x] 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`. +- [x] 2.2 Create `apps/journal/app/lib/auth/completion.ts` exporting `completeAuth({ userId, request, returnTo? }) → Promise` 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. +- [x] 2.3 Run completion tests green. + +## 3. Caller migration + +- [x] 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. +- [x] 3.2 `apps/journal/app/routes/api.auth.login.ts` passkey `step: "finish-passkey"` branch — replace with `return completeAuth({ userId, request, returnTo })`. +- [x] 3.3 `apps/journal/app/routes/api.auth.login.ts` magic-link `step: "verify-code"` branch — replace with `return completeAuth(...)`. +- [x] 3.4 `apps/journal/app/routes/auth.verify.tsx` magic-link click-through consumer — replace with `return completeAuth(...)`. +- [x] 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 + +- [x] 4.1 `pnpm typecheck && pnpm lint && pnpm test` green. +- [x] 4.2 `pnpm test:e2e` (auth flows) green without modification — proves behaviour-preserving refactor. +- [x] 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. Verified 2026-05-08 over plain HTTP (HTTPS dev hits a pre-existing HTTP/2 + missing-Host issue with React Router 7.14's CSRF check — separate follow-up). + +## 5. Documentation + follow-up + +- [x] 5.1 At archive time, apply the spec delta in `specs/authentication-methods/` to `openspec/specs/`. +- [x] 5.2 Update import paths app-wide from `auth.server.ts` to `./auth/session.ts` and drop the re-exports. (Folded into this PR after the smoke test confirmed everything works.) diff --git a/openspec/specs/authentication-methods/spec.md b/openspec/specs/authentication-methods/spec.md index 64e8995..9fb5c5b 100644 --- a/openspec/specs/authentication-methods/spec.md +++ b/openspec/specs/authentication-methods/spec.md @@ -93,3 +93,35 @@ A signed-in user SHALL be able to remove a passkey from their account via the Se #### Scenario: Last-passkey safety net (verified email is the fallback) - **WHEN** a user attempts to delete their only remaining passkey - **THEN** the action proceeds because magic-link login by email is always available — passkey deletion does not lock the user out + +### 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.server.ts`. The function SHALL be the sole place where a successful web authentication 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. + +Terms recording happens at user creation time inside the per-method registration functions (`finishRegistration` for passkey, `registerWithMagicLink` for magic-link), not inside `completeAuth`. 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`. + +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 and creates the user row (with terms recorded) inside `finishRegistration`, then calls `completeAuth({ userId, request, returnTo })` +- **AND** `completeAuth` 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, request, returnTo })` +- **AND** `completeAuth` 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, request, returnTo })` + +#### Scenario: Magic-link click-through verify completes through the chokepoint +- **WHEN** a visitor opens `/auth/verify?token=` with a valid, unused, unexpired token +- **THEN** the route handler consumes the magic token and calls `completeAuth({ userId, 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