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
68 lines
3.1 KiB
TypeScript
68 lines
3.1 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* The reason the homegrown JWT existed was the guarantee that the identical
|
|
* code runs in the CommonJS api and the ESM collab server (ADR 0020). With
|
|
* `jose` that property must be PROVEN, not assumed — jose v6 is ESM-only
|
|
* and reaches CJS via Node's `require(esm)`. This test loads the actually
|
|
* built dist artefacts (what api and collab load at runtime) in child
|
|
* processes of both module systems and round-trips a token in each
|
|
* direction. Requires a current `pnpm --filter @dorfteich/shared build`
|
|
* (CI builds before testing).
|
|
*/
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const distCjs = path.resolve(here, '../dist/token-crypto.js');
|
|
const distEsm = path.resolve(here, '../dist/token-crypto.mjs');
|
|
|
|
const secret = 'cross-runtime-secret-32-characters';
|
|
const claims = { userId: 'user-x', pageId: 'page-x', mode: 'rw' } as const;
|
|
|
|
function run(args: string[], extraEnv: Record<string, string>): string {
|
|
return execFileSync(process.execPath, args, {
|
|
cwd: path.resolve(here, '..'),
|
|
env: { ...process.env, SECRET: secret, ...extraEnv },
|
|
encoding: 'utf8',
|
|
});
|
|
}
|
|
|
|
const signCjs = `require(process.env.DIST).signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60).then((t) => process.stdout.write(t));`;
|
|
const verifyCjs = `require(process.env.DIST).verifyCollabToken(process.env.TOKEN, process.env.SECRET).then((r) => process.stdout.write(JSON.stringify(r)));`;
|
|
const signEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(await m.signCollabToken(JSON.parse(process.env.CLAIMS), process.env.SECRET, 60));`;
|
|
const verifyEsm = `const m = await import(process.env.DIST_URL); process.stdout.write(JSON.stringify(await m.verifyCollabToken(process.env.TOKEN, process.env.SECRET)));`;
|
|
|
|
describe('token crypto across module systems (built dist)', () => {
|
|
it('has a built dist to test against', () => {
|
|
for (const file of [distCjs, distEsm]) {
|
|
if (!existsSync(file)) {
|
|
throw new Error(`${file} missing — run \`pnpm --filter @dorfteich/shared build\` first`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('verifies a CommonJS-signed token in an ESM runtime', () => {
|
|
const token = run(['-e', signCjs], { DIST: distCjs, CLAIMS: JSON.stringify(claims) });
|
|
expect(token.split('.')).toHaveLength(3);
|
|
const result = run(['--input-type=module', '-e', verifyEsm], {
|
|
DIST_URL: pathToFileURL(distEsm).href,
|
|
TOKEN: token,
|
|
});
|
|
expect(JSON.parse(result)).toEqual({ valid: true, claims });
|
|
});
|
|
|
|
it('verifies an ESM-signed token in a CommonJS runtime', () => {
|
|
const token = run(['--input-type=module', '-e', signEsm], {
|
|
DIST_URL: pathToFileURL(distEsm).href,
|
|
CLAIMS: JSON.stringify(claims),
|
|
});
|
|
expect(token.split('.')).toHaveLength(3);
|
|
const result = run(['-e', verifyCjs], { DIST: distCjs, TOKEN: token });
|
|
expect(JSON.parse(result)).toEqual({ valid: true, claims });
|
|
});
|
|
});
|