dorfteich/apps/api/src/notifications/unsubscribe-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

73 lines
2.8 KiB
TypeScript

import { createHmac } from 'node:crypto';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
import { describe, expect, it } from 'vitest';
import {
LEGACY_VERIFY_UNTIL,
signUnsubscribeToken,
verifyUnsubscribeToken,
} from './unsubscribe-token';
const secret = 'test-secret-at-least-16-chars-long';
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
/** A token as the pre-#188 implementation minted it: root secret + purpose prefix. */
function legacyToken(userId: string, now = Date.now()): string {
const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + 90 * 24 * 60 * 60 }),
'utf8',
).toString('base64url');
const sig = createHmac('sha256', secret).update(`digest-unsubscribe.${body}`).digest('base64url');
return `${body}.${sig}`;
}
describe('unsubscribe token', () => {
it('round-trips a user id', () => {
expect(verifyUnsubscribeToken(signUnsubscribeToken('u1', secret), secret)).toBe('u1');
});
it('rejects an expired token', () => {
const token = signUnsubscribeToken('u1', secret);
expect(verifyUnsubscribeToken(token, secret, Date.now() + TTL_MS + 1000)).toBeNull();
});
it('rejects garbage and a wrong secret', () => {
expect(verifyUnsubscribeToken('nonsense', secret)).toBeNull();
expect(verifyUnsubscribeToken('a.b', secret)).toBeNull();
const token = signUnsubscribeToken('u1', secret);
expect(verifyUnsubscribeToken(token, 'another-secret-16-chars')).toBeNull();
});
it("rejects a token HMACed with a different purpose's subkey (ADR 0020)", () => {
const body = Buffer.from(
JSON.stringify({ userId: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }),
'utf8',
).toString('base64url');
const sig = createHmac('sha256', deriveTokenKey(secret, 'collab'))
.update(body)
.digest('base64url');
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
});
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
'u1',
);
});
it('rejects a legacy token once the dual-verify window has closed', () => {
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
// Unexpired on its own terms — rejected purely because the window closed.
const token = legacyToken('u1', afterWindow - 1000);
expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull();
});
it('verifies freshly minted tokens via the subkey, independent of the window', () => {
const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000;
const token = signUnsubscribeToken('u1', secret, afterWindow);
expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1');
});
});