jevstrudel.git / worker / src / session.ts
1// Who is signed in: the session cookie, and the random values accounts use.
2//
3// The cookie holds a random token (32 bytes, base64url); the database holds
4// only its SHA-256 (accounts-store.ts), so reading the sessions table signs
5// nobody in. The cookie is HttpOnly (page scripts never see it), Secure
6// (browsers also accept that on http://localhost, so dev behaves the same),
7// SameSite=Lax (another site's form or fetch does not carry it on a POST),
8// Path=/ and host-only (no Domain): workers.dev is a public suffix, so no
9// other Worker's subdomain can set or read it.
10export const SESSION_COOKIE = 'jev_session';
11export const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
12
13const b64url = (bytes: Uint8Array) =>
14  btoa(String.fromCharCode(...bytes))
15    .replaceAll('+', '-')
16    .replaceAll('/', '_')
17    .replace(/=+$/, '');
18
19export const randomId = (bytes: number) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
20// A user's id: 16 random bytes, 22 characters. It is also the WebAuthn user
21// handle, so the passkey carries it.
22export const newUserId = () => randomId(16);
23export const newSessionToken = () => randomId(32);
24// A WebAuthn challenge: 32 random bytes. The options carry them base64url
25// encoded (43 characters), which is also how the browser's clientDataJSON
26// returns them.
27export const newChallenge = () => crypto.getRandomValues(new Uint8Array(32));
28
29export const userIdBytes = (id: string) => base64urlBytes(id);
30export function base64urlBytes(s: string): Uint8Array<ArrayBuffer> {
31  const bin = atob(s.replaceAll('-', '+').replaceAll('_', '/'));
32  return Uint8Array.from(bin, (c) => c.charCodeAt(0));
33}
34
35export async function tokenHash(token: string): Promise<string> {
36  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)));
37  return Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join('');
38}
39
40// Whether a request came from this site's own pages: its Origin is this
41// site's, or it has none (a same-origin GET, a navigation, or not a
42// browser). Everything else is another site's page, or an opaque origin:
43// `Origin: null`, which is what the sandbox a listener's song plays in
44// sends (website/src/jev/sandbox.mjs). A session is honoured only from this
45// site (signedIn), and nothing is written from anywhere else (index.ts).
46export function fromThisSite(request: Request): boolean {
47  const from = request.headers.get('Origin');
48  return from === null || from === new URL(request.url).origin;
49}
50
51// Every route's first check: a request that could change something (any
52// method but GET and HEAD) from another origin is refused, before any
53// route runs. Browsers send Origin on every such request, so this is the
54// whole of cross-site request forgery and of the sandbox writing, whatever
55// a route checks itself. It also answers CORS preflights, since no route
56// here is meant for another origin.
57export function refuseCrossOrigin(request: Request): Response | null {
58  if (request.method === 'GET' || request.method === 'HEAD' || fromThisSite(request)) return null;
59  return new Response('cross-origin request', { status: 403, headers: { 'Cache-Control': 'no-store' } });
60}
61
62// The session token in the request's cookies, or null. Only a value shaped
63// like one we issue is returned.
64export function sessionToken(request: Request): string | null {
65  const header = request.headers.get('Cookie');
66  if (!header) return null;
67  for (const part of header.split(';')) {
68    const eq = part.indexOf('=');
69    if (eq < 0 || part.slice(0, eq).trim() !== SESSION_COOKIE) continue;
70    const value = part.slice(eq + 1).trim();
71    return /^[A-Za-z0-9_-]{43}$/.test(value) ? value : null;
72  }
73  return null;
74}
75
76const attributes = 'Path=/; HttpOnly; Secure; SameSite=Lax';
77export const sessionCookie = (token: string) =>
78  `${SESSION_COOKIE}=${token}; ${attributes}; Max-Age=${SESSION_TTL_MS / 1000}`;
79export const clearedCookie = () => `${SESSION_COOKIE}=; ${attributes}; Max-Age=0`;