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 { 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); }); });