Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// -----------------------------------------------------------------------------3// KA ID — SSO du Groupe KA (hub d'identité : groupe-ka.com).4// « Se connecter avec KA ID » : l'utilisateur est envoyé au hub, qui le5// renvoie ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET6// et transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur7// toutes les plateformes du groupe). Aucune dépendance : node:crypto seulement.8// · state / session locale : base64url(JSON) + "." + HMAC-SHA256 hex,9// signés avec KA_AUTH_SECRET (jamais partagé).10// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET11// (signature, alg, iss, aud, exp).12// -----------------------------------------------------------------------------13import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";1415export const SESSION_COOKIE = "vp_ka_session";16export const SESSION_DAYS = 30;17export const CLIENT_ID = "vrai-prix";1819export const KA_HUB_URL = (20 process.env.KA_HUB_URL || "https://www.groupe-ka.com"21).replace(/\/+$/, "");2223/** URL publique de l'app (redirect_uri — le hub n'accepte que vrai-prix.com). */24export const KA_BASE_URL = (25 process.env.KA_BASE_URL || "https://www.vrai-prix.com"26).replace(/\/+$/, "");2728/** Session locale posée en cookie httpOnly après un retour SSO réussi. */29export type KaSession = {30 ka_id: string;31 email: string;32 name: string;33 picture: string;34 provider?: string;35 exp: number; // epoch (secondes)36};3738const now = () => Math.floor(Date.now() / 1000);3940function authSecret(): Buffer | null {41 const s = process.env.KA_AUTH_SECRET;42 return s ? Buffer.from(s, "utf8") : null;43}4445function ssoSecret(): Buffer | null {46 const s = process.env.KA_SSO_SECRET;47 return s ? Buffer.from(s, "utf8") : null;48}4950/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */51function safeEqual(a: Buffer, b: Buffer): boolean {52 return a.length === b.length && timingSafeEqual(a, b);53}5455// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------5657export function signLocal(payload: Record<string, unknown>): string | null {58 const secret = authSecret();59 if (!secret) return null;60 const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");61 const sig = createHmac("sha256", secret).update(raw).digest("hex");62 return `${raw}.${sig}`;63}6465export function verifyLocal(token: string): Record<string, unknown> | null {66 const secret = authSecret();67 if (!secret || !token) return null;68 try {69 const dot = token.lastIndexOf(".");70 if (dot < 0) return null;71 const raw = token.slice(0, dot);72 const sig = token.slice(dot + 1);73 const expected = createHmac("sha256", secret).update(raw).digest("hex");74 if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) {75 return null;76 }77 const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));78 if (typeof payload !== "object" || payload === null) return null;79 if (typeof payload.exp !== "number" || payload.exp < now()) return null;80 return payload as Record<string, unknown>;81 } catch {82 return null;83 }84}8586// -- state SSO (anti-forgerie, transporte aussi la destination `next`) --------8788/** State opaque signé localement — expire en 10 minutes. */89export function newState(next: string): string | null {90 return signLocal({91 n: randomBytes(12).toString("base64url"),92 next,93 exp: now() + 600,94 });95}9697/** Vérifie le state et rend la destination `next` (chemin local sûr). */98export function verifyState(state: string): { next: string } | null {99 const p = verifyLocal(state);100 if (!p) return null;101 const next = typeof p.next === "string" ? p.next : "/compte";102 return { next: sanitizeNext(next) };103}104105/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */106export function sanitizeNext(next: string | null | undefined): string {107 if (!next || !next.startsWith("/") || next.startsWith("//")) return "/compte";108 return next;109}110111// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) ---------------112113export function verifyKaToken(token: string): Record<string, unknown> | null {114 const secret = ssoSecret();115 if (!secret || !token) return null;116 try {117 const parts = token.split(".");118 if (parts.length !== 3) return null;119 const [h64, p64, s64] = parts;120 const expected = createHmac("sha256", secret)121 .update(`${h64}.${p64}`)122 .digest();123 if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null;124125 const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8"));126 if (header?.alg !== "HS256") return null;127128 const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8"));129 if (claims?.iss !== KA_HUB_URL) return null;130 const aud = claims?.aud;131 const audOk =132 aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID));133 if (!audOk) return null;134 if (typeof claims?.exp !== "number" || claims.exp < now()) return null;135 return claims as Record<string, unknown>;136 } catch {137 return null;138 }139}140141// -- session locale ------------------------------------------------------------142143/** Construit le cookie de session (30 jours) depuis les claims du hub. */144export function sessionFromClaims(145 claims: Record<string, unknown>,146): { value: string; session: KaSession } | null {147 const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : "";148 if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis149 const email =150 typeof claims.email === "string" ? claims.email.toLowerCase() : "";151 const session: KaSession = {152 ka_id: kaId,153 email,154 name:155 typeof claims.name === "string" && claims.name156 ? claims.name157 : email.split("@")[0] || "membre",158 picture: typeof claims.picture === "string" ? claims.picture : "",159 provider: typeof claims.provider === "string" ? claims.provider : undefined,160 exp: now() + SESSION_DAYS * 86400,161 };162 const value = signLocal({ ...session });163 return value ? { value, session } : null;164}165166/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */167export function readSession(cookieValue: string | undefined): KaSession | null {168 if (!cookieValue) return null;169 const p = verifyLocal(cookieValue);170 if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null;171 return {172 ka_id: p.ka_id,173 email: typeof p.email === "string" ? p.email : "",174 name: typeof p.name === "string" ? p.name : "",175 picture: typeof p.picture === "string" ? p.picture : "",176 provider: typeof p.provider === "string" ? p.provider : undefined,177 exp: p.exp as number,178 };179}180