// The exchange (plan §8.5): the dashboard's ticket in, F's cookie out. The // editor's frame opens /__auth?ticket=…&next=/…; F checks the ticket, // redeems it for a session (the browser's own, when it brings one) and // sends the browser on to `next`, a place of F's own and nowhere else. Why // a ticket is refused goes into the log, not into the answer. import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Log } from './log.js'; import { SESSION_SECONDS, type SessionStore } from './sessions.js'; import { cookieValue, SESSION_COOKIE } from './sync.js'; import { TicketError, verifyTicket } from './ticket.js'; export interface AuthOptions { readonly sessions: SessionStore; readonly log: Log; readonly ticketSecret: string; /** The cookie only over HTTPS; false on a developer's machine without it. */ readonly secureCookie: boolean; } const HERE = 'http://filius.invalid'; /** * Where to go after the exchange: a path of F's own, else "/". Resolved as * the browser resolves it, so that "//elsewhere", "/\elsewhere" (a * backslash is a slash to the browser) and "/.//elsewhere" (which the * resolving shortens to "//elsewhere") cannot lead away. */ export function safeNext(value: string | null): string { if (value === null) return '/'; let url: URL; try { url = new URL(value, HERE); } catch { return '/'; } if (url.origin !== HERE || url.pathname.startsWith('//')) return '/'; return `${url.pathname}${url.search}${url.hash}`; } export function sessionCookie(key: string, secure: boolean): string { return `${SESSION_COOKIE}=${key}; Max-Age=${SESSION_SECONDS}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`; } function answer(response: ServerResponse, status: number, body: object): void { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body)); } export async function exchangeTicket(request: IncomingMessage, response: ServerResponse, { sessions, log, ticketSecret, secureCookie }: AuthOptions): Promise { const query = new URL(request.url ?? '/', HERE).searchParams; let claims; try { claims = verifyTicket(query.get('ticket') ?? '', ticketSecret); } catch (error) { if (!(error instanceof TicketError)) throw error; log.warn('auth.ticketRefused', { reason: error.message }); answer(response, 401, { error: 'invalidTicket' }); return; } const session = await sessions.redeem(claims, cookieValue(request.headers.cookie, SESSION_COOKIE)); if (session === undefined) { log.warn('auth.unknownProject', { project: claims.project, user: claims.login }); answer(response, 404, { error: 'unknownProject' }); return; } log.info('auth.signedIn', { project: claims.project, user: claims.login, grant: claims.level }); response.writeHead(302, { location: safeNext(query.get('next')), 'set-cookie': sessionCookie(session.key, secureCookie), 'cache-control': 'no-store' }); response.end(); }