pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/lib/session.server.ts

88 lines2,874 bytesCodeBlame
1import {
2 type MiddlewareFunction,
3 type RouterContextProvider,
4 createContext,
5 data,
6 redirect,
7} from "react-router";
8
9import { type Result, type User, type Viewer, httpStatus } from "@g1t/contracts";
10
11import { identity } from "./services.server";
12
13const SESSION_COOKIE = "g1t_session";
14const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
15
16const viewerContext = createContext<Viewer>(null);
17
18function sessionToken(request: Request): string | null {
19 const cookies = request.headers.get("cookie") ?? "";
20 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
21 return match ? match[1] : null;
22}
23
24function sessionCookie(value: string, maxAge: number): string {
25 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
26}
27
28/** Root middleware: resolves the signed-in user once per request. */
29export const viewerMiddleware: MiddlewareFunction<Response> = async ({
30 request,
31 context,
32}) => {
33 const token = sessionToken(request);
34 if (token) {
35 context.set(viewerContext, await identity.userForSession(token));
36 }
37};
38
39type Context = Readonly<RouterContextProvider>;
40
41export function getViewer(context: Context): Viewer {
42 return context.get(viewerContext);
43}
44
45export function requireUser(context: Context, request: Request): User {
46 const viewer = getViewer(context);
47 if (!viewer) {
48 // Keep the query string: a device sign-in link carries its code there.
49 const { pathname, search } = new URL(request.url);
50 throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
51 }
52 return viewer;
53}
54
55/**
56 * Where to go after signing in. Only same-site paths are honoured, so
57 * `next` cannot redirect off g1t.
58 */
59export function nextPath(request: Request): string {
60 const next = new URL(request.url).searchParams.get("next") ?? "/";
61 return next.startsWith("/") && !next.startsWith("//") ? next : "/";
62}
63
64/** `Set-Cookie` value that starts a session. */
65export function startSession(token: string): string {
66 return sessionCookie(token, SESSION_TTL_SECONDS);
67}
68
69/** Ends the session and returns the `Set-Cookie` value that clears it. */
70export async function endSession(request: Request): Promise<string> {
71 const token = sessionToken(request);
72 if (token) await identity.signOut(token);
73 return sessionCookie("", 0);
74}
75
76/** Rejects cross-site form posts; call at the top of every action. */
77export function assertSameOrigin(request: Request): void {
78 const origin = request.headers.get("origin");
79 if (origin && origin !== new URL(request.url).origin) {
80 throw new Response("Cross-origin request rejected", { status: 403 });
81 }
82}
83
84/** The value of a service result, or the matching HTTP error. */
85export function unwrap<T>(result: Result<T>): T {
86 if (result.ok) return result.value;
87 throw data(result.error.message, { status: httpStatus(result.error) });
88}