46 lines
1.5 KiB
TypeScript
46 lines
1.5 KiB
TypeScript
|
|
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
|
||
|
|
}
|