dorfteich/packages/shared/src/collab-token.test.ts
Claude Opus 4.8 d4ebcfcfbe
All checks were successful
CD / Build and push images (push) Successful in 2m45s
CI / Lint, typecheck, test (push) Successful in 1m56s
CI / Auth e2e pack (push) Successful in 2m1s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 12s
Add collaboration token issuance and connection authentication (#34)
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).

- packages/shared: browser-safe token schema/types in `collab-token`, and the
  Node `crypto` sign/verify in `token-crypto` behind its own subpath export
  (`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
  `node:crypto`. Only HS256 is produced/accepted; the signature is checked in
  constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
  {token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
  service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
  document name, stores {userId, mode} context, and enforces `ro` via
  Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
  disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
  .env.example, stage docs); a dev default keeps native dev/test/CI running.

Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).

Closes #34

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:52:19 +02:00

65 lines
2.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { CollabTokenClaims } from './collab-token';
import { signCollabToken, verifyCollabToken } from './token-crypto';
const secret = 'test-secret-at-least-16-chars-long';
const claims: CollabTokenClaims = { userId: 'user-1', pageId: 'page-1', mode: 'rw' };
describe('collab token', () => {
it('round-trips valid claims', () => {
const token = signCollabToken(claims, secret, 60);
const result = verifyCollabToken(token, secret);
expect(result).toEqual({ valid: true, claims });
});
it('rejects a token signed with a different secret', () => {
const token = signCollabToken(claims, secret, 60);
expect(verifyCollabToken(token, 'another-secret-16-chars')).toEqual({
valid: false,
reason: 'bad_signature',
});
});
it('rejects a tampered payload', () => {
const token = signCollabToken(claims, secret, 60);
const [header, , signature] = token.split('.');
const forgedPayload = Buffer.from(
JSON.stringify({ ...claims, mode: 'rw', iat: 0, exp: 9999999999 }),
'utf8',
).toString('base64url');
const forged = `${header}.${forgedPayload}.${signature}`;
expect(verifyCollabToken(forged, secret).valid).toBe(false);
});
it('rejects an expired token', () => {
const token = signCollabToken(claims, secret, 60);
// 61 seconds later the 60 s token is past its expiry.
const later = Date.now() + 61_000;
expect(verifyCollabToken(token, secret, later)).toEqual({ valid: false, reason: 'expired' });
});
it('rejects a malformed token', () => {
expect(verifyCollabToken('not-a-jwt', secret)).toEqual({ valid: false, reason: 'malformed' });
expect(verifyCollabToken('a.b', secret).valid).toBe(false);
});
it('rejects an alg:none token even if the signature check is bypassed', () => {
// Craft a header advertising `none` and an empty signature; the empty
// signature cannot match a real HMAC, so this is caught as bad_signature,
// but the algorithm guard is the intended second line of defense.
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
'base64url',
);
const result = verifyCollabToken(`${header}.${payload}.`, secret);
expect(result.valid).toBe(false);
});
it('preserves the read-only mode claim', () => {
const roToken = signCollabToken({ ...claims, mode: 'ro' }, secret, 60);
const result = verifyCollabToken(roToken, secret);
expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } });
});
});