Files
mdg-filius/packages/server/src/auth.ts
T
julian.kiedaischandClaude Opus 5.5 5ded84cb33 feat: F5.2 sign-in with the dashboard's ticket and the service way
Server F checks the dashboard's entry ticket as itsdangerous does
(tools/ticket-oracle: tickets made by itsdangerous 2.2.0 itself), redeems
it at /__auth for a session cookie (only the key's SHA-256 is kept) and
decides at /sync by the session's grant: none, read or write; a frozen
project is read by everyone; every refusal names its reason (one list in
packages/protocol/src/sync.ts). A WebSocket from a page of another origin
is refused. /svc/* with the service key makes, deletes, freezes and
thaws projects, ends a person's grant or all their sessions (connections
let go) and lists the sessions. Snapshots, restoring and copying move to
F6 (they need the canonical doc.json and an epoch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:56:45 +00:00

71 lines
3.0 KiB
TypeScript

// 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<void> {
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();
}