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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user