import { createHmac, timingSafeEqual } from 'node:crypto'; export const TOKEN_LIFETIME_SECONDS = 12 * 60 * 60; export interface TokenService { issue(userId: number): string; verify(token: string): number | undefined; } const b64 = (data: string | Buffer): string => Buffer.from(data).toString('base64url'); const HEADER = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); export function createTokenService(secret: string, now: () => Date = () => new Date()): TokenService { const sign = (data: string): Buffer => createHmac('sha256', secret).update(data).digest(); return { issue(userId) { const iat = Math.floor(now().getTime() / 1000); const payload = b64(JSON.stringify({ sub: userId, iat, exp: iat + TOKEN_LIFETIME_SECONDS })); return `${HEADER}.${payload}.${b64(sign(`${HEADER}.${payload}`))}`; }, verify(token) { const parts = token.split('.'); if (parts.length !== 3 || parts[0] !== HEADER) return undefined; const [header, payload, signature] = parts as [string, string, string]; const expected = sign(`${header}.${payload}`); const given = Buffer.from(signature, 'base64url'); if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined; try { const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { sub?: unknown; exp?: unknown }; const nowSeconds = Math.floor(now().getTime() / 1000); if (!Number.isInteger(claims.sub) || typeof claims.exp !== 'number' || claims.exp <= nowSeconds) return undefined; return claims.sub as number; } catch { return undefined; } }, }; }