All checks were successful
CI / Build container images (pull_request) Successful in 3m27s
CI / Auth e2e pack (pull_request) Successful in 7m51s
CI / Lint, typecheck, test (pull_request) Successful in 4m49s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m51s
CI / Import/export fidelity gate (push) Successful in 54s
The tampered-token case flipped the LAST base64url character of the signature. Its low bits are padding that decoders ignore, so whenever a signature ends in 'A' (~1/16 of tokens) the flip to 'B' decodes to the same bytes and the token verifies — jose compares decoded bytes, unlike the pre-#188 homegrown code that compared encoded strings. Reproduced deterministically (20/20 A-ending signatures accepted the flip); CI run 477 and one local full-suite failure were this, not load. Flipping the first character makes the tamper always significant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { HocuspocusProvider } from '@hocuspocus/provider';
|
|
import type { CollabTokenClaims } from '@dorfteich/shared';
|
|
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
|
import { pino } from 'pino';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import * as Y from 'yjs';
|
|
|
|
import { createCollabServer } from './server.js';
|
|
import { freePort } from './testing/free-port.js';
|
|
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
|
|
|
|
const secret = 'integration-test-secret-32-chars!!';
|
|
const logger = pino({ enabled: false });
|
|
|
|
/** Poll `predicate` until it is true or the timeout elapses. */
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
throw new Error('timed out waiting for condition');
|
|
}
|
|
|
|
describe('collab authentication', () => {
|
|
let server: ReturnType<typeof createCollabServer>;
|
|
let url: string;
|
|
|
|
beforeAll(async () => {
|
|
server = createCollabServer({
|
|
version: 'test',
|
|
logger,
|
|
tokenSecret: secret,
|
|
pingDatabase: async () => ({ ok: true }),
|
|
persistence: new InMemoryPagePersistence(),
|
|
});
|
|
const port = await freePort();
|
|
await server.listen(port);
|
|
url = `ws://127.0.0.1:${port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.destroy();
|
|
});
|
|
|
|
function connect(
|
|
name: string,
|
|
token: string,
|
|
): {
|
|
provider: HocuspocusProvider;
|
|
doc: Y.Doc;
|
|
authenticated: () => boolean;
|
|
failed: () => boolean;
|
|
synced: () => boolean;
|
|
} {
|
|
const doc = new Y.Doc();
|
|
let authenticated = false;
|
|
let failed = false;
|
|
let synced = false;
|
|
// No WebSocketPolyfill: the provider falls back to Node's global WebSocket.
|
|
const provider = new HocuspocusProvider({
|
|
url,
|
|
name,
|
|
token,
|
|
document: doc,
|
|
onAuthenticated: () => {
|
|
authenticated = true;
|
|
},
|
|
onAuthenticationFailed: () => {
|
|
failed = true;
|
|
},
|
|
onSynced: () => {
|
|
synced = true;
|
|
},
|
|
});
|
|
return {
|
|
provider,
|
|
doc,
|
|
authenticated: () => authenticated,
|
|
failed: () => failed,
|
|
synced: () => synced,
|
|
};
|
|
}
|
|
|
|
it('accepts a valid token whose page matches the document', async () => {
|
|
const token = await signCollabToken(
|
|
{ userId: 'u1', pageId: 'page-ok', mode: 'rw' },
|
|
secret,
|
|
60,
|
|
);
|
|
const client = connect('page-ok', token);
|
|
await waitFor(client.authenticated);
|
|
expect(client.authenticated()).toBe(true);
|
|
expect(client.failed()).toBe(false);
|
|
client.provider.destroy();
|
|
client.doc.destroy();
|
|
});
|
|
|
|
const claims: CollabTokenClaims = { userId: 'u1', pageId: 'page-x', mode: 'rw' };
|
|
const rejectionCases: { label: string; name: string; token: () => Promise<string> }[] = [
|
|
{
|
|
label: 'an expired token',
|
|
name: 'page-x',
|
|
token: () => signCollabToken(claims, secret, -60),
|
|
},
|
|
{
|
|
label: 'a tampered token',
|
|
name: 'page-x',
|
|
token: async () => {
|
|
const [h, p, s = ''] = (await signCollabToken(claims, secret, 60)).split('.');
|
|
// Flip the FIRST signature character — its bits are all significant.
|
|
// The last character is not: its low bits are base64url padding that
|
|
// decoders ignore, so a last-char flip of a signature ending in 'A'
|
|
// decodes to the same bytes and verifies (jose compares decoded
|
|
// bytes, unlike the pre-#188 code that compared encoded strings).
|
|
const flipped = (s.startsWith('A') ? 'B' : 'A') + s.slice(1);
|
|
return `${h}.${p}.${flipped}`;
|
|
},
|
|
},
|
|
{
|
|
label: 'a token minted for a different page',
|
|
name: 'page-y',
|
|
token: () => signCollabToken(claims, secret, 60),
|
|
},
|
|
{
|
|
label: 'a token signed with the wrong secret',
|
|
name: 'page-x',
|
|
token: () => signCollabToken(claims, 'a-completely-different-secret-32c', 60),
|
|
},
|
|
];
|
|
|
|
it.each(rejectionCases)('rejects $label', async ({ name, token }) => {
|
|
const client = connect(name, await token());
|
|
await waitFor(client.failed);
|
|
expect(client.failed()).toBe(true);
|
|
expect(client.authenticated()).toBe(false);
|
|
client.provider.destroy();
|
|
client.doc.destroy();
|
|
});
|
|
|
|
it('lets a read-only client receive updates but discards its writes server-side', async () => {
|
|
const page = 'page-shared';
|
|
const rw = connect(
|
|
page,
|
|
await signCollabToken({ userId: 'w', pageId: page, mode: 'rw' }, secret, 60),
|
|
);
|
|
const ro = connect(
|
|
page,
|
|
await signCollabToken({ userId: 'r', pageId: page, mode: 'ro' }, secret, 60),
|
|
);
|
|
await waitFor(() => rw.synced() && ro.synced());
|
|
|
|
const rwMap = rw.doc.getMap<string>('m');
|
|
const roMap = ro.doc.getMap<string>('m');
|
|
|
|
// A write from the read-write client reaches the read-only client.
|
|
rw.doc.transact(() => rwMap.set('fromRw', 'a'));
|
|
await waitFor(() => roMap.get('fromRw') === 'a');
|
|
|
|
// The read-only client writes; the server must drop it.
|
|
ro.doc.transact(() => roMap.set('fromRo', 'b'));
|
|
// Barrier: a second rw write round-trips to ro; by the time it arrives the
|
|
// server has already processed (and dropped) the ro write that preceded it.
|
|
rw.doc.transact(() => rwMap.set('barrier', 'c'));
|
|
await waitFor(() => roMap.get('barrier') === 'c');
|
|
|
|
expect(rwMap.get('fromRo')).toBeUndefined();
|
|
|
|
rw.provider.destroy();
|
|
ro.provider.destroy();
|
|
rw.doc.destroy();
|
|
ro.doc.destroy();
|
|
});
|
|
});
|