dorfteich/packages/shared/src/collab-token.test.ts
Claude Fable 5 3d1f4fda53
All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
#188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own
HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the
root key directly. Collaboration tokens are signed and verified by jose
with HS256 as an explicit allowlist; the sign/verify API turns async at
its three call sites. Unsubscribe tokens move from a purpose-prefix
string to the structural subkey, with a documented dual-verify window
(legacy derivation accepted until 2026-11-01, covering the 90-day TTL
of links in already-sent mail).

The cross-runtime property that justified the homegrown implementation
is now proven by a test: the built CJS and ESM dist artefacts round-trip
tokens in both directions in child processes (jose v6 reaches CJS via
Node's require(esm), pinned Node 22 images). Negative tests cover
cross-purpose subkeys, root-key-signed tokens, alg:none and RS256.

Refs #188 (ADR 0020)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 06:41:11 +02:00

123 lines
4.7 KiB
TypeScript

import { SignJWT } from 'jose';
import { describe, expect, it } from 'vitest';
import type { CollabTokenClaims } from './collab-token';
import { deriveTokenKey, 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' };
/** Sign the claims with an arbitrary key — for cross-purpose/root-key forgeries. */
async function signWithKey(key: Uint8Array): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return await new SignJWT({ ...claims })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt(now)
.setExpirationTime(now + 60)
.sign(key);
}
describe('collab token', () => {
it('round-trips valid claims', async () => {
const token = await signCollabToken(claims, secret, 60);
const result = await verifyCollabToken(token, secret);
expect(result).toEqual({ valid: true, claims });
});
it('rejects a token signed with a different secret', async () => {
const token = await signCollabToken(claims, secret, 60);
expect(await verifyCollabToken(token, 'another-secret-16-chars')).toEqual({
valid: false,
reason: 'bad_signature',
});
});
it("rejects a token signed with a different purpose's subkey (ADR 0020)", async () => {
const forged = await signWithKey(deriveTokenKey(secret, 'unsubscribe'));
expect(await verifyCollabToken(forged, secret)).toEqual({
valid: false,
reason: 'bad_signature',
});
});
it('rejects a token signed with the raw root secret — no code path signs with it', async () => {
const forged = await signWithKey(new TextEncoder().encode(secret));
expect(await verifyCollabToken(forged, secret)).toEqual({
valid: false,
reason: 'bad_signature',
});
});
it('rejects a tampered payload', async () => {
const token = await 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((await verifyCollabToken(forged, secret)).valid).toBe(false);
});
it('rejects an expired token', async () => {
const token = await signCollabToken(claims, secret, 60);
// 61 seconds later the 60 s token is past its expiry.
const later = Date.now() + 61_000;
expect(await verifyCollabToken(token, secret, later)).toEqual({
valid: false,
reason: 'expired',
});
});
it('rejects a malformed token', async () => {
expect(await verifyCollabToken('not-a-jwt', secret)).toEqual({
valid: false,
reason: 'malformed',
});
expect((await verifyCollabToken('a.b', secret)).valid).toBe(false);
});
it('rejects alg:none — HS256 is an explicit allowlist', async () => {
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 = await verifyCollabToken(`${header}.${payload}.`, secret);
expect(result.valid).toBe(false);
});
it('rejects an RS256 header before any signature check', async () => {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ ...claims, iat: 0, exp: 9999999999 })).toString(
'base64url',
);
const result = await verifyCollabToken(`${header}.${payload}.AAAA`, secret);
expect(result).toEqual({ valid: false, reason: 'bad_algorithm' });
});
it('rejects a signed token without an expiry claim', async () => {
const token = await new SignJWT({ ...claims })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.sign(deriveTokenKey(secret, 'collab'));
expect(await verifyCollabToken(token, secret)).toEqual({
valid: false,
reason: 'invalid_claims',
});
});
it('preserves the read-only mode claim', async () => {
const roToken = await signCollabToken({ ...claims, mode: 'ro' }, secret, 60);
const result = await verifyCollabToken(roToken, secret);
expect(result).toEqual({ valid: true, claims: { ...claims, mode: 'ro' } });
});
it('derives distinct, stable subkeys per purpose', () => {
const collab = deriveTokenKey(secret, 'collab');
const unsubscribe = deriveTokenKey(secret, 'unsubscribe');
expect(collab).toHaveLength(32);
expect(Buffer.from(collab).equals(Buffer.from(unsubscribe))).toBe(false);
expect(Buffer.from(collab).equals(Buffer.from(deriveTokenKey(secret, 'collab')))).toBe(true);
});
});