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`;