// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ----------------------------------------------------------------------------- // KA ID — SSO du Groupe KA (hub d'identité : groupe-ka.com). // « Se connecter avec KA ID » : l'utilisateur est envoyé au hub, qui le // renvoie ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET // et transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur // toutes les plateformes du groupe). Aucune dépendance : node:crypto seulement. // · state / session locale : base64url(JSON) + "." + HMAC-SHA256 hex, // signés avec KA_AUTH_SECRET (jamais partagé). // · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET // (signature, alg, iss, aud, exp). // ----------------------------------------------------------------------------- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; export const SESSION_COOKIE = "vpx_ka_session"; export const SESSION_DAYS = 30; export const CLIENT_ID = "valoplex"; export const KA_HUB_URL = ( process.env.KA_HUB_URL || "https://www.groupe-ka.com" ).replace(/\/+$/, ""); /** URL publique de l'app (redirect_uri — le hub n'accepte que valoplex.com). */ export const KA_BASE_URL = ( process.env.KA_BASE_URL || "https://www.valoplex.com" ).replace(/\/+$/, ""); /** Session locale posée en cookie httpOnly après un retour SSO réussi. */ export type KaSession = { ka_id: string; email: string; name: string; picture: string; provider?: string; exp: number; // epoch (secondes) }; const now = () => Math.floor(Date.now() / 1000); function authSecret(): Buffer | null { const s = process.env.KA_AUTH_SECRET; return s ? Buffer.from(s, "utf8") : null; } function ssoSecret(): Buffer | null { const s = process.env.KA_SSO_SECRET; return s ? Buffer.from(s, "utf8") : null; } /** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */ function safeEqual(a: Buffer, b: Buffer): boolean { return a.length === b.length && timingSafeEqual(a, b); } // -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------ export function signLocal(payload: Record): string | null { const secret = authSecret(); if (!secret) return null; const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); const sig = createHmac("sha256", secret).update(raw).digest("hex"); return `${raw}.${sig}`; } export function verifyLocal(token: string): Record | null { const secret = authSecret(); if (!secret || !token) return null; try { const dot = token.lastIndexOf("."); if (dot < 0) return null; const raw = token.slice(0, dot); const sig = token.slice(dot + 1); const expected = createHmac("sha256", secret).update(raw).digest("hex"); if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) { return null; } const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); if (typeof payload !== "object" || payload === null) return null; if (typeof payload.exp !== "number" || payload.exp < now()) return null; return payload as Record; } catch { return null; } } // -- state SSO (anti-forgerie, transporte aussi la destination `next`) -------- /** State opaque signé localement — expire en 10 minutes. */ export function newState(next: string): string | null { return signLocal({ n: randomBytes(12).toString("base64url"), next, exp: now() + 600, }); } /** Vérifie le state et rend la destination `next` (chemin local sûr). */ export function verifyState(state: string): { next: string } | null { const p = verifyLocal(state); if (!p) return null; const next = typeof p.next === "string" ? p.next : "/favoris"; return { next: sanitizeNext(next) }; } /** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */ export function sanitizeNext(next: string | null | undefined): string { if (!next || !next.startsWith("/") || next.startsWith("//")) return "/favoris"; return next; } // -- ka_token : JWT HS256 émis par le hub (vérification stdlib) --------------- export function verifyKaToken(token: string): Record | null { const secret = ssoSecret(); if (!secret || !token) return null; try { const parts = token.split("."); if (parts.length !== 3) return null; const [h64, p64, s64] = parts; const expected = createHmac("sha256", secret) .update(`${h64}.${p64}`) .digest(); if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null; const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8")); if (header?.alg !== "HS256") return null; const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8")); if (claims?.iss !== KA_HUB_URL) return null; const aud = claims?.aud; const audOk = aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID)); if (!audOk) return null; if (typeof claims?.exp !== "number" || claims.exp < now()) return null; return claims as Record; } catch { return null; } } // -- session locale ------------------------------------------------------------ /** Construit le cookie de session (30 jours) depuis les claims du hub. */ export function sessionFromClaims( claims: Record, ): { value: string; session: KaSession } | null { const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : ""; if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis const email = typeof claims.email === "string" ? claims.email.toLowerCase() : ""; const session: KaSession = { ka_id: kaId, email, name: typeof claims.name === "string" && claims.name ? claims.name : email.split("@")[0] || "membre", picture: typeof claims.picture === "string" ? claims.picture : "", provider: typeof claims.provider === "string" ? claims.provider : undefined, exp: now() + SESSION_DAYS * 86400, }; const value = signLocal({ ...session }); return value ? { value, session } : null; } /** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */ export function readSession(cookieValue: string | undefined): KaSession | null { if (!cookieValue) return null; const p = verifyLocal(cookieValue); if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null; return { ka_id: p.ka_id, email: typeof p.email === "string" ? p.email : "", name: typeof p.name === "string" ? p.name : "", picture: typeof p.picture === "string" ? p.picture : "", provider: typeof p.provider === "string" ? p.provider : undefined, exp: p.exp as number, }; }