SPB Git forge

spb/valoplex

Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

11commits 1branches 0releases
2.4 MBsize
maindefault branch
20 days agolast push
TypeScript 91.9% Python 6% CSS 2.1%
6.7 KB · 180 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 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 = "vpx_ka_session";16export const SESSION_DAYS = 30;17export const CLIENT_ID = "valoplex";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 valoplex.com). */24export const KA_BASE_URL = (25  process.env.KA_BASE_URL || "https://www.valoplex.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 : "/favoris";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 "/favoris";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