import crypto from 'node:crypto'; export const AUTH_COOKIE = 'tkmind_h5_session'; export function parseCookies(header = '') { return Object.fromEntries( header .split(';') .map((part) => part.trim()) .filter(Boolean) .map((part) => { const separator = part.indexOf('='); if (separator < 0) return [part, '']; return [ decodeURIComponent(part.slice(0, separator)), decodeURIComponent(part.slice(separator + 1)), ]; }), ); } function safeEqual(left, right) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return ( leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer) ); } export function createAuthManager({ password, ttlMs = 7 * 24 * 60 * 60 * 1000, maxFailures = 5, failureWindowMs = 5 * 60 * 1000, }) { if (!password) { throw new Error('H5_ACCESS_PASSWORD is required'); } const sessions = new Map(); const failures = new Map(); const prune = (now = Date.now()) => { for (const [token, expiresAt] of sessions) { if (expiresAt <= now) sessions.delete(token); } for (const [ip, state] of failures) { if (state.resetAt <= now) failures.delete(ip); } }; const login = (candidate, ip = 'unknown', now = Date.now()) => { prune(now); const failure = failures.get(ip); if (failure && failure.count >= maxFailures && failure.resetAt > now) { return { ok: false, retryAfterMs: failure.resetAt - now }; } if (!safeEqual(candidate, password)) { const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + failureWindowMs }; current.count += 1; failures.set(ip, current); return { ok: false, retryAfterMs: 0 }; } failures.delete(ip); const token = crypto.randomBytes(32).toString('base64url'); sessions.set(token, now + ttlMs); return { ok: true, token }; }; const verify = (token, now = Date.now()) => { if (!token) return false; prune(now); const expiresAt = sessions.get(token); if (!expiresAt || expiresAt <= now) { sessions.delete(token); return false; } sessions.set(token, now + ttlMs); return true; }; const revoke = (token) => { if (token) sessions.delete(token); }; return { login, verify, revoke }; } export function sessionCookie(token, secure) { const parts = [ `${AUTH_COOKIE}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=604800', ]; if (secure) parts.push('Secure'); return parts.join('; '); } export function clearSessionCookie(secure) { const parts = [ `${AUTH_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0', ]; if (secure) parts.push('Secure'); return parts.join('; '); }