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:
2026-10-04 13:56:45 +00:00
co-authored by Claude Opus 5.5
parent 88db6eff7e
commit 5ded84cb33
24 changed files with 1794 additions and 151 deletions
+406
View File
@@ -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();
});
});
+167
View File
@@ -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));
}
};
+22 -87
View File
@@ -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 });
});
});
+147
View File
@@ -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);
});
});
+201
View File
@@ -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"
}
}
]
}
+15
View File
@@ -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)}`;
}