// Sessione del portale cliente dopo la verifica OTP (v2.3). // // Stateless e firmata, come la sessione admin: nessuna tabella sessions da // mantenere. Il payload porta client_id + email + istante di emissione, il tutto // firmato HMAC-SHA256 con NEXTAUTH_SECRET — non falsificabile senza il segreto. // // La revoca lato admin non cancella cookie (non si può): alza // clients.sessions_valid_from, e il gate scarta ogni sessione emessa prima. // Per questo `iat` fa parte del payload firmato. // // Web Crypto, come admin-gate.ts e otp.ts: stesso modulo in edge e node. export const SESSION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; // 90 giorni export type ClientSession = { clientId: string; email: string; iat: number }; /** * Un cookie per cliente: aprire il portale del cliente B non sloggia dal * portale del cliente A. clientId è un nanoid — caratteri già validi in un * nome di cookie. */ export function sessionCookieName(clientId: string): string { return `ch_sess_${clientId}`; } function b64urlEncode(s: string): string { const bytes = new TextEncoder().encode(s); let binary = ""; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function b64urlDecode(s: string): string { const binary = atob(s.replace(/-/g, "+").replace(/_/g, "/")); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); return new TextDecoder().decode(bytes); } async function sign(payload: string): Promise { const secret = process.env.NEXTAUTH_SECRET; if (!secret) throw new Error("NEXTAUTH_SECRET must be set"); const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(`${secret}:client-session:v1`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); return Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** Confronto a tempo costante — evita di far trapelare la firma dai tempi di risposta. */ function safeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } export async function createSessionValue(clientId: string, email: string): Promise { const payload = b64urlEncode( JSON.stringify({ clientId, email, iat: Date.now() } satisfies ClientSession) ); return `${payload}.${await sign(payload)}`; } /** * Ritorna la sessione solo se: la firma è valida, il cookie appartiene a QUESTO * cliente, e non ha superato la durata massima. Il confronto con * sessions_valid_from (revoca admin) resta al chiamante, che ha già il record cliente. */ export async function verifySessionValue( value: string | undefined, clientId: string ): Promise { if (!value) return null; const [payload, signature] = value.split("."); if (!payload || !signature) return null; if (!safeEqual(await sign(payload), signature)) return null; let session: ClientSession; try { session = JSON.parse(b64urlDecode(payload)); } catch { return null; } if (session.clientId !== clientId) return null; if (typeof session.iat !== "number") return null; if (Date.now() - session.iat > SESSION_MAX_AGE_SECONDS * 1000) return null; return session; }