Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 51.9%
TypeScript 32.7%
JavaScript 4.8%
CSS 4.1%
Shell 2.9%
HTML 1.8%
SQL 1.5%
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 » : l'utilisateur est envoyé au hub, qui le renvoie5// ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET et6// 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 une clé LOCALE (KA_AUTH_SECRET, sinon dérivée de10// KA_SSO_SECRET — jamais le secret partagé tel quel).11// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET12// (signature, alg, iss, aud, exp).13// Modèle de référence : vrai-prix/src/lib/ka-auth.ts.14// -----------------------------------------------------------------------------15import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";1617export const SESSION_COOKIE = "tk_ka_session";18export const SESSION_DAYS = 30;19export const CLIENT_ID = "trouve-ka";2021export const KA_HUB_URL = (22 process.env.KA_HUB_URL || "https://www.groupe-ka.com"23).replace(/\/+$/, "");2425/** URL publique de l'app (redirect_uri — le hub n'accepte que trouve-ka.com). */26export const KA_BASE_URL = (27 process.env.KA_BASE_URL ||28 process.env.PUBLIC_URL ||29 "https://www.trouve-ka.com"30).replace(/\/+$/, "");3132/** Session locale posée en cookie httpOnly après un retour SSO réussi. */33export type KaSession = {34 ka_id: string;35 email: string;36 name: string;37 picture: string;38 provider?: string;39 exp: number; // epoch (secondes)40};4142const now = () => Math.floor(Date.now() / 1000);4344/** Clé LOCALE de signature (state + session). Dérivée de KA_SSO_SECRET si45 * KA_AUTH_SECRET n'est pas défini — jamais le secret partagé directement. */46function authSecret(): Buffer | null {47 const local = process.env.KA_AUTH_SECRET;48 if (local) return Buffer.from(local, "utf8");49 const shared = process.env.KA_SSO_SECRET;50 if (!shared) return null;51 return createHash("sha256").update(`trouve-ka-local|${shared}`).digest();52}5354function ssoSecret(): Buffer | null {55 const s = process.env.KA_SSO_SECRET;56 return s ? Buffer.from(s, "utf8") : null;57}5859/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */60function safeEqual(a: Buffer, b: Buffer): boolean {61 return a.length === b.length && timingSafeEqual(a, b);62}6364// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------6566export function signLocal(payload: Record<string, unknown>): string | null {67 const secret = authSecret();68 if (!secret) return null;69 const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");70 const sig = createHmac("sha256", secret).update(raw).digest("hex");71 return `${raw}.${sig}`;72}7374export function verifyLocal(token: string): Record<string, unknown> | null {75 const secret = authSecret();76 if (!secret || !token) return null;77 try {78 const dot = token.lastIndexOf(".");79 if (dot < 0) return null;80 const raw = token.slice(0, dot);81 const sig = token.slice(dot + 1);82 const expected = createHmac("sha256", secret).update(raw).digest("hex");83 if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) {84 return null;85 }86 const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));87 if (typeof payload !== "object" || payload === null) return null;88 if (typeof payload.exp !== "number" || payload.exp < now()) return null;89 return payload as Record<string, unknown>;90 } catch {91 return null;92 }93}9495// -- state SSO (anti-forgerie, transporte aussi la destination `next`) --------9697/** State opaque signé localement — expire en 10 minutes. */98export function newState(next: string): string | null {99 return signLocal({100 n: randomBytes(12).toString("base64url"),101 next,102 exp: now() + 600,103 });104}105106/** Vérifie le state et rend la destination `next` (chemin local sûr). */107export function verifyState(state: string): { next: string } | null {108 const p = verifyLocal(state);109 if (!p) return null;110 const next = typeof p.next === "string" ? p.next : "/";111 return { next: sanitizeNext(next) };112}113114/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */115export function sanitizeNext(next: string | null | undefined): string {116 if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";117 return next;118}119120// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) ---------------121122export function verifyKaToken(token: string): Record<string, unknown> | null {123 const secret = ssoSecret();124 if (!secret || !token) return null;125 try {126 const parts = token.split(".");127 if (parts.length !== 3) return null;128 const [h64, p64, s64] = parts;129 const expected = createHmac("sha256", secret)130 .update(`${h64}.${p64}`)131 .digest();132 if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null;133134 const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8"));135 if (header?.alg !== "HS256") return null;136137 const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8"));138 if (claims?.iss !== KA_HUB_URL) return null;139 const aud = claims?.aud;140 const audOk =141 aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID));142 if (!audOk) return null;143 if (typeof claims?.exp !== "number" || claims.exp < now()) return null;144 return claims as Record<string, unknown>;145 } catch {146 return null;147 }148}149150// -- session locale ------------------------------------------------------------151152/** Construit le cookie de session (30 jours) depuis les claims du hub. */153export function sessionFromClaims(154 claims: Record<string, unknown>,155): { value: string; session: KaSession } | null {156 const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : "";157 if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis158 const email =159 typeof claims.email === "string" ? claims.email.toLowerCase() : "";160 const session: KaSession = {161 ka_id: kaId,162 email,163 name:164 typeof claims.name === "string" && claims.name165 ? claims.name166 : email.split("@")[0] || "membre",167 picture: typeof claims.picture === "string" ? claims.picture : "",168 provider: typeof claims.provider === "string" ? claims.provider : undefined,169 exp: now() + SESSION_DAYS * 86400,170 };171 const value = signLocal({ ...session });172 return value ? { value, session } : null;173}174175/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */176export function readSession(cookieValue: string | undefined): KaSession | null {177 if (!cookieValue) return null;178 const p = verifyLocal(cookieValue);179 if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null;180 return {181 ka_id: p.ka_id,182 email: typeof p.email === "string" ? p.email : "",183 name: typeof p.name === "string" ? p.name : "",184 picture: typeof p.picture === "string" ? p.picture : "",185 provider: typeof p.provider === "string" ? p.provider : undefined,186 exp: p.exp as number,187 };188}189