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>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// Server F (plan §8): the shared documents, and later the simulation rooms,
|
||||
// the sign-in and the dashboard's service.
|
||||
// Server F (plan §8): the shared documents, the sign-in and the dashboard's
|
||||
// service; later the simulation rooms.
|
||||
export * from './auth.js';
|
||||
export * from './log.js';
|
||||
export * from './persistence.js';
|
||||
export * from './server.js';
|
||||
export * from './service.js';
|
||||
export * from './sessions.js';
|
||||
export * from './store.js';
|
||||
export * from './sync.js';
|
||||
export * from './ticket.js';
|
||||
|
||||
+22
-13
@@ -1,29 +1,38 @@
|
||||
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL.
|
||||
// Until the sign-in comes (F5.2) every connection is refused, unless
|
||||
// FILIUS_INSECURE_DEV=1 lets everyone write — on a developer's machine only.
|
||||
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL,
|
||||
// FILIUS_TICKET_SECRET (shared with the dashboard, which signs the tickets),
|
||||
// FILIUS_SERVICE_KEY (the dashboard's key to /svc/*), FILIUS_ORIGINS (the
|
||||
// origins the editor is served from, separated by commas). Without one of
|
||||
// them F does not start: an empty secret would let anyone sign. The cookie
|
||||
// goes over HTTPS only, unless FILIUS_INSECURE_COOKIE=1 (a developer's
|
||||
// machine without HTTPS).
|
||||
|
||||
import { jsonLog } from './log.js';
|
||||
import { startServer } from './server.js';
|
||||
import { PostgresSessions } from './sessions.js';
|
||||
import { PostgresStore } from './store.js';
|
||||
|
||||
const log = jsonLog();
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (url === undefined) {
|
||||
log.error('config.missing', { variable: 'DATABASE_URL' });
|
||||
const config = Object.fromEntries(['DATABASE_URL', 'FILIUS_TICKET_SECRET', 'FILIUS_SERVICE_KEY', 'FILIUS_ORIGINS'].map((name) => [name, process.env[name] ?? '']));
|
||||
const missing = Object.keys(config).filter((name) => config[name] === '');
|
||||
if (missing.length > 0) {
|
||||
log.error('config.missing', { variables: missing });
|
||||
process.exit(1);
|
||||
}
|
||||
const insecure = process.env.FILIUS_INSECURE_DEV === '1';
|
||||
if (insecure) log.warn('auth.insecureDev', { note: 'every connection may write' });
|
||||
const store = PostgresStore.connect(url);
|
||||
const secureCookie = process.env.FILIUS_INSECURE_COOKIE !== '1';
|
||||
if (!secureCookie) log.warn('auth.insecureCookie', { note: 'the session cookie goes over plain HTTP' });
|
||||
const store = PostgresStore.connect(config.DATABASE_URL!);
|
||||
const sessions = new PostgresSessions(store.pool);
|
||||
await store.migrate();
|
||||
await sessions.migrate();
|
||||
const server = await startServer({
|
||||
port: Number(process.env.FILIUS_PORT ?? 8080),
|
||||
store,
|
||||
sessions,
|
||||
log,
|
||||
authenticate: async () => {
|
||||
if (insecure) return { readOnly: false };
|
||||
throw new Error('no sign-in yet');
|
||||
},
|
||||
ticketSecret: config.FILIUS_TICKET_SECRET!,
|
||||
serviceKey: config.FILIUS_SERVICE_KEY!,
|
||||
origins: config.FILIUS_ORIGINS!.split(',').map((origin) => origin.trim()),
|
||||
secureCookie,
|
||||
});
|
||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(signal, () => {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// service asks (a minute after the last change, and when the last person
|
||||
// leaves; plan §5.4). A compaction stores the document as it is and drops
|
||||
// the updates stored before it; one stored after it stays, and applying it
|
||||
// again on loading changes nothing (Yjs).
|
||||
// again on loading changes nothing (Yjs). A project being deleted is
|
||||
// discarded: what is still asked for is not stored any more.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { errorText, type Log } from './log.js';
|
||||
@@ -21,6 +22,7 @@ export class DocumentPersistence {
|
||||
private sinceCompaction = 0;
|
||||
/** Updates queued since the last compaction was queued: what decides the next one. */
|
||||
private queued = 0;
|
||||
private discarded = false;
|
||||
|
||||
constructor(
|
||||
private readonly store: DocStore,
|
||||
@@ -47,7 +49,7 @@ export class DocumentPersistence {
|
||||
}
|
||||
|
||||
private enqueue(work: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(work).catch((error: unknown) => this.log.error('store.failed', { project: this.project, error: errorText(error) }));
|
||||
this.queue = this.queue.then(() => (this.discarded ? undefined : work())).catch((error: unknown) => this.log.error('store.failed', { project: this.project, error: errorText(error) }));
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
@@ -73,6 +75,12 @@ export class DocumentPersistence {
|
||||
});
|
||||
}
|
||||
|
||||
/** Nothing stored any more; done once what is being stored now is. */
|
||||
discard(): Promise<void> {
|
||||
this.discarded = true;
|
||||
return this.flush();
|
||||
}
|
||||
|
||||
/** Everything stored that was asked for. */
|
||||
flush(): Promise<void> {
|
||||
return this.enqueue(async () => {});
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
|
||||
// proxy, the WebSocket /sync for the shared documents; anything else is not
|
||||
// found. Closed, it first takes no one new (a client lets go and comes back
|
||||
// at once, which would keep its document open), then lets everyone go,
|
||||
// waits until every document is stored and unloaded, and stops.
|
||||
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
|
||||
// the dashboard (with the service key), the WebSocket /sync for the shared
|
||||
// documents; anything else is not found. A WebSocket from a page of another
|
||||
// origin is refused (a page elsewhere under the same domain would bring the
|
||||
// person's cookie along: SameSite only tells sites apart). Closed, it first
|
||||
// takes no one new (a client lets go and comes back at once, which would
|
||||
// keep its document open), then lets everyone go, waits until every
|
||||
// document is stored and unloaded, and stops.
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo, Socket } from 'node:net';
|
||||
import type { Hocuspocus } from '@hocuspocus/server';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type { Log } from './log.js';
|
||||
import { createSync, type SyncOptions } from './sync.js';
|
||||
import { exchangeTicket } from './auth.js';
|
||||
import { errorText, type Log } from './log.js';
|
||||
import { handleService } from './service.js';
|
||||
import { createSync, type Sync, type SyncOptions } from './sync.js';
|
||||
|
||||
export interface FiliusServer {
|
||||
readonly port: number;
|
||||
readonly sync: Hocuspocus;
|
||||
readonly sync: Sync;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -21,25 +26,40 @@ export interface ServerOptions extends SyncOptions {
|
||||
readonly port?: number;
|
||||
readonly host?: string;
|
||||
readonly log: Log;
|
||||
readonly ticketSecret: string;
|
||||
readonly serviceKey: string;
|
||||
/** The origins the editor is served from (FILIUS_ORIGINS): a WebSocket from another is refused. */
|
||||
readonly origins: readonly string[];
|
||||
readonly secureCookie: boolean;
|
||||
}
|
||||
|
||||
function refuse(socket: Socket, status: string): void {
|
||||
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
||||
}
|
||||
|
||||
function json(response: ServerResponse, status: number, value: object): void {
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
|
||||
const sync = createSync(options);
|
||||
const { hocuspocus } = sync;
|
||||
const sockets = new WebSocketServer({ noServer: true });
|
||||
let closing = false;
|
||||
const handle = async (request: IncomingMessage, response: ServerResponse, path: string) => {
|
||||
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount() });
|
||||
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
|
||||
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync });
|
||||
else json(response, 404, { error: 'notFound' });
|
||||
};
|
||||
const http: Server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
||||
if (request.method === 'GET' && path === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ status: 'ok', documents: sync.getDocumentsCount(), connections: sync.getConnectionsCount() }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ error: 'notFound' }));
|
||||
handle(request, response, path).catch((error: unknown) => {
|
||||
options.log.error('http.failed', { path, error: errorText(error) });
|
||||
if (!response.headersSent) json(response, 500, { error: 'internal' });
|
||||
else response.end();
|
||||
});
|
||||
});
|
||||
http.on('upgrade', (request, socket, head) => {
|
||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
||||
@@ -47,11 +67,17 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
|
||||
refuse(socket as Socket, '404 Not Found');
|
||||
return;
|
||||
}
|
||||
const origin = request.headers.origin;
|
||||
if (origin !== undefined && !options.origins.includes(origin)) {
|
||||
options.log.warn('sync.foreignOrigin', { origin });
|
||||
refuse(socket as Socket, '403 Forbidden');
|
||||
return;
|
||||
}
|
||||
if (closing) {
|
||||
refuse(socket as Socket, '503 Service Unavailable');
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (ws) => sync.handleConnection(ws, request));
|
||||
sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
|
||||
});
|
||||
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
|
||||
const port = (http.address() as AddressInfo).port;
|
||||
@@ -61,14 +87,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
|
||||
sync,
|
||||
async close() {
|
||||
closing = true;
|
||||
sync.closeConnections();
|
||||
hocuspocus.closeConnections();
|
||||
// Hocuspocus' close is a message on the socket, after which a client signs in again on it: the sockets go too.
|
||||
for (const socket of sockets.clients) socket.close(1001, 'service stopping');
|
||||
// Each document is stored and unloaded once its last person has gone.
|
||||
for (let waited = 0; sync.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (let waited = 0; hocuspocus.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
sockets.close();
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
options.log.info('server.closed', { documents: sync.getDocumentsCount() });
|
||||
options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// The dashboard's way into F (plan §8.5): /svc/*, with the service key and
|
||||
// without a person — the dashboard has decided who may, F does it. Make a
|
||||
// project (an empty FILIUS project), delete one (whoever is in it is let
|
||||
// go), freeze or thaw one (whoever is in it reads from then on, or writes
|
||||
// again with a write grant), end a person's grant to a project or all their
|
||||
// sessions (whoever is connected is let go), and list who is signed in to
|
||||
// what (the dashboard checks it against its shares). Snapshots, restoring,
|
||||
// copying and the .fls come with the commits (F6).
|
||||
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import * as Y from 'yjs';
|
||||
import { emptyProject, writeProject } from '@mdg-filius/doc';
|
||||
import type { SyncClosing } from '@mdg-filius/protocol';
|
||||
import type { Log } from './log.js';
|
||||
import type { SessionStore } from './sessions.js';
|
||||
import type { DocStore } from './store.js';
|
||||
import { accessMessage, type ConnectionContext, type Sync } from './sync.js';
|
||||
import { isUuid } from './ticket.js';
|
||||
|
||||
export const SERVICE_KEY_HEADER = 'x-filius-service-key';
|
||||
/** A request's body at most (the service sends small JSON). */
|
||||
export const SERVICE_BODY_BYTES = 64 * 1024;
|
||||
/** Hocuspocus' code for a connection ended for want of rights. */
|
||||
const FORBIDDEN = 4403;
|
||||
|
||||
export interface ServiceOptions {
|
||||
readonly store: DocStore;
|
||||
readonly sessions: SessionStore;
|
||||
readonly sync: Sync;
|
||||
readonly log: Log;
|
||||
readonly serviceKey: string;
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest();
|
||||
|
||||
/** The service key compared in constant time (the time an answer takes tells nothing of how much was right). */
|
||||
export function serviceKeyMatches(given: string | string[] | undefined, expected: string): boolean {
|
||||
return expected !== '' && typeof given === 'string' && timingSafeEqual(digest(given), digest(expected));
|
||||
}
|
||||
|
||||
async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const parts: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const part of request as AsyncIterable<Buffer>) {
|
||||
size += part.length;
|
||||
if (size > SERVICE_BODY_BYTES) throw new HttpError(413, 'tooLarge');
|
||||
parts.push(part);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(Buffer.concat(parts).toString('utf8'));
|
||||
} catch {
|
||||
throw new HttpError(400, 'notJson');
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new HttpError(400, 'notAnObject');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function uuid(value: unknown, code: string): string {
|
||||
if (!isUuid(value)) throw new HttpError(400, code);
|
||||
return value;
|
||||
}
|
||||
|
||||
const contextOf = (connection: { context: unknown }) => connection.context as ConnectionContext;
|
||||
|
||||
export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise<void> {
|
||||
const send = (status: number, value: object) => {
|
||||
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
if (!serviceKeyMatches(request.headers[SERVICE_KEY_HEADER], options.serviceKey)) {
|
||||
options.log.warn('svc.forbidden', { path });
|
||||
send(403, { error: 'forbidden' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [status, value] = await route(request, path, options);
|
||||
send(status, value);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError)) throw error;
|
||||
send(error.status, { error: error.code });
|
||||
}
|
||||
}
|
||||
|
||||
async function route(request: IncomingMessage, path: string, { store, sessions, sync, log }: ServiceOptions): Promise<[number, object]> {
|
||||
const close = (connections: ReturnType<Sync['connections']>, reason: SyncClosing) => {
|
||||
for (const connection of connections) connection.close({ code: FORBIDDEN, reason });
|
||||
return connections.length;
|
||||
};
|
||||
const project = /^\/svc\/projects\/([^/]+)(\/freeze)?$/.exec(path);
|
||||
if (request.method === 'POST' && path === '/svc/projects') {
|
||||
const id = uuid((await body(request)).project, 'notAProject');
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
if (!(await store.create(id, Y.encodeStateAsUpdate(doc)))) throw new HttpError(409, 'exists');
|
||||
log.info('svc.created', { project: id });
|
||||
return [201, { project: id }];
|
||||
}
|
||||
if (request.method === 'DELETE' && project !== null && project[2] === undefined) {
|
||||
const id = uuid(project[1], 'notAProject');
|
||||
// Not stored any more, then gone (the grants with it), then everyone let go: coming back, they find no project.
|
||||
await sync.discard(id);
|
||||
if (!(await store.remove(id))) throw new HttpError(404, 'unknownProject');
|
||||
const disconnected = close(sync.connections(id), 'deleted');
|
||||
log.info('svc.deleted', { project: id, disconnected });
|
||||
return [200, { deleted: true, disconnected }];
|
||||
}
|
||||
if (request.method === 'POST' && project !== null && project[2] === '/freeze') {
|
||||
const id = uuid(project[1], 'notAProject');
|
||||
const frozen = (await body(request)).frozen;
|
||||
if (typeof frozen !== 'boolean') throw new HttpError(400, 'frozenNotABoolean');
|
||||
if (!(await store.freeze(id, frozen))) throw new HttpError(404, 'unknownProject');
|
||||
for (const connection of sync.connections(id)) {
|
||||
const readOnly = frozen || contextOf(connection).level === 'read';
|
||||
if (connection.readOnly === readOnly) continue;
|
||||
connection.readOnly = readOnly;
|
||||
connection.sendStateless(accessMessage(readOnly, frozen));
|
||||
}
|
||||
log.info('svc.frozen', { project: id, frozen });
|
||||
return [200, { frozen }];
|
||||
}
|
||||
if (request.method === 'POST' && path === '/svc/revoke') {
|
||||
const given = await body(request);
|
||||
const user = uuid(given.user, 'notAPerson');
|
||||
const only = given.project === undefined ? undefined : uuid(given.project, 'notAProject');
|
||||
// The grant first: whoever is let go and comes back at once is refused.
|
||||
const revoked = await sessions.revoke(user, only);
|
||||
const disconnected = close(
|
||||
sync.connections(only).filter((connection) => contextOf(connection).person.user === user),
|
||||
'revoked',
|
||||
);
|
||||
log.info('svc.revoked', { user, ...(only === undefined ? {} : { project: only }), revoked, disconnected });
|
||||
return [200, { revoked, disconnected }];
|
||||
}
|
||||
if (request.method === 'GET' && path === '/svc/sessions') return [200, await sessions.active()];
|
||||
throw new HttpError(404, 'notFound');
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Who is signed in at F, and to which projects (plan §8.5). A ticket is
|
||||
// exchanged for a session (the cookie holds its key; F keeps only the key's
|
||||
// SHA-256, so that a read of the table signs no one in), and the session
|
||||
// gets the ticket's grant: this project, this level. Opening a second
|
||||
// project in the same browser adds a grant to the session; a ticket for
|
||||
// someone else ends the session the browser had (a shared iPad: the next
|
||||
// person is not the last one). A session lasts a school day; every ticket
|
||||
// lengthens it. The dashboard ends grants and sessions (/svc/revoke) and
|
||||
// asks which there are (/svc/sessions).
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import type { Level, TicketClaims } from './ticket.js';
|
||||
|
||||
export const SESSION_SECONDS = 12 * 60 * 60;
|
||||
|
||||
export interface SessionPerson {
|
||||
readonly user: string;
|
||||
readonly login: string;
|
||||
readonly name: string;
|
||||
readonly color: string;
|
||||
readonly language: string;
|
||||
}
|
||||
|
||||
/** What a session may do in a project. */
|
||||
export type ProjectAccess =
|
||||
| { readonly kind: 'notSignedIn' }
|
||||
| { readonly kind: 'unknownProject'; readonly person: SessionPerson }
|
||||
| { readonly kind: 'noAccess'; readonly person: SessionPerson }
|
||||
| { readonly kind: 'granted'; readonly person: SessionPerson; readonly level: Level; readonly frozen: boolean };
|
||||
|
||||
export interface ActiveSessions {
|
||||
readonly users: readonly string[];
|
||||
readonly grants: readonly { readonly user: string; readonly project: string; readonly level: Level }[];
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
migrate(): Promise<void>;
|
||||
/** A ticket redeemed: the browser's session (its key, when it brought one) or a new one; undefined if F does not know the project. */
|
||||
redeem(claims: TicketClaims, key?: string): Promise<{ readonly key: string } | undefined>;
|
||||
access(key: string, project: string): Promise<ProjectAccess>;
|
||||
/** A person's grant to a project ended, or (without a project) all their sessions; how many. */
|
||||
revoke(user: string, project?: string): Promise<number>;
|
||||
active(): Promise<ActiveSessions>;
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS filius_session (
|
||||
key_hash text PRIMARY KEY,
|
||||
user_uuid uuid NOT NULL,
|
||||
login text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
color text NOT NULL,
|
||||
language text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS filius_session_user ON filius_session (user_uuid);
|
||||
CREATE TABLE IF NOT EXISTS filius_grant (
|
||||
key_hash text NOT NULL REFERENCES filius_session ON DELETE CASCADE,
|
||||
project_uuid uuid NOT NULL REFERENCES filius_doc ON DELETE CASCADE,
|
||||
level text NOT NULL CHECK (level IN ('read', 'write')),
|
||||
PRIMARY KEY (key_hash, project_uuid)
|
||||
);
|
||||
`;
|
||||
|
||||
const hashOf = (key: string) => createHash('sha256').update(key).digest('hex');
|
||||
|
||||
interface PersonRow {
|
||||
readonly user_uuid: string;
|
||||
readonly login: string;
|
||||
readonly display_name: string;
|
||||
readonly color: string;
|
||||
readonly language: string;
|
||||
}
|
||||
|
||||
const person = (row: PersonRow): SessionPerson => ({ user: row.user_uuid, login: row.login, name: row.display_name, color: row.color, language: row.language });
|
||||
|
||||
export class PostgresSessions implements SessionStore {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
await this.pool.query(SCHEMA);
|
||||
}
|
||||
|
||||
async redeem(claims: TicketClaims, key?: string): Promise<{ readonly key: string } | undefined> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM filius_session WHERE expires_at <= now()');
|
||||
const project = await client.query('SELECT 1 FROM filius_doc WHERE project_uuid = $1 FOR SHARE', [claims.project]);
|
||||
if (project.rowCount !== 1) {
|
||||
await client.query('ROLLBACK');
|
||||
return undefined;
|
||||
}
|
||||
const values = [claims.user, claims.login, claims.name, claims.color, claims.language, SESSION_SECONDS];
|
||||
let kept = key === undefined ? undefined : hashOf(key);
|
||||
if (kept !== undefined) {
|
||||
const same = await client.query(
|
||||
`UPDATE filius_session SET login = $3, display_name = $4, color = $5, language = $6, expires_at = now() + make_interval(secs => $7)
|
||||
WHERE key_hash = $1 AND user_uuid = $2`,
|
||||
[kept, ...values],
|
||||
);
|
||||
if (same.rowCount !== 1) {
|
||||
await client.query('DELETE FROM filius_session WHERE key_hash = $1', [kept]);
|
||||
kept = undefined;
|
||||
}
|
||||
}
|
||||
let fresh = key;
|
||||
if (kept === undefined) {
|
||||
fresh = randomBytes(32).toString('base64url');
|
||||
kept = hashOf(fresh);
|
||||
await client.query(
|
||||
`INSERT INTO filius_session (key_hash, user_uuid, login, display_name, color, language, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now() + make_interval(secs => $7))`,
|
||||
[kept, ...values],
|
||||
);
|
||||
}
|
||||
await client.query('INSERT INTO filius_grant (key_hash, project_uuid, level) VALUES ($1, $2, $3) ON CONFLICT (key_hash, project_uuid) DO UPDATE SET level = EXCLUDED.level', [
|
||||
kept,
|
||||
claims.project,
|
||||
claims.level,
|
||||
]);
|
||||
await client.query('COMMIT');
|
||||
return { key: fresh! };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async access(key: string, project: string): Promise<ProjectAccess> {
|
||||
const result = await this.pool.query<PersonRow & { readonly known: boolean; readonly level: Level | null; readonly frozen: boolean | null }>(
|
||||
`SELECT s.user_uuid, s.login, s.display_name, s.color, s.language, d.project_uuid IS NOT NULL AS known, g.level, d.frozen
|
||||
FROM filius_session s
|
||||
LEFT JOIN filius_doc d ON d.project_uuid = $2
|
||||
LEFT JOIN filius_grant g ON g.key_hash = s.key_hash AND g.project_uuid = $2
|
||||
WHERE s.key_hash = $1 AND s.expires_at > now()`,
|
||||
[hashOf(key), project],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (row === undefined) return { kind: 'notSignedIn' };
|
||||
if (!row.known) return { kind: 'unknownProject', person: person(row) };
|
||||
if (row.level === null) return { kind: 'noAccess', person: person(row) };
|
||||
return { kind: 'granted', person: person(row), level: row.level, frozen: row.frozen === true };
|
||||
}
|
||||
|
||||
async revoke(user: string, project?: string): Promise<number> {
|
||||
const result =
|
||||
project === undefined
|
||||
? await this.pool.query('DELETE FROM filius_session WHERE user_uuid = $1', [user])
|
||||
: await this.pool.query('DELETE FROM filius_grant g USING filius_session s WHERE g.key_hash = s.key_hash AND s.user_uuid = $1 AND g.project_uuid = $2', [user, project]);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
async active(): Promise<ActiveSessions> {
|
||||
const users = await this.pool.query<{ user_uuid: string }>('SELECT DISTINCT user_uuid FROM filius_session WHERE expires_at > now() ORDER BY user_uuid');
|
||||
// Two devices, the same grant twice: the stronger level counts (the one the dashboard has to check); 'write' sorts after 'read'.
|
||||
const grants = await this.pool.query<{ user_uuid: string; project_uuid: string; level: Level }>(
|
||||
`SELECT s.user_uuid, g.project_uuid, max(g.level) AS level
|
||||
FROM filius_grant g JOIN filius_session s ON s.key_hash = g.key_hash
|
||||
WHERE s.expires_at > now()
|
||||
GROUP BY s.user_uuid, g.project_uuid ORDER BY s.user_uuid, g.project_uuid`,
|
||||
);
|
||||
return { users: users.rows.map((row) => row.user_uuid), grants: grants.rows.map((row) => ({ user: row.user_uuid, project: row.project_uuid, level: row.level })) };
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// filius_doc, the updates since then in filius_doc_update, in the order they
|
||||
// came. A project's row is written when the project is made, so that the
|
||||
// compaction's FOR UPDATE has a row to lock (a lock on a missing row locks
|
||||
// nothing; Scratch's lesson 6.13).
|
||||
// nothing; Scratch's lesson 6.13). A frozen project (its deadline passed,
|
||||
// plan §10.3) is read by everyone and written by no one.
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
@@ -25,9 +26,12 @@ export interface DocStore {
|
||||
append(project: string, update: Uint8Array): Promise<string>;
|
||||
/** A new state, which holds every update up to `upToSeq`: those go. */
|
||||
compact(project: string, state: Uint8Array, upToSeq: string): Promise<void>;
|
||||
/** A project frozen or thawed; false without the project. */
|
||||
freeze(project: string, frozen: boolean): Promise<boolean>;
|
||||
/** How many updates wait for the next compaction. */
|
||||
pending(project: string): Promise<number>;
|
||||
remove(project: string): Promise<void>;
|
||||
/** A project gone, its updates with it; false without the project. */
|
||||
remove(project: string): Promise<boolean>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,12 +48,13 @@ CREATE TABLE IF NOT EXISTS filius_doc_update (
|
||||
update bytea NOT NULL,
|
||||
PRIMARY KEY (project_uuid, seq)
|
||||
);
|
||||
ALTER TABLE filius_doc ADD COLUMN IF NOT EXISTS frozen boolean NOT NULL DEFAULT false;
|
||||
`;
|
||||
|
||||
const bytes = (buffer: Buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
export class PostgresStore implements DocStore {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
constructor(readonly pool: pg.Pool) {}
|
||||
|
||||
static connect(url: string): PostgresStore {
|
||||
return new PostgresStore(new pg.Pool({ connectionString: url }));
|
||||
@@ -94,13 +99,19 @@ export class PostgresStore implements DocStore {
|
||||
}
|
||||
}
|
||||
|
||||
async freeze(project: string, frozen: boolean): Promise<boolean> {
|
||||
const result = await this.pool.query('UPDATE filius_doc SET frozen = $2 WHERE project_uuid = $1', [project, frozen]);
|
||||
return result.rowCount === 1;
|
||||
}
|
||||
|
||||
async pending(project: string): Promise<number> {
|
||||
const result = await this.pool.query<{ count: string }>('SELECT count(*) FROM filius_doc_update WHERE project_uuid = $1', [project]);
|
||||
return Number(result.rows[0]!.count);
|
||||
}
|
||||
|
||||
async remove(project: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
|
||||
async remove(project: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
|
||||
return result.rowCount === 1;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
+60
-20
@@ -1,19 +1,24 @@
|
||||
// The shared documents (/sync, plan §5): Hocuspocus, a project a document.
|
||||
// A project is loaded from the store when its first person comes (a project
|
||||
// the store has not got is refused, with its reason: projects are made
|
||||
// through the service, F5.2), every
|
||||
// Who comes in is decided by the session the browser's cookie names (plan
|
||||
// §8.5): its grant to the project, read or write; a frozen project is read
|
||||
// by everyone; a refusal says why (SYNC_REFUSALS). A project is loaded from
|
||||
// the store when its first person comes (projects are made through the
|
||||
// service), every
|
||||
// update stored as it comes, compacted a minute after the last change, when
|
||||
// the last person leaves and after COMPACT_AFTER_UPDATES updates. The
|
||||
// janitor (plan §5.3) runs only here, as a client of its own: once when the
|
||||
// document is loaded, then 250 ms after the last update that was not its
|
||||
// own; its repair goes to everyone like any update.
|
||||
|
||||
import { Hocuspocus, type onAuthenticatePayload } from '@hocuspocus/server';
|
||||
import { Hocuspocus, type Connection } from '@hocuspocus/server';
|
||||
import type * as Y from 'yjs';
|
||||
import { JANITOR_ORIGIN, repair } from '@mdg-filius/doc';
|
||||
import type { AccessMessage, SyncRefusal } from '@mdg-filius/protocol';
|
||||
import { errorText, type Log } from './log.js';
|
||||
import { DocumentPersistence } from './persistence.js';
|
||||
import type { SessionPerson, SessionStore } from './sessions.js';
|
||||
import type { DocStore } from './store.js';
|
||||
import { isUuid, type Level } from './ticket.js';
|
||||
|
||||
/** How long after the last change a document is compacted (Hocuspocus' debounce of onStoreDocument). */
|
||||
export const COMPACT_AFTER_IDLE_MILLISECONDS = 60_000;
|
||||
@@ -22,27 +27,49 @@ export const COMPACT_AT_LATEST_MILLISECONDS = 600_000;
|
||||
/** The janitor's wait after an update (entprellt). */
|
||||
export const JANITOR_DELAY_MILLISECONDS = 250;
|
||||
|
||||
/** What a connection may do; thrown, it is refused. */
|
||||
export interface Access {
|
||||
readonly readOnly: boolean;
|
||||
/** Who, for the log. */
|
||||
readonly user?: string;
|
||||
/** The cookie that names a browser's session at F. */
|
||||
export const SESSION_COOKIE = 'filius_session';
|
||||
|
||||
/** Who is behind a connection, and with which grant (Hocuspocus' context of the connection). */
|
||||
export interface ConnectionContext {
|
||||
readonly person: SessionPerson;
|
||||
readonly level: Level;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
readonly store: DocStore;
|
||||
readonly sessions: SessionStore;
|
||||
readonly log: Log;
|
||||
readonly authenticate: (payload: onAuthenticatePayload) => Promise<Access>;
|
||||
readonly compactAfterIdleMilliseconds?: number;
|
||||
}
|
||||
|
||||
export interface Sync {
|
||||
readonly hocuspocus: Hocuspocus;
|
||||
/** The connections to a project, or to every project. */
|
||||
connections(project?: string): Connection[];
|
||||
/** A project's document not stored any more, while it is open (it is being deleted). */
|
||||
discard(project: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A refusal of a connection, with the reason the person's editor is told (Hocuspocus sends an error's `reason`). */
|
||||
export function refusal(reason: string): Error & { readonly reason: string } {
|
||||
export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } {
|
||||
return Object.assign(new Error(reason), { reason });
|
||||
}
|
||||
|
||||
/** A project's id: a UUID, as the dashboard makes them. */
|
||||
export const isProjectId = (name: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(name);
|
||||
/** A cookie's value from a Cookie header. */
|
||||
export function cookieValue(header: string | undefined, name: string): string | undefined {
|
||||
for (const part of (header ?? '').split(';')) {
|
||||
const cut = part.indexOf('=');
|
||||
if (cut > 0 && part.slice(0, cut).trim() === name) return part.slice(cut + 1).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What a connection is told when its access changes. */
|
||||
export function accessMessage(readOnly: boolean, frozen: boolean): string {
|
||||
const message: AccessMessage = { v: 1, kind: 'access', readOnly, frozen };
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
|
||||
/** The janitor on a document; the function that ends it. */
|
||||
function startJanitor(doc: Y.Doc, project: string, log: Log): () => void {
|
||||
@@ -66,17 +93,23 @@ function startJanitor(doc: Y.Doc, project: string, log: Log): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export function createSync({ store, log, authenticate, compactAfterIdleMilliseconds = COMPACT_AFTER_IDLE_MILLISECONDS }: SyncOptions): Hocuspocus {
|
||||
export function createSync({ store, sessions, log, compactAfterIdleMilliseconds = COMPACT_AFTER_IDLE_MILLISECONDS }: SyncOptions): Sync {
|
||||
const open = new Map<string, { readonly persistence: DocumentPersistence; stopJanitor?: () => void }>();
|
||||
return new Hocuspocus({
|
||||
const hocuspocus = new Hocuspocus({
|
||||
quiet: true,
|
||||
debounce: compactAfterIdleMilliseconds,
|
||||
maxDebounce: COMPACT_AT_LATEST_MILLISECONDS,
|
||||
async onAuthenticate(payload) {
|
||||
if (!isProjectId(payload.documentName)) throw refusal('notAProject');
|
||||
const access = await authenticate(payload);
|
||||
payload.connectionConfig.readOnly = access.readOnly;
|
||||
return access;
|
||||
async onAuthenticate({ documentName: project, requestHeaders, connectionConfig }): Promise<ConnectionContext> {
|
||||
if (!isUuid(project)) throw refusal('notAProject');
|
||||
const key = cookieValue(requestHeaders.cookie, SESSION_COOKIE);
|
||||
const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await sessions.access(key, project);
|
||||
if (access.kind !== 'granted') {
|
||||
log.info('sync.refused', { project, reason: access.kind, ...(access.kind === 'notSignedIn' ? {} : { user: access.person.login }) });
|
||||
throw refusal(access.kind);
|
||||
}
|
||||
connectionConfig.readOnly = access.level === 'read' || access.frozen;
|
||||
log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly });
|
||||
return { person: access.person, level: access.level };
|
||||
},
|
||||
async onLoadDocument({ documentName, document }) {
|
||||
const persistence = new DocumentPersistence(store, documentName, log);
|
||||
@@ -110,4 +143,11 @@ export function createSync({ store, log, authenticate, compactAfterIdleMilliseco
|
||||
log.info('doc.unloaded', { project: documentName });
|
||||
},
|
||||
});
|
||||
return {
|
||||
hocuspocus,
|
||||
connections: (project) => (project === undefined ? [...hocuspocus.documents.values()] : [hocuspocus.documents.get(project)].filter((doc) => doc !== undefined)).flatMap((doc) => doc.getConnections()),
|
||||
discard: async (project) => {
|
||||
await open.get(project)?.persistence.discard();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// The dashboard's entry ticket (plan §8.5), checked: itsdangerous'
|
||||
// URLSafeTimedSerializer as the dashboard signs it — the payload as compact
|
||||
// JSON, zlib-packed when that is shorter (then a leading "."), base64url;
|
||||
// the moment of signing as the shortest big-endian bytes, base64url; an
|
||||
// HMAC-SHA1 over both under the key sha1(salt + "signer" + secret)
|
||||
// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing and
|
||||
// not before it, as itsdangerous counts (whole seconds). What lies under a
|
||||
// matching signature the dashboard made: its base64 is read as Node reads
|
||||
// it (leniently, as Python does). Python's own tickets are the oracle
|
||||
// (tools/ticket-oracle).
|
||||
//
|
||||
// What the ticket says: who (`u`, the login `n`, the name `d`, the colour
|
||||
// `c`, the language `lang`), which project (`p`) and what for (`lvl`, read
|
||||
// or write). The keys are a contract with the dashboard.
|
||||
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
export const TICKET_SECONDS = 60;
|
||||
export const TICKET_SALT = 'mdg-filius-ticket';
|
||||
export const LEVELS = ['read', 'write'] as const;
|
||||
export type Level = (typeof LEVELS)[number];
|
||||
|
||||
export interface TicketClaims {
|
||||
readonly user: string;
|
||||
readonly login: string;
|
||||
readonly name: string;
|
||||
readonly color: string;
|
||||
readonly language: string;
|
||||
readonly project: string;
|
||||
readonly level: Level;
|
||||
}
|
||||
|
||||
/** Why a ticket is no good: for the log, never for the answer. */
|
||||
export class TicketError extends Error {}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** A UUID, as the dashboard makes them (lower case). */
|
||||
export const isUuid = (value: unknown): value is string => typeof value === 'string' && UUID.test(value);
|
||||
|
||||
/** The signing key itsdangerous derives (django-concat over SHA-1). */
|
||||
function signingKey(secret: string, salt: string): Buffer {
|
||||
return createHash('sha1').update(salt).update('signer').update(secret).digest();
|
||||
}
|
||||
|
||||
export function signature(value: string, secret: string, salt = TICKET_SALT): string {
|
||||
return createHmac('sha1', signingKey(secret, salt)).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
/** The payload a ticket carries, signature and age checked; `now` in seconds since 1970. */
|
||||
export function readSigned(ticket: string, secret: string, now: number, salt = TICKET_SALT): unknown {
|
||||
if (secret === '') throw new TicketError('no secret');
|
||||
const cut = ticket.lastIndexOf('.');
|
||||
if (cut < 0) throw new TicketError('no signature');
|
||||
const value = ticket.slice(0, cut);
|
||||
const expected = Buffer.from(signature(value, secret, salt));
|
||||
const given = Buffer.from(ticket.slice(cut + 1));
|
||||
if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw new TicketError('signature does not match');
|
||||
const stamp = value.lastIndexOf('.');
|
||||
if (stamp < 0) throw new TicketError('no timestamp');
|
||||
const age = now - Buffer.from(value.slice(stamp + 1), 'base64url').reduce((sum, byte) => sum * 256 + byte, 0);
|
||||
if (age > TICKET_SECONDS) throw new TicketError(`ticket ${age} s old`);
|
||||
if (age < 0) throw new TicketError('ticket from the future');
|
||||
const payload = value.slice(0, stamp);
|
||||
const packed = payload.startsWith('.');
|
||||
try {
|
||||
const bytes = Buffer.from(packed ? payload.slice(1) : payload, 'base64url');
|
||||
return JSON.parse((packed ? inflateSync(bytes) : bytes).toString('utf8')) as unknown;
|
||||
} catch {
|
||||
throw new TicketError('payload unreadable');
|
||||
}
|
||||
}
|
||||
|
||||
const text = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined);
|
||||
|
||||
/** A ticket's claims, or a TicketError. */
|
||||
export function verifyTicket(ticket: string, secret: string, now = Math.floor(Date.now() / 1000)): TicketClaims {
|
||||
const payload = readSigned(ticket, secret, now);
|
||||
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TicketError('payload is not an object');
|
||||
const fields = payload as Record<string, unknown>;
|
||||
if (!isUuid(fields.u)) throw new TicketError('no person');
|
||||
const login = text(fields.n);
|
||||
if (login === undefined) throw new TicketError('no login');
|
||||
if (!isUuid(fields.p)) throw new TicketError('no project');
|
||||
// Without a level nothing is granted: read is not the fallback, nothing is.
|
||||
if (!LEVELS.includes(fields.lvl as Level)) throw new TicketError('no level');
|
||||
return {
|
||||
user: fields.u,
|
||||
login,
|
||||
name: text(fields.d) ?? login,
|
||||
color: text(fields.c) ?? '',
|
||||
language: text(fields.lang) ?? 'de',
|
||||
project: fields.p,
|
||||
level: fields.lvl as Level,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Who comes into a project at F (F5.2) with real clients and a real
|
||||
// Postgres: the dashboard's ticket redeemed at /__auth for a cookie, and
|
||||
// the browser sent on to a place of F's own; the session's grant decides at
|
||||
// /sync — none, read or write —, and a refusal says why; a browser opening a
|
||||
// second project keeps its session, the next person on a shared device gets
|
||||
// one of their own; a page of another origin gets no WebSocket. The
|
||||
// dashboard's service with its key: makes and deletes projects (whoever is
|
||||
// in one is let go), freezes and thaws them (whoever is in one reads, and is
|
||||
// told), ends a person's grant or all their sessions (whoever is connected
|
||||
// is let go and refused when they come back), lists who has which grant.
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
|
||||
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
|
||||
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||
import { testDatabase, type TestDatabase } from './database.js';
|
||||
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
|
||||
import { signTicket } from './tickets.js';
|
||||
|
||||
let database: TestDatabase;
|
||||
let store: PostgresStore;
|
||||
let sessions: PostgresSessions;
|
||||
let sql: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await testDatabase();
|
||||
store = PostgresStore.connect(database.url);
|
||||
sessions = new PostgresSessions(store.pool);
|
||||
await store.migrate();
|
||||
await sessions.migrate();
|
||||
sql = new pg.Pool({ connectionString: database.url });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await sql?.end();
|
||||
await store?.close();
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
const newProject = () => makeProject(store);
|
||||
const start = (options: { readonly sessions?: SessionStore; readonly secureCookie?: boolean } = {}) =>
|
||||
startTestServer(store, options.sessions ?? sessions, options.secureCookie === undefined ? {} : { secureCookie: options.secureCookie });
|
||||
|
||||
/** A call of the dashboard's service. */
|
||||
function service(server: TestServer, method: string, path: string, body?: unknown, key: string | null = SERVICE_KEY): Promise<Response> {
|
||||
return fetch(`${server.base}${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', ...(key === null ? {} : { 'x-filius-service-key': key }) },
|
||||
...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
const answer = async (response: Response) => [response.status, await response.json()] as const;
|
||||
const deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
|
||||
const place = (doc: Y.Doc, id: string) => addDevice(doc, { id, kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
||||
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
/** The status a WebSocket upgrade to /sync is answered with (101 when it opens). */
|
||||
function upgrade(server: TestServer, headers: Record<string, string>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/sync`, { headers });
|
||||
socket.on('open', () => {
|
||||
socket.close();
|
||||
resolve(101);
|
||||
});
|
||||
socket.on('unexpected-response', (_request, response) => resolve(response.statusCode ?? 0));
|
||||
socket.on('error', () => {});
|
||||
});
|
||||
}
|
||||
|
||||
describe('the exchange at /__auth', () => {
|
||||
it('redeems a ticket for a session cookie and sends the browser on', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const response = await exchange(server, project, { login: 'ab12', level: 'read' }, '/editor?mode=x#top');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe('/editor?mode=x#top');
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
const cookie = response.headers.get('set-cookie')!;
|
||||
expect(cookie).toMatch(new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Max-Age=${SESSION_SECONDS}; Path=/; HttpOnly; SameSite=Lax$`));
|
||||
expect(server.log.lines.find((line) => line.event === 'auth.signedIn')).toEqual({ level: 'info', event: 'auth.signedIn', project, user: 'ab12', grant: 'read' });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('sets the cookie for HTTPS only, unless told otherwise', async () => {
|
||||
const server = await start({ secureCookie: true });
|
||||
const response = await exchange(server, await newProject());
|
||||
expect(response.headers.get('set-cookie')).toMatch(/; HttpOnly; SameSite=Lax; Secure$/);
|
||||
await server.close();
|
||||
expect(sessionCookie('k', false)).toBe(`${SESSION_COOKIE}=k; Max-Age=43200; Path=/; HttpOnly; SameSite=Lax`);
|
||||
});
|
||||
|
||||
it('refuses a bad ticket without saying why, and says why in the log', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const old = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET, Math.floor(Date.now() / 1000) - 61);
|
||||
const forged = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, 'guessed');
|
||||
for (const ticket of [old, forged, '']) {
|
||||
const response = await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { redirect: 'manual' });
|
||||
expect(await answer(response)).toEqual([401, { error: 'invalidTicket' }]);
|
||||
expect(response.headers.get('set-cookie')).toBeNull();
|
||||
}
|
||||
expect((await fetch(`${server.base}/__auth`, { redirect: 'manual' })).status).toBe(401);
|
||||
// Only a GET exchanges: a ticket sent otherwise is not looked at.
|
||||
const ticket = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET);
|
||||
expect(await answer(await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { method: 'POST', redirect: 'manual' }))).toEqual([404, { error: 'notFound' }]);
|
||||
expect(server.log.lines.filter((line) => line.event === 'auth.ticketRefused').map((line) => line.reason)).toEqual(['ticket 61 s old', 'signature does not match', 'no signature', 'no signature']);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('refuses a ticket to a project F does not know', async () => {
|
||||
const server = await start();
|
||||
const project = randomUUID();
|
||||
expect(await answer(await exchange(server, project, { login: 'ab12' }))).toEqual([404, { error: 'unknownProject' }]);
|
||||
expect(server.log.lines.find((line) => line.event === 'auth.unknownProject')).toMatchObject({ project, user: 'ab12' });
|
||||
expect((await sql.query('SELECT count(*)::int AS n FROM filius_grant WHERE project_uuid = $1', [project])).rows[0].n).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/editor', '/editor'],
|
||||
['/a/b?c=d#e', '/a/b?c=d#e'],
|
||||
['relative', '/relative'],
|
||||
[null, '/'],
|
||||
['', '/'],
|
||||
['//elsewhere.test/x', '/'],
|
||||
['/\\elsewhere.test/x', '/'],
|
||||
['/\t/elsewhere.test', '/'],
|
||||
['/.//elsewhere.test', '/'],
|
||||
['https://elsewhere.test/', '/'],
|
||||
['javascript:alert(1)', '/'],
|
||||
['http://filius.invalid/x', '/x'],
|
||||
])('sends the browser after the exchange from %j to %j', (next, place) => {
|
||||
expect(safeNext(next)).toBe(place);
|
||||
});
|
||||
|
||||
it('keeps the session when the same browser opens a second project, and takes the newer level', async () => {
|
||||
const server = await start();
|
||||
const [first, second] = [await newProject(), await newProject()];
|
||||
const user = randomUUID();
|
||||
const cookie = await signIn(server, first, { user });
|
||||
const again = await exchange(server, second, { user, cookie, level: 'read' });
|
||||
expect(again.headers.get('set-cookie')!.split(';')[0]).toBe(cookie);
|
||||
const a = await join(server, first, { cookie });
|
||||
const b = await join(server, second, { cookie });
|
||||
expect([a.provider.authorizedScope, b.provider.authorizedScope]).toEqual(['read-write', 'readonly']);
|
||||
// The same project with a read ticket: from now on it is read.
|
||||
await exchange(server, first, { user, cookie, level: 'read' });
|
||||
const c = await join(server, first, { cookie });
|
||||
expect(c.provider.authorizedScope).toBe('readonly');
|
||||
for (const each of [a, b, c]) each.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('gives the next person on the same browser a session of their own, and ends the last one', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const theirs = await signIn(server, project, { login: 'mia' });
|
||||
const mine = await exchange(server, project, { login: 'ben', cookie: theirs });
|
||||
expect(mine.headers.get('set-cookie')!.split(';')[0]).not.toBe(theirs);
|
||||
await expect(join(server, project, { cookie: theirs })).rejects.toThrow('refused: notSignedIn');
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('lengthens a session with every ticket and forgets the ones that ran out', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const user = randomUUID();
|
||||
const cookie = await signIn(server, project, { user });
|
||||
await sql.query("UPDATE filius_session SET expires_at = now() + interval '1 minute' WHERE user_uuid = $1", [user]);
|
||||
await exchange(server, project, { user, cookie });
|
||||
const left = (await sql.query('SELECT extract(epoch FROM expires_at - now())::int AS s FROM filius_session WHERE user_uuid = $1', [user])).rows[0].s as number;
|
||||
expect(left).toBeGreaterThan(SESSION_SECONDS - 60);
|
||||
const gone = randomUUID();
|
||||
await signIn(server, project, { user: gone });
|
||||
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
|
||||
await signIn(server, project);
|
||||
expect((await sql.query('SELECT count(*)::int AS n FROM filius_session WHERE user_uuid = $1', [gone])).rows[0].n).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the way into a shared document', () => {
|
||||
it('refuses without a session, with a session that ran out, and without a grant to the project — saying which', async () => {
|
||||
const server = await start();
|
||||
const [project, other] = [await newProject(), await newProject()];
|
||||
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
|
||||
await expect(join(server, project, { cookie: `${SESSION_COOKIE}=made-up` })).rejects.toThrow('refused: notSignedIn');
|
||||
const user = randomUUID();
|
||||
const cookie = await signIn(server, other, { user, login: 'ab12' });
|
||||
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
|
||||
await expect(join(server, 'not-a-project', { cookie })).rejects.toThrow('refused: notAProject');
|
||||
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [user]);
|
||||
await expect(join(server, other, { cookie })).rejects.toThrow('refused: notSignedIn');
|
||||
expect(server.log.lines.filter((line) => line.event === 'sync.refused').map(({ reason, user: login }) => [reason, login])).toEqual([
|
||||
['notSignedIn', undefined],
|
||||
['notSignedIn', undefined],
|
||||
['noAccess', 'ab12'],
|
||||
['notSignedIn', undefined],
|
||||
]);
|
||||
expect(SYNC_REFUSALS).toEqual(['notAProject', 'notSignedIn', 'noAccess', 'unknownProject']);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('logs who joined and whether they read', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const person = await enter(server, project, { login: 'ab12', level: 'read' });
|
||||
expect(server.log.lines.find((line) => line.event === 'sync.joined')).toEqual({ level: 'info', event: 'sync.joined', project, user: 'ab12', readOnly: true });
|
||||
person.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("refuses a WebSocket from a page of another origin, and takes the editor's and one without an origin", async () => {
|
||||
const server = await start();
|
||||
expect(await upgrade(server, { origin: 'https://elsewhere.test' })).toBe(403);
|
||||
expect(await upgrade(server, { origin: EDITOR_ORIGIN })).toBe(101);
|
||||
expect(await upgrade(server, {})).toBe(101);
|
||||
expect(server.log.lines.filter((line) => line.event === 'sync.foreignOrigin').map((line) => line.origin)).toEqual(['https://elsewhere.test']);
|
||||
const project = await newProject();
|
||||
const person = await enter(server, project, {}, { origin: EDITOR_ORIGIN });
|
||||
expect(person.provider.isSynced).toBe(true);
|
||||
person.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('reads a cookie among others, and nothing that only looks like it', () => {
|
||||
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
|
||||
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
|
||||
expect(cookieValue(`x${SESSION_COOKIE}=key; =y`, SESSION_COOKIE)).toBeUndefined();
|
||||
expect(cookieValue(undefined, SESSION_COOKIE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the dashboard's service", () => {
|
||||
it('lets in only with the service key', async () => {
|
||||
const server = await start();
|
||||
for (const key of [null, '', 'wrong', `${SERVICE_KEY}x`]) expect(await answer(await service(server, 'GET', '/svc/sessions', undefined, key))).toEqual([403, { error: 'forbidden' }]);
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.forbidden')).toHaveLength(4);
|
||||
expect([serviceKeyMatches('k', 'k'), serviceKeyMatches('k', ''), serviceKeyMatches('', ''), serviceKeyMatches(['k'], 'k')]).toEqual([true, false, false, false]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('makes an empty project once', async () => {
|
||||
const server = await start();
|
||||
const project = randomUUID();
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([201, { project }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([409, { error: 'exists' }]);
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, (await store.load(project))!.state);
|
||||
expect(readProject(doc)).toEqual(emptyProject());
|
||||
const person = await enter(server, project);
|
||||
expect(deviceCount(person.doc)).toBe(0);
|
||||
person.close();
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.created').map((line) => line.project)).toEqual([project]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('answers what it cannot read, what it does not know and what is too large', async () => {
|
||||
const server = await start();
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', '{"project":'))).toEqual([400, { error: 'notJson' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', '[1]'))).toEqual([400, { error: 'notAnObject' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', 'null'))).toEqual([400, { error: 'notAnObject' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x'.repeat(SERVICE_BODY_BYTES) }))).toEqual([413, { error: 'tooLarge' }]);
|
||||
expect(await answer(await service(server, 'GET', '/svc/projects'))).toEqual([404, { error: 'notFound' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/sessions', {}))).toEqual([404, { error: 'notFound' }]);
|
||||
expect(await answer(await service(server, 'PUT', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'notFound' }]);
|
||||
expect(await answer(await service(server, 'DELETE', '/svc/projects/x'))).toEqual([400, { error: 'notAProject' }]);
|
||||
expect(await answer(await service(server, 'DELETE', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'unknownProject' }]);
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: true }))).toEqual([404, { error: 'unknownProject' }]);
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: 'yes' }))).toEqual([400, { error: 'frozenNotABoolean' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/projects/x/freeze', { frozen: true }))).toEqual([400, { error: 'notAProject' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: 'x' }))).toEqual([400, { error: 'notAPerson' }]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: randomUUID(), project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('deletes a project: whoever is in it is let go and finds no project when coming back, nothing stored after', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const cookie = await signIn(server, project);
|
||||
const person = await join(server, project, { cookie });
|
||||
place(person.doc, 'a');
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([200, { deleted: true, disconnected: 1 }]);
|
||||
await until(() => person.closings.length === 1);
|
||||
expect(person.closings).toEqual(['deleted']);
|
||||
await expect(join(server, project, { cookie })).rejects.toThrow('refused: unknownProject');
|
||||
// What the person does now is stored nowhere.
|
||||
place(person.doc, 'b');
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(await store.load(project)).toBeUndefined();
|
||||
expect(server.log.lines.filter((line) => ['store.failed', 'doc.unloadFailed'].includes(line.event as string))).toEqual([]);
|
||||
expect(server.log.lines.find((line) => line.event === 'svc.deleted')).toMatchObject({ project, disconnected: 1 });
|
||||
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([404, { error: 'unknownProject' }]);
|
||||
expect(SYNC_CLOSINGS).toEqual(['revoked', 'deleted']);
|
||||
person.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('freezes a project: whoever writes in it reads from then on and is told, and writes again when it is thawed', async () => {
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const writer = await enter(server, project, { level: 'write' });
|
||||
const reader = await enter(server, project, { level: 'read' });
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
|
||||
await until(() => writer.messages.length === 1);
|
||||
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||
// Whoever comes now reads too, with a write grant; what they change is not taken.
|
||||
const late = await enter(server, project, { level: 'write' });
|
||||
expect(late.provider.authorizedScope).toBe('readonly');
|
||||
place(late.doc, 'a');
|
||||
await sleep(300);
|
||||
expect(await store.pending(project)).toBe(0);
|
||||
expect(deviceCount(writer.doc)).toBe(0);
|
||||
late.close();
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
|
||||
await until(() => writer.messages.length === 2);
|
||||
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||
place(writer.doc, 'b');
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
await until(() => deviceCount(reader.doc) === 1);
|
||||
// The reader was told nothing: it read before and reads still.
|
||||
expect(reader.messages).toEqual([]);
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
|
||||
writer.close();
|
||||
reader.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("ends a person's grant to one project: their connection there is let go and refused, the others stay", async () => {
|
||||
const server = await start();
|
||||
const [project, other] = [await newProject(), await newProject()];
|
||||
const user = randomUUID();
|
||||
const cookie = await signIn(server, project, { user });
|
||||
await exchange(server, other, { user, cookie });
|
||||
const here = await join(server, project, { cookie });
|
||||
const there = await join(server, other, { cookie });
|
||||
const someone = await enter(server, project);
|
||||
expect(await answer(await service(server, 'POST', '/svc/revoke', { user, project }))).toEqual([200, { revoked: 1, disconnected: 1 }]);
|
||||
await until(() => here.closings.length === 1);
|
||||
expect(here.closings).toEqual(['revoked']);
|
||||
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
|
||||
expect([there.closings, someone.closings]).toEqual([[], []]);
|
||||
place(there.doc, 'a');
|
||||
await until(async () => (await store.pending(other)) === 1);
|
||||
expect(server.log.lines.find((line) => line.event === 'svc.revoked')).toMatchObject({ user, project, revoked: 1, disconnected: 1 });
|
||||
for (const each of [here, there, someone]) each.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('ends all sessions of a person: every connection of theirs is let go, and they are signed out', async () => {
|
||||
const server = await start();
|
||||
const [project, other] = [await newProject(), await newProject()];
|
||||
const user = randomUUID();
|
||||
const phone = await signIn(server, project, { user });
|
||||
const laptop = await signIn(server, other, { user });
|
||||
const a = await join(server, project, { cookie: phone });
|
||||
const b = await join(server, other, { cookie: laptop });
|
||||
const someone = await enter(server, project);
|
||||
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 2, disconnected: 2 }]);
|
||||
await until(() => a.closings.length === 1 && b.closings.length === 1);
|
||||
expect([a.closings, b.closings]).toEqual([['revoked'], ['revoked']]);
|
||||
await expect(join(server, project, { cookie: phone })).rejects.toThrow('refused: notSignedIn');
|
||||
await expect(join(server, other, { cookie: laptop })).rejects.toThrow('refused: notSignedIn');
|
||||
expect(someone.closings).toEqual([]);
|
||||
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 0, disconnected: 0 }]);
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.revoked').at(-1)).not.toHaveProperty('project');
|
||||
for (const each of [a, b, someone]) each.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('lists who is signed in with which grant, the stronger of two devices', async () => {
|
||||
await sql.query('DELETE FROM filius_session');
|
||||
const server = await start();
|
||||
const [project, other] = [await newProject(), await newProject()];
|
||||
const [mia, ben] = [randomUUID(), randomUUID()].sort() as [string, string];
|
||||
await signIn(server, project, { user: mia, level: 'read' });
|
||||
await signIn(server, project, { user: mia, level: 'write' });
|
||||
await signIn(server, other, { user: ben, level: 'read' });
|
||||
const gone = randomUUID();
|
||||
await signIn(server, other, { user: gone });
|
||||
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
|
||||
const grants = [
|
||||
{ user: mia, project, level: 'write' },
|
||||
{ user: ben, project: other, level: 'read' },
|
||||
].sort((x, y) => (x.user + x.project).localeCompare(y.user + y.project));
|
||||
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([200, { users: [mia, ben].sort(), grants }]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('answers 500 when something breaks, and logs it', async () => {
|
||||
const broken = Object.assign(Object.create(sessions) as PostgresSessions, {
|
||||
active: async () => {
|
||||
throw new Error('database gone');
|
||||
},
|
||||
});
|
||||
const server = await start({ sessions: broken });
|
||||
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([500, { error: 'internal' }]);
|
||||
expect(server.log.lines.find((line) => line.event === 'http.failed')).toMatchObject({ level: 'error', path: '/svc/sessions', error: 'database gone' });
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// What the server's tests share: the log kept in memory, a server F with
|
||||
// test secrets, a project as the service makes it, a person signed in with
|
||||
// a ticket as the dashboard signs one, and that person in a project with
|
||||
// Hocuspocus' own provider over a real WebSocket (bringing the session's
|
||||
// cookie, as the browser does).
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
||||
import { expect } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { emptyProject, writeProject } from '@mdg-filius/doc';
|
||||
import { startServer, type DocStore, type FiliusServer, type Log, type ServerOptions, type SessionStore } from '../src/index.js';
|
||||
import { signTicket } from './tickets.js';
|
||||
|
||||
export const TICKET_SECRET = 'test-ticket-secret';
|
||||
export const SERVICE_KEY = 'test-service-key';
|
||||
export const EDITOR_ORIGIN = 'http://editor.test';
|
||||
|
||||
export type MemoryLog = Log & { readonly lines: Record<string, unknown>[] };
|
||||
|
||||
/** The log's lines, kept. */
|
||||
export function memoryLog(): MemoryLog {
|
||||
const lines: Record<string, unknown>[] = [];
|
||||
const entry =
|
||||
(level: string) =>
|
||||
(event: string, fields: Record<string, unknown> = {}) => {
|
||||
lines.push(JSON.parse(JSON.stringify({ level, event, ...fields })) as Record<string, unknown>);
|
||||
};
|
||||
return { lines, info: entry('info'), warn: entry('warn'), error: entry('error') };
|
||||
}
|
||||
|
||||
export type TestServer = FiliusServer & { readonly log: MemoryLog; readonly base: string };
|
||||
|
||||
export async function startTestServer(store: DocStore, sessions: SessionStore, options: Partial<ServerOptions> = {}): Promise<TestServer> {
|
||||
const log = memoryLog();
|
||||
const server = await startServer({
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
store,
|
||||
sessions,
|
||||
log,
|
||||
ticketSecret: TICKET_SECRET,
|
||||
serviceKey: SERVICE_KEY,
|
||||
origins: [EDITOR_ORIGIN],
|
||||
secureCookie: false,
|
||||
...options,
|
||||
});
|
||||
return Object.assign(server, { log, base: `http://127.0.0.1:${server.port}` });
|
||||
}
|
||||
|
||||
/** A new project as the service makes it: an empty FILIUS project. */
|
||||
export async function newProject(store: DocStore): Promise<string> {
|
||||
const project = randomUUID();
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
|
||||
return project;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
readonly user?: string;
|
||||
readonly login?: string;
|
||||
readonly level?: 'read' | 'write';
|
||||
/** The cookie the browser brings to /__auth. */
|
||||
readonly cookie?: string;
|
||||
}
|
||||
|
||||
/** The answer of /__auth to a fresh ticket. */
|
||||
export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', level = 'write', cookie }: Person = {}, next = '/'): Promise<Response> {
|
||||
const ticket = signTicket({ u: user, n: login, d: 'Ada Beispiel', c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET);
|
||||
return fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(next)}`, {
|
||||
redirect: 'manual',
|
||||
...(cookie === undefined ? {} : { headers: { cookie } }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Signed in through /__auth: the cookie for the browser's next requests. */
|
||||
export async function signIn(server: TestServer, project: string, person: Person = {}): Promise<string> {
|
||||
const response = await exchange(server, project, person);
|
||||
expect(response.status).toBe(302);
|
||||
return response.headers.get('set-cookie')!.split(';')[0]!;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
readonly doc: Y.Doc;
|
||||
readonly provider: HocuspocusProvider;
|
||||
/** Why F closed the connection, in order. */
|
||||
readonly closings: string[];
|
||||
/** Why F refused it after it had been in, in order. */
|
||||
readonly refusals: string[];
|
||||
/** Stateless messages from F. */
|
||||
readonly messages: unknown[];
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface JoinOptions {
|
||||
readonly cookie?: string;
|
||||
/** Comes back at once when let go (no delay, as a client may). */
|
||||
readonly eager?: boolean;
|
||||
readonly origin?: string;
|
||||
}
|
||||
|
||||
/** A person in a project: synced, or refused. */
|
||||
export function join(server: FiliusServer, project: string, { cookie, eager = false, origin }: JoinOptions = {}): Promise<Client> {
|
||||
const doc = new Y.Doc();
|
||||
const headers = { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) };
|
||||
class Browser extends WebSocket {
|
||||
constructor(url: string, protocols?: string | string[]) {
|
||||
super(url, protocols, { headers });
|
||||
// Let go while it is still connecting (the test ends, the service has gone), ws reports an error no one awaits.
|
||||
this.on('error', () => {});
|
||||
}
|
||||
}
|
||||
const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: Browser, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) });
|
||||
const closings: string[] = [];
|
||||
const refusals: string[] = [];
|
||||
const messages: unknown[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
let synced = false;
|
||||
const timer = setTimeout(() => {
|
||||
provider.destroy();
|
||||
socket.destroy();
|
||||
reject(new Error('not synced within 3 s'));
|
||||
}, 3_000);
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: project,
|
||||
document: doc,
|
||||
token: 'cookie',
|
||||
onSynced: () => {
|
||||
if (synced) return;
|
||||
synced = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ doc, provider, closings, refusals, messages, close: () => (provider.destroy(), socket.destroy()) });
|
||||
},
|
||||
onAuthenticationFailed: ({ reason }) => {
|
||||
if (synced) {
|
||||
refusals.push(reason);
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
provider.destroy();
|
||||
socket.destroy();
|
||||
reject(new Error(`refused: ${reason}`));
|
||||
},
|
||||
onStateless: ({ payload }) => messages.push(JSON.parse(payload)),
|
||||
onClose: ({ event }) => {
|
||||
if (synced) closings.push(event.reason);
|
||||
else reject(new Error(`closed: ${event.reason}`));
|
||||
},
|
||||
});
|
||||
provider.attach();
|
||||
});
|
||||
}
|
||||
|
||||
/** Signed in with a fresh ticket, then in the project. */
|
||||
export async function enter(server: TestServer, project: string, person: Person = {}, options: Omit<JoinOptions, 'cookie'> = {}): Promise<Client> {
|
||||
return join(server, project, { ...options, cookie: await signIn(server, project, person) });
|
||||
}
|
||||
|
||||
export const until = async (condition: () => boolean | Promise<boolean>, milliseconds = 5_000) => {
|
||||
for (let waited = 0; !(await condition()); waited += 25) {
|
||||
if (waited > milliseconds) throw new Error('timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
};
|
||||
@@ -8,23 +8,26 @@
|
||||
// answers, and the log is JSON.
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, connect, emptyProject, readProject, ROOTS, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { COMPACT_AFTER_IDLE_MILLISECONDS, COMPACT_AFTER_UPDATES, JANITOR_DELAY_MILLISECONDS, PostgresStore, startServer, type Access, type FiliusServer, type Log } from '../src/index.js';
|
||||
import { COMPACT_AFTER_IDLE_MILLISECONDS, COMPACT_AFTER_UPDATES, JANITOR_DELAY_MILLISECONDS, PostgresSessions, PostgresStore, type DocStore } from '../src/index.js';
|
||||
import { testDatabase, type TestDatabase } from './database.js';
|
||||
import { enter, newProject as makeProject, startTestServer, until, type TestServer } from './harness.js';
|
||||
|
||||
let database: TestDatabase;
|
||||
let store: PostgresStore;
|
||||
let sessions: PostgresSessions;
|
||||
let sql: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await testDatabase();
|
||||
store = PostgresStore.connect(database.url);
|
||||
sessions = new PostgresSessions(store.pool);
|
||||
await store.migrate();
|
||||
await sessions.migrate();
|
||||
sql = new pg.Pool({ connectionString: database.url });
|
||||
}, 60_000);
|
||||
|
||||
@@ -34,82 +37,12 @@ afterAll(async () => {
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
/** The log's lines, kept. */
|
||||
function memoryLog(): Log & { readonly lines: Record<string, unknown>[] } {
|
||||
const lines: Record<string, unknown>[] = [];
|
||||
const entry =
|
||||
(level: string) =>
|
||||
(event: string, fields: Record<string, unknown> = {}) => {
|
||||
lines.push(JSON.parse(JSON.stringify({ level, event, ...fields })) as Record<string, unknown>);
|
||||
};
|
||||
return { lines, info: entry('info'), warn: entry('warn'), error: entry('error') };
|
||||
}
|
||||
const start = (options: { readonly idle?: number; readonly store?: DocStore } = {}): Promise<TestServer> =>
|
||||
startTestServer(options.store ?? store, sessions, options.idle === undefined ? {} : { compactAfterIdleMilliseconds: options.idle });
|
||||
const newProject = () => makeProject(store);
|
||||
/** A person signed in to the project with a write grant (a reader with `read`), in it. */
|
||||
const join = (server: TestServer, project: string, eager = false, level: 'read' | 'write' = 'write') => enter(server, project, { level }, { eager });
|
||||
|
||||
async function start(options: { readonly access?: Access; readonly idle?: number } = {}): Promise<FiliusServer & { readonly log: ReturnType<typeof memoryLog> }> {
|
||||
const log = memoryLog();
|
||||
const server = await startServer({
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
store,
|
||||
log,
|
||||
authenticate: async () => options.access ?? { readOnly: false },
|
||||
...(options.idle === undefined ? {} : { compactAfterIdleMilliseconds: options.idle }),
|
||||
});
|
||||
return Object.assign(server, { log });
|
||||
}
|
||||
|
||||
/** A new project as the service makes it: an empty FILIUS project. */
|
||||
async function newProject(): Promise<string> {
|
||||
const project = randomUUID();
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
|
||||
return project;
|
||||
}
|
||||
|
||||
interface Client {
|
||||
readonly doc: Y.Doc;
|
||||
readonly provider: HocuspocusProvider;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** A person in a project: synced, or refused. `eager` comes back at once when let go (no delay, as a client may). */
|
||||
function join(server: FiliusServer, project: string, eager = false): Promise<Client> {
|
||||
const doc = new Y.Doc();
|
||||
const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: WebSocket, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) });
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
provider.destroy();
|
||||
socket.destroy();
|
||||
reject(new Error('not synced within 3 s'));
|
||||
}, 3_000);
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: project,
|
||||
document: doc,
|
||||
token: 'test',
|
||||
onSynced: () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ doc, provider, close: () => (provider.destroy(), socket.destroy()) });
|
||||
},
|
||||
onAuthenticationFailed: ({ reason }) => {
|
||||
clearTimeout(timer);
|
||||
provider.destroy();
|
||||
socket.destroy();
|
||||
reject(new Error(`refused: ${reason}`));
|
||||
},
|
||||
onClose: () => reject(new Error('closed')),
|
||||
});
|
||||
provider.attach();
|
||||
});
|
||||
}
|
||||
|
||||
const until = async (condition: () => boolean | Promise<boolean>, milliseconds = 5_000) => {
|
||||
for (let waited = 0; !(await condition()); waited += 25) {
|
||||
if (waited > milliseconds) throw new Error('timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
};
|
||||
const deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
|
||||
const NAMES = { pc: 'Rechner', networkSwitch: 'Switch' } as const;
|
||||
/** A device as the palette puts one down. */
|
||||
@@ -125,7 +58,7 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) >= 2);
|
||||
first.close();
|
||||
// The last person left: compacted and unloaded.
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(await store.pending(project)).toBe(0);
|
||||
await server.close();
|
||||
|
||||
@@ -186,7 +119,7 @@ describe('server F keeps the shared documents', () => {
|
||||
const person = await join(server, project);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
person.close();
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(await rev()).toBe(0);
|
||||
expect(server.log.lines.some((line) => line.event === 'doc.compacted')).toBe(false);
|
||||
await server.close();
|
||||
@@ -214,7 +147,7 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
await store.remove(project);
|
||||
person.close();
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(server.log.lines.filter((line) => line.event === 'store.failed').map((line) => line.project)).toEqual([project]);
|
||||
expect(await store.load(project)).toBeUndefined();
|
||||
await server.close();
|
||||
@@ -312,13 +245,14 @@ describe('server F keeps the shared documents', () => {
|
||||
expect([COMPACT_AFTER_UPDATES, COMPACT_AFTER_IDLE_MILLISECONDS, JANITOR_DELAY_MILLISECONDS]).toEqual([500, 60_000, 250]);
|
||||
});
|
||||
|
||||
it('refuses a project it does not know, and makes none', async () => {
|
||||
const server = await start();
|
||||
const project = randomUUID();
|
||||
it('refuses a project its store does not have when it comes to load it, and makes none', async () => {
|
||||
// Deleted between the sign-in and the loading: the store has it no more.
|
||||
const project = await newProject();
|
||||
const forgetful = Object.assign(Object.create(store) as PostgresStore, { load: async () => undefined });
|
||||
const server = await start({ store: forgetful });
|
||||
await expect(join(server, project)).rejects.toThrow('refused: unknownProject');
|
||||
expect(await store.load(project)).toBeUndefined();
|
||||
await expect(join(server, 'not-a-project')).rejects.toThrow('refused: notAProject');
|
||||
expect(server.log.lines.some((line) => line.event === 'doc.unknown' && line.project === project)).toBe(true);
|
||||
expect(server.sync.hocuspocus.getDocumentsCount()).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -330,8 +264,8 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) >= 1);
|
||||
writer.close();
|
||||
await writerServer.close();
|
||||
const server = await start({ access: { readOnly: true } });
|
||||
const reader = await join(server, project);
|
||||
const server = await start();
|
||||
const reader = await join(server, project, false, 'read');
|
||||
expect(deviceCount(reader.doc)).toBe(1);
|
||||
place(reader.doc, 'switch', 'networkSwitch', 0, 'reader');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
@@ -356,5 +290,6 @@ describe('server F keeps the shared documents', () => {
|
||||
expect(refused).toBe(404);
|
||||
await server.close();
|
||||
expect(server.log.lines.map((line) => line.event)).toEqual(['server.listening', 'server.closed']);
|
||||
expect(server.log.lines[0]).toEqual({ level: 'info', event: 'server.listening', port: server.port });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// The entry ticket checked in Node as itsdangerous checks it in Python
|
||||
// (tools/ticket-oracle → tickets.python.json): every ticket the oracle made
|
||||
// reads the same — the same payload, or refused where Python refuses it, for
|
||||
// the same reason (a signature that does not match, an age beyond 60 s or
|
||||
// below 0) —; tickets signed in Node carry the same JSON and the same moment
|
||||
// as Python's (packed by Node's zlib, Chromium's, the bytes differ; an
|
||||
// unpacked one is the same to the byte); and
|
||||
// the claims the server takes from a ticket: a person, a login, a project
|
||||
// and a level, or nothing.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readSigned, signature, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js';
|
||||
import { signTicket } from './tickets.js';
|
||||
|
||||
interface OracleCase {
|
||||
readonly name: string;
|
||||
readonly ticket: string;
|
||||
readonly now: number;
|
||||
readonly payload?: unknown;
|
||||
readonly error?: 'expired' | 'badSignature';
|
||||
}
|
||||
|
||||
const oracle = JSON.parse(readFileSync(new URL('./tickets.python.json', import.meta.url), 'utf8')) as {
|
||||
readonly secret: string;
|
||||
readonly salt: string;
|
||||
readonly ttl: number;
|
||||
readonly signedAt: number;
|
||||
readonly cases: readonly OracleCase[];
|
||||
};
|
||||
/** A ticket's JSON as signed, unpacked. */
|
||||
const unpacked = (ticket: string) => {
|
||||
const body = ticket.split('.').at(-3)!;
|
||||
const bytes = Buffer.from(body, 'base64url');
|
||||
return (ticket.startsWith('.') ? inflateSync(bytes) : bytes).toString('utf8');
|
||||
};
|
||||
const byName = (name: string) => oracle.cases.find((each) => each.name === name)!;
|
||||
|
||||
function outcome(ticket: string, now: number): { payload: unknown } | { error: string } {
|
||||
try {
|
||||
return { payload: readSigned(ticket, oracle.secret, now) };
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TicketError);
|
||||
const message = (error as Error).message;
|
||||
return { error: /old|future/.test(message) ? 'expired' : 'badSignature' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('the ticket as itsdangerous signs it', () => {
|
||||
it('shares the salt and the lifetime with the dashboard', () => {
|
||||
expect([TICKET_SALT, TICKET_SECONDS]).toEqual([oracle.salt, oracle.ttl]);
|
||||
});
|
||||
|
||||
it.each(oracle.cases.map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => {
|
||||
expect(outcome(each.ticket, each.now)).toEqual(each.error === undefined ? { payload: each.payload } : { error: each.error });
|
||||
});
|
||||
|
||||
it.each(oracle.cases.filter((each) => each.error === undefined).map((each) => [each.name, each] as const))('signs „%s“ with the same JSON at the same moment', (_name, each) => {
|
||||
const ours = signTicket(each.payload, oracle.secret, oracle.signedAt);
|
||||
const [body, stamp] = ours.split('.').slice(-3);
|
||||
const [theirBody, theirStamp] = each.ticket.split('.').slice(-3);
|
||||
expect(unpacked(ours)).toBe(unpacked(each.ticket));
|
||||
expect([ours.startsWith('.'), stamp]).toEqual([each.ticket.startsWith('.'), theirStamp]);
|
||||
if (!ours.startsWith('.')) expect(body).toBe(theirBody);
|
||||
expect(readSigned(ours, oracle.secret, oracle.signedAt)).toEqual(each.payload);
|
||||
});
|
||||
|
||||
it('covers a packed and an unpacked payload', () => {
|
||||
expect(byName('plain').ticket.startsWith('.')).toBe(true);
|
||||
expect(byName('listPayload').ticket.startsWith('.')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses without a secret, whatever the ticket', () => {
|
||||
expect(() => readSigned(signTicket({ a: 1 }, '', oracle.signedAt), '', oracle.signedAt)).toThrow('no secret');
|
||||
});
|
||||
|
||||
it('refuses a signed value without a moment of signing', () => {
|
||||
const value = Buffer.from('{}').toString('base64url');
|
||||
expect(() => readSigned(`${value}.${signature(value, oracle.secret)}`, oracle.secret, oracle.signedAt)).toThrow('no timestamp');
|
||||
});
|
||||
|
||||
it('refuses a signed payload that is not packed JSON', () => {
|
||||
const value = `${Buffer.from('{"u":').toString('base64url')}.${Buffer.from([1]).toString('base64url')}`;
|
||||
const forged = signTicket({}, oracle.secret, 1).replace(/^[^.]*\.[^.]*/, value);
|
||||
// Re-signed after the change, so that only the payload is wrong.
|
||||
const signed = `${value}.${forged.split('.').at(-1)}`;
|
||||
expect(() => readSigned(signed, oracle.secret, 1)).toThrow();
|
||||
expect(() => readSigned(`.${Buffer.from('no zlib').toString('base64url')}.AQ.x`, oracle.secret, 1)).toThrow(TicketError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the claims of a ticket', () => {
|
||||
const claims = (name: string) => verifyTicket(byName(name).ticket, oracle.secret, byName(name).now);
|
||||
|
||||
it('names the person, the project and the level', () => {
|
||||
expect(claims('plain')).toEqual({
|
||||
user: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc',
|
||||
login: 'ab12',
|
||||
name: 'Ada Beispiel',
|
||||
color: '#3366cc',
|
||||
language: 'de',
|
||||
project: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6',
|
||||
level: 'write',
|
||||
});
|
||||
expect(claims('readLevel').level).toBe('read');
|
||||
expect(claims('umlautsAndEmoji')).toMatchObject({ name: 'Jörg Ömer Weiß 🐧', language: 'en' });
|
||||
});
|
||||
|
||||
it('fills the name, the colour and the language in when they are missing or empty', () => {
|
||||
expect(claims('onlyRequired')).toMatchObject({ login: 'ab12', name: 'ab12', color: '', language: 'de', level: 'read' });
|
||||
const at = oracle.signedAt;
|
||||
const empty = { u: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', n: 'ab12', d: '', c: '', lang: '', p: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6', lvl: 'read' };
|
||||
expect(verifyTicket(signTicket(empty, 's', at), 's', at)).toMatchObject({ name: 'ab12', color: '', language: 'de' });
|
||||
expect(() => verifyTicket(signTicket({ ...empty, n: '' }, 's', at), 's', at)).toThrow('no login');
|
||||
expect(() => verifyTicket(signTicket({ ...empty, n: 7 }, 's', at), 's', at)).toThrow('no login');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['listPayload', 'payload is not an object'],
|
||||
['withoutLevel', 'no level'],
|
||||
['unknownLevel', 'no level'],
|
||||
['projectNotAUuid', 'no project'],
|
||||
['withoutName', 'no login'],
|
||||
])('refuses „%s“ (%s)', (name, reason) => {
|
||||
expect(() => claims(name)).toThrow(reason);
|
||||
});
|
||||
|
||||
it('refuses a person or a project that is not a UUID or only holds one, and null', () => {
|
||||
const at = oracle.signedAt;
|
||||
const person = '0b8f5f2e-1a3c-4d5e-8f90-123456789abc';
|
||||
const project = '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6';
|
||||
const read = (fields: object) => verifyTicket(signTicket({ u: person, n: 'ab12', p: project, lvl: 'read', ...fields }, 's', at), 's', at);
|
||||
expect(() => read({ u: 'ab12' })).toThrow('no person');
|
||||
expect(() => read({ u: `x${person}` })).toThrow('no person');
|
||||
expect(() => read({ u: `${person}x` })).toThrow('no person');
|
||||
expect(() => read({ p: `../${project}` })).toThrow('no project');
|
||||
expect(() => read({ u: person.toUpperCase() })).toThrow('no person');
|
||||
expect(() => verifyTicket(signTicket(null, 's', at), 's', at)).toThrow('payload is not an object');
|
||||
});
|
||||
|
||||
it('counts the age from now by default', () => {
|
||||
const ticket = signTicket({ u: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', n: 'ab12', p: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6', lvl: 'write' }, 's');
|
||||
expect(verifyTicket(ticket, 's').level).toBe('write');
|
||||
expect(() => verifyTicket(byName('plain').ticket, oracle.secret)).toThrow(TicketError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"itsdangerous": "2.2.0",
|
||||
"python": "3.13.5",
|
||||
"secret": "filius-test-secret",
|
||||
"salt": "mdg-filius-ticket",
|
||||
"ttl": 60,
|
||||
"signedAt": 1790000000,
|
||||
"cases": [
|
||||
{
|
||||
"name": "plain",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readLevel",
|
||||
"ticket": ".eJwVi0kKwkAQAL8i7dWGzJoZb_qT3kYCIQRFL-Lf7dyKouoLb7jCxG2UEQ0DJcGsxbCNPmGIKZc6t04scIHNU-IQHdXxpnS62_LaF1tdiatzSrXK0a60PVyoOe8OdQSNkhgzlY7ZmmKXmZECu9ZsZdRj-6weP40Ufn9uhyly.arE7gA.CA_tdrFV7T4m5P4ZTVcLpUTBby8",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "umlautsAndEmoji",
|
||||
"ticket": ".eJwVi7sNwjAURVexTMuT4m9sRmABan-eo0jBQlGAArEDFW1KNmABL8QIvHRH99zz4Fd-4F10xRSJIIJKoLNBcMV3IKTSxvbOh5j4nle6higkYSY8tu88sPY-48xOOLaV_dbXh2QiuVPK2rRVU6gDDViJLwS2iCyTiqCD8aDRZfCpjxBEpDlrNMVu2W2i830eF-TPP73wMJ4.arE7gA.iCZHG4Ent52jQ4US5NkTWZ-62pw",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Jörg Ömer Weiß 🐧",
|
||||
"c": "#3366cc",
|
||||
"lang": "en",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compressed",
|
||||
"ticket": ".eJxVzstqwzAQBdBfEeo2I2LrETvLdp1Vv0CPUS2QZWNbSUnIv2cSKCWLgctw5zA3XvmR710XdWwRGis9qKARutjvoWml0ubQ9dZ5vuOFqqP9TWPKyRYU6zQPCYUf7JKnbUNxnoqoJYhrFQ7TOifMg60rFhjruuFCkwpBgaDTP8S-XxD7-oMYQYwgdq3s8x06vUGeoA8pjfHP_7ItP7QISHmmYGITWi8dKKt7UNgF6P3BgW0crYNCHc3z7JypfFnShvz-ALoRWAg.arE7gA.FCNmtMClRGk5V70ufHkfaOaYXuY",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "maximiliane.sophie.charlotte.von.und.zu.beispielhausen-musterstein",
|
||||
"d": "Maximiliane Sophie Charlotte von und zu Beispielhausen-Musterstein",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sixtySecondsOld",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||
"now": 1790000060,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sixtyOneSecondsOld",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||
"now": 1790000061,
|
||||
"error": "expired"
|
||||
},
|
||||
{
|
||||
"name": "fromTheFuture",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||
"now": 1789999999,
|
||||
"error": "expired"
|
||||
},
|
||||
{
|
||||
"name": "otherSecret",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.4wnVSAZOvBXnfPb_aCCd2137AEI",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "scratchSalt",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.n87SY_X1-cvBv6gZmgmGTgGlIcs",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "tamperedPayload",
|
||||
"ticket": ".eAwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "noSignature",
|
||||
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "garbage",
|
||||
"ticket": "not a ticket",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "empty",
|
||||
"ticket": "",
|
||||
"now": 1790000000,
|
||||
"error": "badSignature"
|
||||
},
|
||||
{
|
||||
"name": "listPayload",
|
||||
"ticket": "WyJ1IiwicCJd.arE7gA.pXsWj5Myfb6h4fhFYYSxWDxcBxU",
|
||||
"now": 1790000000,
|
||||
"payload": [
|
||||
"u",
|
||||
"p"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "withoutLevel",
|
||||
"ticket": ".eJwVykkKwkAQQNGrSLm1ID2m2525SU0tgRAC4irk7pa7z-Of8IUnTNxGGdEwUBLMWgzb6BOGmHKpc-vEAg_YfSUO0VM9X0q3xdbPsdrmJE73lGqV_7vR_nZQ8z486ggaJTFmKh2zNcUuMyMFdtZsZVS4fm3rJZo.arE7gA.gZ_wCIiMnGMbf1tD13qYm6XzNzI",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknownLevel",
|
||||
"ticket": ".eJwVjUsKAjEQBa8i7daGyXcSd3qTTrojgRgGRDfi3e3siqIe7wtvuMJWUgvNChpyFT0HwdTyhsY6H-KeMpUKF5iaUjFWkRVvTKe79NfRZaiqqs7OxVhXO2g-VLAoHwqxGbbVFfQUMnpJjLnuBckU1ewltLhmn7FO-Nkn_P6Ydynf.arE7gA.VhUG56J86GGt_eX0M_Aq-Cr38qk",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "admin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "projectNotAUuid",
|
||||
"ticket": ".eJwdikEOwiAURK9ivlupBQrS7vQmH_gYEtI0teqi8e6O7t68eTs9aaI-huKKEaXZJjVkJyqUsVfa2MH5Sxg5JjrRjJSjNsAMvGY-3KQ-lioNKkEdrfU-_drG8x0iC3gBdN1Ztv_xapjvtW5Cny86xCHC.arE7gA.2ayBJAFLfDUpzgXtbnEy8N95QXk",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"d": "Ada Beispiel",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "../etc",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withoutName",
|
||||
"ticket": ".eJwVi0sOwjAMBe9itlhqYsdNuE3sOAipQgjxWVTcHXc3bzRvhzdcYNE6y8yOqZMhj-JYZ1swZeIia21dDc5gkZ6IROxYW79fQwwPfgTITCMbKXIvDdnrwGarYk8aerCXKcfts0X8fd5eDr8_LEIh2g.arE7gA.hLO9zSKg8cblJgKFXxatwaRMJNI",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"c": "#3366cc",
|
||||
"lang": "de",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onlyRequired",
|
||||
"ticket": ".eJwNykEKAzEIQNG7uK4wJppJehuNuhpKKbSb0rvX3efzvvCGOxw2U7IFkvaN7BI4cx1IrbOMcy61DTd4FFWjVvmsHEnedjdklYUc03Ht01DJajuH5Ch6fa7Cr1CH3x9Dch1o.arE7gA.YCJxP6HbXDZ_cvikkPTvJIRfC7Q",
|
||||
"now": 1790000000,
|
||||
"payload": {
|
||||
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||
"n": "ab12",
|
||||
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||
"lvl": "read"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Tickets as the dashboard signs them, made in Node for the server's tests
|
||||
// (the Python oracle shows the same JSON at the same moment: ticket.test.ts).
|
||||
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { signature, TICKET_SALT } from '../src/ticket.js';
|
||||
|
||||
export function signTicket(payload: unknown, secret: string, at = Math.floor(Date.now() / 1000), salt = TICKET_SALT): string {
|
||||
const json = Buffer.from(JSON.stringify(payload));
|
||||
const packed = deflateSync(json);
|
||||
const body = packed.length < json.length - 1 ? `.${packed.toString('base64url')}` : json.toString('base64url');
|
||||
let stamp = Buffer.alloc(0);
|
||||
for (let rest = at; rest > 0; rest = Math.floor(rest / 256)) stamp = Buffer.concat([Buffer.from([rest % 256]), stamp]);
|
||||
const value = `${body}.${stamp.toString('base64url')}`;
|
||||
return `${value}.${signature(value, secret, salt)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user