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