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
73 lines
2.8 KiB
TypeScript
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');
|
|
});
|
|
});
|