From 53a1e6261436717ce0a8bf98da2e44e313d2779f Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 17:50:17 +0200 Subject: [PATCH] feat: HMAC-signed finalist confirmation token --- src/lib/finalist-token.ts | 45 +++++++++++++++++++++++++++++++ tests/unit/finalist-token.test.ts | 33 +++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/lib/finalist-token.ts create mode 100644 tests/unit/finalist-token.test.ts diff --git a/src/lib/finalist-token.ts b/src/lib/finalist-token.ts new file mode 100644 index 0000000..6716708 --- /dev/null +++ b/src/lib/finalist-token.ts @@ -0,0 +1,45 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +export type FinalistTokenPayload = { + confirmationId: string + /** Unix seconds. Token is rejected after this. */ + exp: number +} + +function getSecret(): string { + const s = process.env.NEXTAUTH_SECRET + if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign finalist tokens') + return s +} + +function hmac(payloadB64: string): string { + return createHmac('sha256', getSecret()).update(payloadB64).digest('hex') +} + +export function signFinalistToken(payload: FinalistTokenPayload): string { + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') + const sig = hmac(payloadB64) + return `${payloadB64}.${sig}` +} + +export function verifyFinalistToken(token: string): FinalistTokenPayload { + const parts = token.split('.') + if (parts.length !== 2) throw new Error('Invalid finalist token: malformed') + const [payloadB64, sig] = parts + const expected = hmac(payloadB64) + const a = Buffer.from(sig, 'hex') + const b = Buffer.from(expected, 'hex') + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error('Invalid finalist token: signature mismatch') + } + let payload: FinalistTokenPayload + try { + payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8')) + } catch { + throw new Error('Invalid finalist token: payload not parseable') + } + if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Invalid finalist token: expired') + } + return payload +} diff --git a/tests/unit/finalist-token.test.ts b/tests/unit/finalist-token.test.ts new file mode 100644 index 0000000..4067f11 --- /dev/null +++ b/tests/unit/finalist-token.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { signFinalistToken, verifyFinalistToken } from '../../src/lib/finalist-token' + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' +}) + +describe('finalist token', () => { + it('round-trips a payload', () => { + const exp = Math.floor(Date.now() / 1000) + 86400 + const token = signFinalistToken({ confirmationId: 'cmf_test', exp }) + const verified = verifyFinalistToken(token) + expect(verified.confirmationId).toBe('cmf_test') + expect(verified.exp).toBe(exp) + }) + + it('rejects tampered tokens', () => { + const exp = Math.floor(Date.now() / 1000) + 86400 + const token = signFinalistToken({ confirmationId: 'cmf_test', exp }) + const tampered = token.slice(0, -2) + 'xx' + expect(() => verifyFinalistToken(tampered)).toThrow(/signature/i) + }) + + it('rejects expired tokens', () => { + const exp = Math.floor(Date.now() / 1000) - 1 + const token = signFinalistToken({ confirmationId: 'cmf_test', exp }) + expect(() => verifyFinalistToken(token)).toThrow(/expired/i) + }) + + it('rejects malformed tokens', () => { + expect(() => verifyFinalistToken('not-a-token')).toThrow(/malformed/i) + }) +})