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>
71 lines
3.0 KiB
TypeScript
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();
|
|
}
|