jevstrudel.git / worker / src / session.ts

Who is signed in: the session cookie, and the random values accounts use.

The cookie holds a random token (32 bytes, base64url); the database holds only its SHA-256 (accounts-store.ts), so reading the sessions table signs nobody in. The cookie is HttpOnly (page scripts never see it), Secure (browsers also accept that on http://localhost, so dev behaves the same), SameSite=Lax (another site's form or fetch does not carry it on a POST), Path=/ and host-only (no Domain): workers.dev is a public suffix, so no other Worker's subdomain can set or read it.

10export const SESSION_COOKIE = 'jev_session';
11export const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
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)));

A user's id: 16 random bytes, 22 characters. It is also the WebAuthn user handle, so the passkey carries it.

22export const newUserId = () => randomId(16);
23export const newSessionToken = () => randomId(32);

A WebAuthn challenge: 32 random bytes. The options carry them base64url encoded (43 characters), which is also how the browser's clientDataJSON returns them.

27export const newChallenge = () => crypto.getRandomValues(new Uint8Array(32));
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}

Whether a request came from this site's own pages: its Origin is this site's, or it has none (a same-origin GET, a navigation, or not a browser). Everything else is another site's page, or an opaque origin: Origin: null, which is what the sandbox a listener's song plays in sends (website/src/jev/sandbox.mjs). A session is honoured only from this 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}

Every route's first check: a request that could change something (any method but GET and HEAD) from another origin is refused, before any route runs. Browsers send Origin on every such request, so this is the whole of cross-site request forgery and of the sandbox writing, whatever a route checks itself. It also answers CORS preflights, since no route 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}

The session token in the request's cookies, or null. Only a value shaped 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}
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`;