SPB Git forge

spb/trouve-ka

Public ★ Pinned

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

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%
7.1 KB · 189 lines typescript
Raw Blame History
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