Two cleanups in one pass: 1. Update import paths app-wide from `~/lib/auth.server` to `~/lib/auth/session.server` for the four session helpers (sessionStorage, createSession, getSessionUser, destroySession). ~40 files: 33 simple path swaps where the file imported only session symbols, 5 splits where it also imported per-method auth functions (auth.verify.tsx, api.settings.email.ts, activities.\$id.tsx, routes.\$id.tsx, auth.accept-terms.tsx) — those keep one import from auth.server (for verifyMagicToken, canView, recordTermsAcceptance, etc.) and gain a second import from auth/session.server. Two more files used relative paths and were missed by the first grep pass (lib/oauth.server.ts and routes/oauth.authorize.tsx) — migrated too. The @deprecated re-exports block in auth.server.ts is gone. 2. Rename the new auth files to follow the project's `.server.ts` convention so Vite/React Router treat them as server-only (they read process.env.SESSION_SECRET, hit the DB, etc. — must NOT enter the client bundle): - auth/session.ts → auth/session.server.ts - auth/completion.ts → auth/completion.server.ts - auth/completion.test.ts → auth/completion.server.test.ts Done with `git mv` so blame is preserved. Verified: typecheck + lint green; 126 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
76 lines
3 KiB
TypeScript
76 lines
3 KiB
TypeScript
// completeAuth — the single chokepoint that every successful web auth
|
|
// flow uses to mint the cookie session and produce the response. See
|
|
// ADR-0004 + CONTEXT.md (Authentication section).
|
|
//
|
|
// Two response shapes, picked via `mode`:
|
|
// - 'redirect' (loader callers / direct browser navigation):
|
|
// 302 redirect to the sanitized target, Set-Cookie attached.
|
|
// - 'json' (action callers from imperative fetch in client forms):
|
|
// 200 JSON { ok: true, redirectTo } with Set-Cookie. Client reads
|
|
// redirectTo and does window.location = redirectTo.
|
|
//
|
|
// Both modes share identity-agnostic behaviour: createSession + same-
|
|
// origin sanitization + Set-Cookie. Per ADR-0005, this function knows
|
|
// nothing about how identity was proved (passkey, magic-link, etc.) —
|
|
// the caller does its own per-method verification first.
|
|
//
|
|
// Terms recording is NOT here: both registration paths (finishRegistration
|
|
// for passkey, registerWithMagicLink for magic-link) record terms at
|
|
// user-creation time, before any path can reach completeAuth.
|
|
|
|
import { redirect } from "react-router";
|
|
import { createSession } from "./session.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<Response> {
|
|
const cookie = await createSession(input.userId, input.request);
|
|
const target = safeReturnTo(input.returnTo) ?? "/";
|
|
const headers = { "Set-Cookie": cookie };
|
|
if (input.mode === "json") {
|
|
// `step: "done"` retained for compatibility with existing client
|
|
// form checks (auth.register.tsx, auth.login.tsx). New code should
|
|
// read `redirectTo` and navigate there.
|
|
return Response.json(
|
|
{ ok: true, step: "done", redirectTo: target },
|
|
{ headers },
|
|
);
|
|
}
|
|
return redirect(target, { headers });
|
|
}
|