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
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
123 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|