feat: HMAC-signed finalist confirmation token
This commit is contained in:
45
src/lib/finalist-token.ts
Normal file
45
src/lib/finalist-token.ts
Normal file
@@ -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
|
||||
}
|
||||
33
tests/unit/finalist-token.test.ts
Normal file
33
tests/unit/finalist-token.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user