SSO « Se connecter avec KA ID » via le hub groupe-ka.com
Routes /api/auth/ka/{login,callback} + /api/auth/{me,logout}, session
cookie signée (ka-auth.ts, node:crypto pur), profil hub (hub-profile.ts,
cache 60 s), page /compte carte de membre, AccountButton au header.
(Déployé le 2026-08-13 ; l'intégration au layout arrive avec le commit SEO.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 changed files +941 −0
renamed
src/app/page.tsx → src/app/HomeClient.tsx
+0 −0
added
src/app/api/auth/ka/callback/route.ts
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Retour SSO KA ID : vérifie le state signé localement puis le ka_token du hub | |
| 3 | +// (JWT HS256 — signature KA_SSO_SECRET, alg, iss, aud, exp), pose la session | |
| 4 | +// locale (cookie httpOnly vp_ka_session signé HMAC-SHA256, 30 jours) et | |
| 5 | +// redirige vers la destination demandée (ou /compte). | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { | |
| 8 | + KA_BASE_URL, | |
| 9 | + SESSION_COOKIE, | |
| 10 | + SESSION_DAYS, | |
| 11 | + sessionFromClaims, | |
| 12 | + verifyKaToken, | |
| 13 | + verifyState, | |
| 14 | +} from "@/lib/ka-auth"; | |
| 15 | + | |
| 16 | +export function GET(req: NextRequest) { | |
| 17 | + const q = req.nextUrl.searchParams; | |
| 18 | + const state = verifyState(q.get("state") ?? ""); | |
| 19 | + if (!state) { | |
| 20 | + return NextResponse.json( | |
| 21 | + { error: "state invalide ou expiré — recommencez la connexion" }, | |
| 22 | + { status: 400 }, | |
| 23 | + ); | |
| 24 | + } | |
| 25 | + const claims = verifyKaToken(q.get("ka_token") ?? ""); | |
| 26 | + if (!claims) { | |
| 27 | + return NextResponse.json( | |
| 28 | + { error: "jeton KA invalide ou expiré — recommencez la connexion" }, | |
| 29 | + { status: 401 }, | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + const made = sessionFromClaims(claims); | |
| 33 | + if (!made) { | |
| 34 | + return NextResponse.json( | |
| 35 | + { error: "jeton KA incomplet (ka_id manquant)" }, | |
| 36 | + { status: 401 }, | |
| 37 | + ); | |
| 38 | + } | |
| 39 | + const res = NextResponse.redirect(new URL(state.next, KA_BASE_URL)); | |
| 40 | + res.cookies.set(SESSION_COOKIE, made.value, { | |
| 41 | + httpOnly: true, | |
| 42 | + secure: KA_BASE_URL.startsWith("https"), | |
| 43 | + sameSite: "lax", | |
| 44 | + path: "/", | |
| 45 | + maxAge: SESSION_DAYS * 86400, | |
| 46 | + }); | |
| 47 | + return res; | |
| 48 | +} | |
added
src/app/api/auth/ka/login/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Départ SSO « Se connecter avec KA ID » : redirige vers le hub d'identité du | |
| 3 | +// Groupe KA (groupe-ka.com) avec un state opaque signé localement (le state | |
| 4 | +// transporte aussi la destination `next` de retour — chemin local seulement). | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { CLIENT_ID, KA_BASE_URL, KA_HUB_URL, newState, sanitizeNext } from "@/lib/ka-auth"; | |
| 7 | + | |
| 8 | +export function GET(req: NextRequest) { | |
| 9 | + const next = sanitizeNext(req.nextUrl.searchParams.get("next")); | |
| 10 | + const state = newState(next); | |
| 11 | + if (!state || !process.env.KA_SSO_SECRET) { | |
| 12 | + return NextResponse.json( | |
| 13 | + { error: "SSO KA ID non configuré (KA_SSO_SECRET / KA_AUTH_SECRET manquant)" }, | |
| 14 | + { status: 503 }, | |
| 15 | + ); | |
| 16 | + } | |
| 17 | + const url = new URL(`${KA_HUB_URL}/sso/authorize`); | |
| 18 | + url.searchParams.set("client_id", CLIENT_ID); | |
| 19 | + url.searchParams.set("redirect_uri", `${KA_BASE_URL}/api/auth/ka/callback`); | |
| 20 | + url.searchParams.set("state", state); | |
| 21 | + return NextResponse.redirect(url); | |
| 22 | +} | |
added
src/app/api/auth/logout/route.ts
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Déconnexion : efface le cookie de session local (la session est sans | |
| 3 | +// stockage serveur — supprimer le cookie suffit). POST seulement. | |
| 4 | +import { NextResponse } from "next/server"; | |
| 5 | +import { KA_BASE_URL, SESSION_COOKIE } from "@/lib/ka-auth"; | |
| 6 | + | |
| 7 | +export function POST() { | |
| 8 | + const res = NextResponse.json({ ok: true }); | |
| 9 | + res.cookies.set(SESSION_COOKIE, "", { | |
| 10 | + httpOnly: true, | |
| 11 | + secure: KA_BASE_URL.startsWith("https"), | |
| 12 | + sameSite: "lax", | |
| 13 | + path: "/", | |
| 14 | + maxAge: 0, | |
| 15 | + }); | |
| 16 | + return res; | |
| 17 | +} | |
added
src/app/api/auth/me/route.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Session courante : { user } si le cookie vp_ka_session est valide, sinon | |
| 3 | +// { user: null } — consommé par le bouton compte de l'en-tête et la page /compte. | |
| 4 | +// La réponse est enrichie du profil saisi au HUB Groupe KA (bio, ville, emploi, | |
| 5 | +// entreprise, âge, site web, réseaux, statut, profil public) quand il est | |
| 6 | +// joignable — profile_source: "groupe-ka" ; sinon retombée sur la session | |
| 7 | +// locale — profile_source: "local". | |
| 8 | +import { NextRequest, NextResponse } from "next/server"; | |
| 9 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 10 | +import { fetchHubProfile } from "@/lib/hub-profile"; | |
| 11 | + | |
| 12 | +export async function GET(req: NextRequest) { | |
| 13 | + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 14 | + if (!s) return NextResponse.json({ user: null }); | |
| 15 | + const hub = await fetchHubProfile(s.ka_id); // cache 60 s, null si injoignable | |
| 16 | + return NextResponse.json({ | |
| 17 | + user: { | |
| 18 | + ka_id: s.ka_id, | |
| 19 | + email: hub?.email || s.email, | |
| 20 | + name: hub?.name || s.name, | |
| 21 | + picture: hub?.picture || s.picture, | |
| 22 | + provider: s.provider ?? "ka-id", | |
| 23 | + // — profil Groupe KA (source de vérité : groupe-ka.com/compte) — | |
| 24 | + bio: hub?.bio ?? "", | |
| 25 | + city: hub?.city ?? "", | |
| 26 | + job_title: hub?.job_title ?? "", | |
| 27 | + company: hub?.company ?? "", | |
| 28 | + age: hub?.age ?? null, | |
| 29 | + website: hub?.website ?? "", | |
| 30 | + socials: hub?.socials ?? {}, | |
| 31 | + role_label: hub?.role_label ?? "", | |
| 32 | + public: hub?.public ?? false, | |
| 33 | + public_url: hub?.public_url ?? "", | |
| 34 | + profile_source: hub ? "groupe-ka" : "local", | |
| 35 | + }, | |
| 36 | + }); | |
| 37 | +} | |
added
src/app/compte/CompteClient.tsx
+425 −0
@@ -0,0 +1,425 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Mon compte — carte de membre Groupe KA (gabarit « pc » de Lou·Ka transposé) : | |
| 4 | + * fond encre, filigrane KA, KA-ID en gros mono lime (#d9f26b) avec glow, | |
| 5 | + * porteur + avatar + badge statut, bande code-barres ; profil Groupe KA en | |
| 6 | + * lecture seule (saisi sur groupe-ka.com/compte, LA source de vérité), grille | |
| 7 | + * d'infos et actions dessous. | |
| 8 | + * Le lime « Groupe KA » est propre à la carte (l'accent Vrai-Prix reste rouge). | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | +import { useState } from "react"; | |
| 12 | +import { useRouter } from "next/navigation"; | |
| 13 | +import { useLang } from "@/components/LangContext"; | |
| 14 | +import type { HubProfile, HubSocials } from "@/lib/hub-profile"; | |
| 15 | + | |
| 16 | +// couleur signature du Groupe KA — réservée à la carte de membre | |
| 17 | +const KA_LIME = "#d9f26b"; | |
| 18 | + | |
| 19 | +type User = { | |
| 20 | + ka_id: string; | |
| 21 | + email: string; | |
| 22 | + name: string; | |
| 23 | + picture: string; | |
| 24 | + provider: string; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +// réseaux affichés en chips — libellé + URL depuis le pseudo (« @x ») ou l'URL | |
| 28 | +const SOCIAL_LABELS: Record<keyof HubSocials, string> = { | |
| 29 | + instagram: "Instagram", | |
| 30 | + facebook: "Facebook", | |
| 31 | + x: "X", | |
| 32 | + linkedin: "LinkedIn", | |
| 33 | + tiktok: "TikTok", | |
| 34 | + youtube: "YouTube", | |
| 35 | +}; | |
| 36 | + | |
| 37 | +function socialUrl(net: keyof HubSocials, v: string): string { | |
| 38 | + if (/^https?:\/\//i.test(v)) return v; | |
| 39 | + const h = v.replace(/^@/, ""); | |
| 40 | + switch (net) { | |
| 41 | + case "instagram": | |
| 42 | + return `https://instagram.com/${h}`; | |
| 43 | + case "facebook": | |
| 44 | + return `https://facebook.com/${h}`; | |
| 45 | + case "x": | |
| 46 | + return `https://x.com/${h}`; | |
| 47 | + case "linkedin": | |
| 48 | + return `https://linkedin.com/in/${h}`; | |
| 49 | + case "tiktok": | |
| 50 | + return `https://tiktok.com/@${h}`; | |
| 51 | + case "youtube": | |
| 52 | + return `https://youtube.com/@${h}`; | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +export default function CompteClient({ | |
| 57 | + user, | |
| 58 | + profile, | |
| 59 | +}: { | |
| 60 | + user: User; | |
| 61 | + profile: HubProfile | null; | |
| 62 | +}) { | |
| 63 | + const { lang } = useLang(); | |
| 64 | + const fr = lang === "fr"; | |
| 65 | + const router = useRouter(); | |
| 66 | + const [copied, setCopied] = useState(false); | |
| 67 | + const [out, setOut] = useState(false); | |
| 68 | + | |
| 69 | + const copyKaId = async () => { | |
| 70 | + try { | |
| 71 | + await navigator.clipboard.writeText(user.ka_id); | |
| 72 | + setCopied(true); | |
| 73 | + setTimeout(() => setCopied(false), 1800); | |
| 74 | + } catch { | |
| 75 | + /* presse-papiers indisponible : tant pis */ | |
| 76 | + } | |
| 77 | + }; | |
| 78 | + | |
| 79 | + const logout = async () => { | |
| 80 | + setOut(true); | |
| 81 | + try { | |
| 82 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 83 | + } finally { | |
| 84 | + router.push("/"); | |
| 85 | + router.refresh(); | |
| 86 | + } | |
| 87 | + }; | |
| 88 | + | |
| 89 | + const infos: [string, React.ReactNode][] = [ | |
| 90 | + [fr ? "Nom" : "Name", user.name || "—"], | |
| 91 | + [fr ? "Courriel" : "Email", user.email || "—"], | |
| 92 | + [ | |
| 93 | + "KA-ID", | |
| 94 | + <span key="k" className="vp-mono">{user.ka_id}</span>, | |
| 95 | + ], | |
| 96 | + [ | |
| 97 | + fr ? "Connexion" : "Sign-in", | |
| 98 | + user.provider === "ka-id" || !user.provider | |
| 99 | + ? "KA ID (groupe-ka.com)" | |
| 100 | + : `KA ID · ${user.provider}`, | |
| 101 | + ], | |
| 102 | + ]; | |
| 103 | + | |
| 104 | + return ( | |
| 105 | + <div className="mx-auto max-w-2xl pb-10 pt-10"> | |
| 106 | + <span className="kicker">{fr ? "Mon compte" : "My account"}</span> | |
| 107 | + <h1 className="mb-7 mt-2 text-[clamp(30px,5vw,44px)] font-bold leading-tight"> | |
| 108 | + {user.name ? ( | |
| 109 | + <> | |
| 110 | + {fr ? "Salut, " : "Hi, "} | |
| 111 | + <span className="hl">{user.name.split(" ")[0]}</span>. | |
| 112 | + </> | |
| 113 | + ) : ( | |
| 114 | + <> | |
| 115 | + {fr ? "Votre " : "Your "} | |
| 116 | + <span className="hl">{fr ? "profil" : "profile"}</span>. | |
| 117 | + </> | |
| 118 | + )} | |
| 119 | + </h1> | |
| 120 | + | |
| 121 | + {/* ——— Carte de membre Groupe KA ——— */} | |
| 122 | + <div | |
| 123 | + role="img" | |
| 124 | + aria-label={`${fr ? "Carte de membre" : "Member card"} ${user.ka_id}`} | |
| 125 | + className="relative max-w-[520px] overflow-hidden rounded-2xl border-2 border-ink bg-ink px-[26px] pt-6 text-paper" | |
| 126 | + style={{ boxShadow: "10px 10px 0 rgba(20, 24, 20, 0.18)" }} | |
| 127 | + > | |
| 128 | + {/* filigrane */} | |
| 129 | + <div | |
| 130 | + aria-hidden="true" | |
| 131 | + className="vp-display pointer-events-none absolute -top-[34px] right-[-18px] rotate-[-8deg] text-[170px] font-bold leading-none tracking-[-0.06em]" | |
| 132 | + style={{ color: "rgba(217, 242, 107, 0.07)" }} | |
| 133 | + > | |
| 134 | + KA | |
| 135 | + </div> | |
| 136 | + | |
| 137 | + {/* tête : marque + libellé */} | |
| 138 | + <div className="flex flex-wrap items-baseline justify-between gap-3"> | |
| 139 | + <span className="vp-display text-[24px] font-bold tracking-[-0.04em]"> | |
| 140 | + Groupe | |
| 141 | + <span | |
| 142 | + className="ml-[3px] inline-block rotate-[-2deg] rounded-[5px] px-[6px] pb-[3px] pt-[1px] text-ink" | |
| 143 | + style={{ background: KA_LIME }} | |
| 144 | + > | |
| 145 | + KA | |
| 146 | + </span> | |
| 147 | + </span> | |
| 148 | + <span className="vp-mono text-[10px] uppercase tracking-[0.16em] text-[rgba(245,243,238,0.55)]"> | |
| 149 | + {fr ? "Carte de membre · Groupe KA" : "Member card · Groupe KA"} | |
| 150 | + </span> | |
| 151 | + </div> | |
| 152 | + | |
| 153 | + {/* KA-ID */} | |
| 154 | + <div className="my-[24px] flex flex-col gap-1"> | |
| 155 | + <span | |
| 156 | + className="vp-mono text-[10px] uppercase tracking-[0.2em]" | |
| 157 | + style={{ color: "rgba(217, 242, 107, 0.65)" }} | |
| 158 | + > | |
| 159 | + KA-ID | |
| 160 | + </span> | |
| 161 | + <span | |
| 162 | + className="vp-mono text-[clamp(22px,6vw,34px)] font-bold tracking-[0.14em]" | |
| 163 | + style={{ color: KA_LIME, textShadow: "0 0 24px rgba(217, 242, 107, 0.35)" }} | |
| 164 | + > | |
| 165 | + {user.ka_id} | |
| 166 | + </span> | |
| 167 | + </div> | |
| 168 | + | |
| 169 | + {/* porteur + avatar */} | |
| 170 | + <div className="flex items-end justify-between gap-3.5 pb-[18px]"> | |
| 171 | + <div className="flex min-w-0 flex-col gap-0.5"> | |
| 172 | + <span className="flex min-w-0 items-center gap-2"> | |
| 173 | + <span className="truncate text-[15px] font-semibold"> | |
| 174 | + {user.name || user.email} | |
| 175 | + </span> | |
| 176 | + {profile?.role_label ? ( | |
| 177 | + // badge statut — vient du hub (role_label), lime comme la carte | |
| 178 | + <span | |
| 179 | + className="vp-mono flex-none rounded-[4px] border px-[7px] pb-[2px] pt-[1px] text-[9.5px] font-bold uppercase tracking-[0.08em]" | |
| 180 | + style={{ | |
| 181 | + borderColor: "rgba(217, 242, 107, 0.55)", | |
| 182 | + color: KA_LIME, | |
| 183 | + background: "rgba(217, 242, 107, 0.1)", | |
| 184 | + }} | |
| 185 | + > | |
| 186 | + {profile.role_label} | |
| 187 | + </span> | |
| 188 | + ) : null} | |
| 189 | + </span> | |
| 190 | + <span className="vp-mono text-[10.5px] tracking-[0.06em] text-[rgba(245,243,238,0.5)]"> | |
| 191 | + {fr | |
| 192 | + ? "Valide sur toutes les plateformes du Groupe KA" | |
| 193 | + : "Valid across all Groupe KA platforms"} | |
| 194 | + </span> | |
| 195 | + </div> | |
| 196 | + {user.picture ? ( | |
| 197 | + // eslint-disable-next-line @next/next/no-img-element | |
| 198 | + <img | |
| 199 | + src={user.picture} | |
| 200 | + alt="" | |
| 201 | + referrerPolicy="no-referrer" | |
| 202 | + className="h-[52px] w-[52px] flex-none rounded-full border-2 object-cover" | |
| 203 | + style={{ borderColor: KA_LIME }} | |
| 204 | + /> | |
| 205 | + ) : ( | |
| 206 | + <span | |
| 207 | + className="vp-display flex h-[52px] w-[52px] flex-none items-center justify-center rounded-full border-2 text-[22px] font-bold" | |
| 208 | + style={{ borderColor: KA_LIME, color: KA_LIME }} | |
| 209 | + > | |
| 210 | + {(user.name || user.email || "K").charAt(0).toUpperCase()} | |
| 211 | + </span> | |
| 212 | + )} | |
| 213 | + </div> | |
| 214 | + | |
| 215 | + {/* bande code-barres */} | |
| 216 | + <div | |
| 217 | + aria-hidden="true" | |
| 218 | + className="-mx-[26px] flex gap-[5px] overflow-hidden px-[18px] py-[9px]" | |
| 219 | + style={{ | |
| 220 | + background: "rgba(217, 242, 107, 0.1)", | |
| 221 | + borderTop: "1px solid rgba(217, 242, 107, 0.25)", | |
| 222 | + }} | |
| 223 | + > | |
| 224 | + {Array.from({ length: 34 }).map((_, i) => ( | |
| 225 | + <i | |
| 226 | + key={i} | |
| 227 | + className="block w-[3px] rounded-[1px]" | |
| 228 | + style={{ | |
| 229 | + background: KA_LIME, | |
| 230 | + height: i % 3 === 0 ? 14 : i % 3 === 1 ? 9 : 17, | |
| 231 | + opacity: i % 3 === 0 ? 0.35 : i % 3 === 1 ? 0.7 : 0.9, | |
| 232 | + }} | |
| 233 | + /> | |
| 234 | + ))} | |
| 235 | + </div> | |
| 236 | + </div> | |
| 237 | + | |
| 238 | + {/* ——— actions carte ——— */} | |
| 239 | + <div className="mt-4 flex flex-wrap gap-2.5"> | |
| 240 | + <button | |
| 241 | + className="btn btn-ghost" | |
| 242 | + onClick={copyKaId} | |
| 243 | + style={copied ? { background: KA_LIME, color: "var(--ink)" } : undefined} | |
| 244 | + > | |
| 245 | + {copied ? "✓ " + (fr ? "Copié" : "Copied") : fr ? "Copier mon KA-ID" : "Copy my KA-ID"} | |
| 246 | + </button> | |
| 247 | + <button className="btn btn-ghost" onClick={logout} disabled={out}> | |
| 248 | + {out ? (fr ? "Déconnexion…" : "Signing out…") : fr ? "Se déconnecter" : "Sign out"} | |
| 249 | + </button> | |
| 250 | + </div> | |
| 251 | + | |
| 252 | + {/* ——— Mon profil Groupe KA — lecture seule, saisi sur groupe-ka.com ——— */} | |
| 253 | + <section className="vp-card mt-8 p-5 sm:p-6"> | |
| 254 | + <span className="kicker"> | |
| 255 | + {fr ? "Mon profil Groupe KA" : "My Groupe KA profile"} | |
| 256 | + </span> | |
| 257 | + | |
| 258 | + {profile ? ( | |
| 259 | + <> | |
| 260 | + {profile.bio ? ( | |
| 261 | + <p className="mt-3 max-w-xl text-[14.5px] leading-relaxed"> | |
| 262 | + {profile.bio} | |
| 263 | + </p> | |
| 264 | + ) : null} | |
| 265 | + | |
| 266 | + <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"> | |
| 267 | + {profile.job_title || profile.company ? ( | |
| 268 | + <div className="kv-cell"> | |
| 269 | + <div className="k">{fr ? "Emploi" : "Job"}</div> | |
| 270 | + <div className="v break-words"> | |
| 271 | + {[profile.job_title, profile.company] | |
| 272 | + .filter(Boolean) | |
| 273 | + .join(" · ")} | |
| 274 | + </div> | |
| 275 | + </div> | |
| 276 | + ) : null} | |
| 277 | + {profile.city ? ( | |
| 278 | + <div className="kv-cell"> | |
| 279 | + <div className="k">{fr ? "Ville" : "City"}</div> | |
| 280 | + <div className="v break-words">{profile.city}</div> | |
| 281 | + </div> | |
| 282 | + ) : null} | |
| 283 | + {profile.age != null ? ( | |
| 284 | + <div className="kv-cell"> | |
| 285 | + <div className="k">{fr ? "Âge" : "Age"}</div> | |
| 286 | + <div className="v"> | |
| 287 | + {profile.age} {fr ? "ans" : "yrs"} | |
| 288 | + </div> | |
| 289 | + </div> | |
| 290 | + ) : null} | |
| 291 | + {profile.website ? ( | |
| 292 | + <div className="kv-cell"> | |
| 293 | + <div className="k">{fr ? "Site web" : "Website"}</div> | |
| 294 | + <div className="v break-words"> | |
| 295 | + <a | |
| 296 | + href={ | |
| 297 | + /^https?:\/\//i.test(profile.website) | |
| 298 | + ? profile.website | |
| 299 | + : `https://${profile.website}` | |
| 300 | + } | |
| 301 | + target="_blank" | |
| 302 | + rel="noopener noreferrer" | |
| 303 | + className="underline decoration-2 underline-offset-2" | |
| 304 | + > | |
| 305 | + {profile.website.replace(/^https?:\/\//i, "")} | |
| 306 | + </a> | |
| 307 | + </div> | |
| 308 | + </div> | |
| 309 | + ) : null} | |
| 310 | + <div className="kv-cell"> | |
| 311 | + <div className="k">{fr ? "Statut" : "Status"}</div> | |
| 312 | + <div className="v break-words"> | |
| 313 | + {profile.role_label || (fr ? "Membre" : "Member")} | |
| 314 | + {profile.public && profile.public_url ? ( | |
| 315 | + <> | |
| 316 | + {" · "} | |
| 317 | + <a | |
| 318 | + href={profile.public_url} | |
| 319 | + target="_blank" | |
| 320 | + rel="noopener noreferrer" | |
| 321 | + className="underline decoration-2 underline-offset-2" | |
| 322 | + > | |
| 323 | + {fr ? "profil public ↗" : "public profile ↗"} | |
| 324 | + </a> | |
| 325 | + </> | |
| 326 | + ) : null} | |
| 327 | + </div> | |
| 328 | + </div> | |
| 329 | + </div> | |
| 330 | + | |
| 331 | + {/* réseaux sociaux — chips cliquables */} | |
| 332 | + {Object.entries(profile.socials || {}).some(([, v]) => v) ? ( | |
| 333 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 334 | + {(Object.keys(SOCIAL_LABELS) as (keyof HubSocials)[]).map( | |
| 335 | + (net) => { | |
| 336 | + const v = profile.socials?.[net]; | |
| 337 | + if (!v) return null; | |
| 338 | + return ( | |
| 339 | + <a | |
| 340 | + key={net} | |
| 341 | + href={socialUrl(net, v)} | |
| 342 | + target="_blank" | |
| 343 | + rel="noopener noreferrer" | |
| 344 | + className="stat-chip hover:bg-lime-soft" | |
| 345 | + > | |
| 346 | + <b>{SOCIAL_LABELS[net]}</b> {v} | |
| 347 | + </a> | |
| 348 | + ); | |
| 349 | + }, | |
| 350 | + )} | |
| 351 | + </div> | |
| 352 | + ) : null} | |
| 353 | + </> | |
| 354 | + ) : ( | |
| 355 | + <p className="mt-3 max-w-xl text-[13.5px] leading-relaxed text-ink-2"> | |
| 356 | + {fr | |
| 357 | + ? "Votre profil se remplit sur le hub groupe-ka.com — bio, ville, emploi, réseaux sociaux…" | |
| 358 | + : "Your profile is filled in on the groupe-ka.com hub — bio, city, job, social networks…"} | |
| 359 | + </p> | |
| 360 | + )} | |
| 361 | + | |
| 362 | + <div className="mt-5"> | |
| 363 | + <a | |
| 364 | + href="https://www.groupe-ka.com/compte" | |
| 365 | + target="_blank" | |
| 366 | + rel="noopener noreferrer" | |
| 367 | + className="btn btn-primary" | |
| 368 | + > | |
| 369 | + {fr | |
| 370 | + ? "Modifier mon profil sur groupe-ka.com ↗" | |
| 371 | + : "Edit my profile on groupe-ka.com ↗"} | |
| 372 | + </a> | |
| 373 | + </div> | |
| 374 | + <p className="mt-3 text-[12.5px] text-ink-2"> | |
| 375 | + {fr | |
| 376 | + ? "Une seule saisie, visible sur les huit plateformes du groupe." | |
| 377 | + : "Enter it once — visible across the group's eight platforms."} | |
| 378 | + </p> | |
| 379 | + </section> | |
| 380 | + | |
| 381 | + {/* ——— informations ——— */} | |
| 382 | + <section className="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-2"> | |
| 383 | + {infos.map(([k, v]) => ( | |
| 384 | + <div key={k} className="kv-cell"> | |
| 385 | + <div className="k">{k}</div> | |
| 386 | + <div className="v break-words">{v}</div> | |
| 387 | + </div> | |
| 388 | + ))} | |
| 389 | + </section> | |
| 390 | + | |
| 391 | + <p className="mt-7 max-w-xl text-[13.5px] leading-relaxed text-ink-2"> | |
| 392 | + {fr ? ( | |
| 393 | + <> | |
| 394 | + Votre <b>KA-ID</b> vous suit sur toutes les plateformes du{" "} | |
| 395 | + <a | |
| 396 | + href="https://www.groupe-ka.com" | |
| 397 | + target="_blank" | |
| 398 | + rel="noopener noreferrer" | |
| 399 | + className="font-bold underline decoration-2 underline-offset-2" | |
| 400 | + > | |
| 401 | + Groupe KA | |
| 402 | + </a>{" "} | |
| 403 | + — c'est le même identifiant partout. Vrai-Prix ne conserve que | |
| 404 | + cette session (nom, courriel, photo) ; rien d'autre, et jamais | |
| 405 | + revendu. | |
| 406 | + </> | |
| 407 | + ) : ( | |
| 408 | + <> | |
| 409 | + Your <b>KA-ID</b> follows you across every{" "} | |
| 410 | + <a | |
| 411 | + href="https://www.groupe-ka.com" | |
| 412 | + target="_blank" | |
| 413 | + rel="noopener noreferrer" | |
| 414 | + className="font-bold underline decoration-2 underline-offset-2" | |
| 415 | + > | |
| 416 | + Groupe KA | |
| 417 | + </a>{" "} | |
| 418 | + platform — the same ID everywhere. Vrai-Prix only keeps this session | |
| 419 | + (name, email, picture); nothing else, never sold. | |
| 420 | + </> | |
| 421 | + )} | |
| 422 | + </p> | |
| 423 | + </div> | |
| 424 | + ); | |
| 425 | +} | |
added
src/app/compte/page.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Page « Mon compte » : carte de membre Groupe KA (KA-ID), profil saisi au hub | |
| 3 | +// groupe-ka.com (bio, ville, emploi… — lecture seule ici), informations de la | |
| 4 | +// session et actions. Sans session valide → départ SSO KA ID (retour ici). | |
| 5 | +import type { Metadata } from "next"; | |
| 6 | +import { cookies } from "next/headers"; | |
| 7 | +import { redirect } from "next/navigation"; | |
| 8 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 9 | +import { fetchHubProfile } from "@/lib/hub-profile"; | |
| 10 | +import CompteClient from "./CompteClient"; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { | |
| 13 | + title: "Mon compte", | |
| 14 | + description: | |
| 15 | + "Votre carte de membre Groupe KA : le KA-ID vous suit sur toutes les plateformes du groupe.", | |
| 16 | + robots: { index: false, follow: false }, | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export default async function ComptePage() { | |
| 20 | + const jar = await cookies(); | |
| 21 | + const session = readSession(jar.get(SESSION_COOKIE)?.value); | |
| 22 | + if (!session) redirect("/api/auth/ka/login?next=/compte"); | |
| 23 | + // Profil au hub Groupe KA — cache 60 s ; null si injoignable ou non relié | |
| 24 | + // (on retombe alors sur les données de la session locale). | |
| 25 | + const profile = await fetchHubProfile(session.ka_id); | |
| 26 | + return ( | |
| 27 | + <CompteClient | |
| 28 | + user={{ | |
| 29 | + ka_id: session.ka_id, | |
| 30 | + email: profile?.email || session.email, | |
| 31 | + name: profile?.name || session.name, | |
| 32 | + picture: profile?.picture || session.picture, | |
| 33 | + provider: session.provider ?? "ka-id", | |
| 34 | + }} | |
| 35 | + profile={profile} | |
| 36 | + /> | |
| 37 | + ); | |
| 38 | +} | |
renamed
src/app/conditions/page.tsx → src/app/conditions/ConditionsClient.tsx
+0 −0
renamed
src/app/confidentialite/page.tsx → src/app/confidentialite/ConfidentialiteClient.tsx
+0 −0
renamed
src/app/methodologie/page.tsx → src/app/methodologie/MethodologieClient.tsx
+0 −0
renamed
src/app/parc/page.tsx → src/app/parc/ParcClient.tsx
+0 −0
added
src/components/AccountButton.tsx
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Bouton compte de l'en-tête (KA ID — SSO du Groupe KA). | |
| 4 | + * · Déconnecté : pilule « KA ID » → /api/auth/ka/login?next=<page courante> | |
| 5 | + * · Connecté : avatar (ou initiale) + prénom → /compte | |
| 6 | + * Composant client : interroge /api/auth/me au montage (session cookie httpOnly). | |
| 7 | + */ | |
| 8 | +"use client"; | |
| 9 | +import { useEffect, useState } from "react"; | |
| 10 | +import Link from "next/link"; | |
| 11 | +import { usePathname } from "next/navigation"; | |
| 12 | +import { useLang } from "./LangContext"; | |
| 13 | + | |
| 14 | +type MeUser = { | |
| 15 | + ka_id: string; | |
| 16 | + email: string; | |
| 17 | + name: string; | |
| 18 | + picture: string; | |
| 19 | + provider: string; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export default function AccountButton() { | |
| 23 | + const { lang } = useLang(); | |
| 24 | + const fr = lang === "fr"; | |
| 25 | + const path = usePathname(); | |
| 26 | + // undefined = chargement (on ne montre rien pour éviter le clignotement) | |
| 27 | + const [user, setUser] = useState<MeUser | null | undefined>(undefined); | |
| 28 | + | |
| 29 | + useEffect(() => { | |
| 30 | + let alive = true; | |
| 31 | + fetch("/api/auth/me") | |
| 32 | + .then((r) => r.json()) | |
| 33 | + .then((d) => alive && setUser(d?.user ?? null)) | |
| 34 | + .catch(() => alive && setUser(null)); | |
| 35 | + return () => { | |
| 36 | + alive = false; | |
| 37 | + }; | |
| 38 | + }, [path]); | |
| 39 | + | |
| 40 | + if (user === undefined) { | |
| 41 | + return <span className="min-h-[36px] w-[52px]" aria-hidden="true" />; | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (user === null) { | |
| 45 | + return ( | |
| 46 | + <a | |
| 47 | + href={`/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`} | |
| 48 | + className="vp-mono inline-flex min-h-[36px] items-center rounded-full border-[1.5px] border-ink bg-ink px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-lime transition-all hover:bg-green-deep" | |
| 49 | + title={fr ? "Se connecter avec KA ID" : "Sign in with KA ID"} | |
| 50 | + > | |
| 51 | + KA ID | |
| 52 | + </a> | |
| 53 | + ); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const prenom = (user.name || user.email).split(" ")[0]; | |
| 57 | + return ( | |
| 58 | + <Link | |
| 59 | + href="/compte" | |
| 60 | + className="group inline-flex min-h-[36px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-surface py-1 pl-1 pr-3 transition-all hover:bg-lime-soft" | |
| 61 | + title={fr ? "Mon compte — carte de membre Groupe KA" : "My account — Groupe KA member card"} | |
| 62 | + > | |
| 63 | + {user.picture ? ( | |
| 64 | + // eslint-disable-next-line @next/next/no-img-element | |
| 65 | + <img | |
| 66 | + src={user.picture} | |
| 67 | + alt="" | |
| 68 | + referrerPolicy="no-referrer" | |
| 69 | + className="h-7 w-7 rounded-full border border-ink object-cover" | |
| 70 | + /> | |
| 71 | + ) : ( | |
| 72 | + <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-lime"> | |
| 73 | + {prenom.charAt(0).toUpperCase()} | |
| 74 | + </span> | |
| 75 | + )} | |
| 76 | + <span className="vp-display max-w-[92px] truncate text-[13px] font-bold text-ink"> | |
| 77 | + {prenom} | |
| 78 | + </span> | |
| 79 | + </Link> | |
| 80 | + ); | |
| 81 | +} | |
added
src/lib/hub-profile.ts
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ----------------------------------------------------------------------------- | |
| 3 | +// Profil membre lu depuis le HUB Groupe KA (groupe-ka.com). | |
| 4 | +// Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise, | |
| 5 | +// site web, réseaux sociaux, photo, statut, profil public) — l'édition se | |
| 6 | +// fait sur groupe-ka.com/compte, Vrai-Prix ne fait qu'afficher. | |
| 7 | +// GET {hub}/api/sso/profile?client_id=vrai-prix&ka_id=…&ts=…&sig=… | |
| 8 | +// avec sig = HMAC-SHA256(KA_SSO_SECRET, "vrai-prix.<ka_id>.<ts>") en hex | |
| 9 | +// (même secret que le SSO). Cache mémoire 60 s (les 404 aussi — cache | |
| 10 | +// négatif) ; null sur toute erreur (réseau, 401, 5xx, vieux compte non | |
| 11 | +// relié) → l'appelant retombe sur les données locales de session. | |
| 12 | +// Aucune dépendance : node:crypto + fetch natif seulement. | |
| 13 | +// ----------------------------------------------------------------------------- | |
| 14 | +import { createHmac } from "node:crypto"; | |
| 15 | +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; | |
| 16 | + | |
| 17 | +const CACHE_TTL_MS = 60_000; // 60 s | |
| 18 | +const TIMEOUT_MS = 5_000; // 5 s | |
| 19 | + | |
| 20 | +export type HubSocials = { | |
| 21 | + instagram?: string; | |
| 22 | + facebook?: string; | |
| 23 | + x?: string; | |
| 24 | + linkedin?: string; | |
| 25 | + tiktok?: string; | |
| 26 | + youtube?: string; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** Réponse de GET /api/sso/profile du hub (200). */ | |
| 30 | +export type HubProfile = { | |
| 31 | + ka_id: string; | |
| 32 | + name: string; | |
| 33 | + email: string; | |
| 34 | + picture: string; | |
| 35 | + role: string; | |
| 36 | + role_label: string; | |
| 37 | + bio: string; | |
| 38 | + city: string; | |
| 39 | + phone: string; | |
| 40 | + website: string; | |
| 41 | + job_title: string; | |
| 42 | + company: string; | |
| 43 | + birth_date: string; | |
| 44 | + age: number | null; | |
| 45 | + socials: HubSocials; | |
| 46 | + public: boolean; | |
| 47 | + public_url: string; | |
| 48 | + created_at: string | number; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +// Cache mémoire process-wide (Map globale — survit au HMR en dev) : | |
| 52 | +// 200 → profil ; 404 → null (cache négatif). Erreurs transitoires : pas de cache. | |
| 53 | +type CacheEntry = { at: number; data: HubProfile | null }; | |
| 54 | +const g = globalThis as unknown as { __vpHubProfileCache?: Map<string, CacheEntry> }; | |
| 55 | +const cache: Map<string, CacheEntry> = | |
| 56 | + g.__vpHubProfileCache ?? (g.__vpHubProfileCache = new Map()); | |
| 57 | + | |
| 58 | +/** Profil du membre au hub Groupe KA, ou null (inconnu ou injoignable). */ | |
| 59 | +export async function fetchHubProfile(kaId: string): Promise<HubProfile | null> { | |
| 60 | + const secret = process.env.KA_SSO_SECRET; | |
| 61 | + if (!secret || !kaId) return null; | |
| 62 | + | |
| 63 | + const now = Date.now(); | |
| 64 | + const hit = cache.get(kaId); | |
| 65 | + if (hit && now - hit.at < CACHE_TTL_MS) return hit.data; | |
| 66 | + | |
| 67 | + const ts = Math.floor(now / 1000); | |
| 68 | + const sig = createHmac("sha256", secret) | |
| 69 | + .update(`${CLIENT_ID}.${kaId}.${ts}`) | |
| 70 | + .digest("hex"); | |
| 71 | + const url = | |
| 72 | + `${KA_HUB_URL}/api/sso/profile?client_id=${encodeURIComponent(CLIENT_ID)}` + | |
| 73 | + `&ka_id=${encodeURIComponent(kaId)}&ts=${ts}&sig=${sig}`; | |
| 74 | + | |
| 75 | + const ctrl = new AbortController(); | |
| 76 | + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); | |
| 77 | + try { | |
| 78 | + const r = await fetch(url, { signal: ctrl.signal, cache: "no-store" }); | |
| 79 | + if (r.status === 404) { | |
| 80 | + cache.set(kaId, { at: now, data: null }); // cache négatif 60 s | |
| 81 | + return null; | |
| 82 | + } | |
| 83 | + if (!r.ok) return null; // transitoire (401, 5xx…) : pas de cache | |
| 84 | + const data = (await r.json()) as unknown; | |
| 85 | + if (typeof data !== "object" || data === null) return null; | |
| 86 | + const profile = data as HubProfile; | |
| 87 | + cache.set(kaId, { at: now, data: profile }); | |
| 88 | + return profile; | |
| 89 | + } catch { | |
| 90 | + return null; // réseau / timeout / JSON : pas de cache | |
| 91 | + } finally { | |
| 92 | + clearTimeout(timer); | |
| 93 | + } | |
| 94 | +} | |
added
src/lib/ka-auth.ts
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ----------------------------------------------------------------------------- | |
| 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 le | |
| 5 | +// renvoie ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET | |
| 6 | +// et transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur | |
| 7 | +// 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_SECRET | |
| 11 | +// (signature, alg, iss, aud, exp). | |
| 12 | +// ----------------------------------------------------------------------------- | |
| 13 | +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; | |
| 14 | + | |
| 15 | +export const SESSION_COOKIE = "vp_ka_session"; | |
| 16 | +export const SESSION_DAYS = 30; | |
| 17 | +export const CLIENT_ID = "vrai-prix"; | |
| 18 | + | |
| 19 | +export const KA_HUB_URL = ( | |
| 20 | + process.env.KA_HUB_URL || "https://www.groupe-ka.com" | |
| 21 | +).replace(/\/+$/, ""); | |
| 22 | + | |
| 23 | +/** URL publique de l'app (redirect_uri — le hub n'accepte que vrai-prix.com). */ | |
| 24 | +export const KA_BASE_URL = ( | |
| 25 | + process.env.KA_BASE_URL || "https://www.vrai-prix.com" | |
| 26 | +).replace(/\/+$/, ""); | |
| 27 | + | |
| 28 | +/** Session locale posée en cookie httpOnly après un retour SSO réussi. */ | |
| 29 | +export type KaSession = { | |
| 30 | + ka_id: string; | |
| 31 | + email: string; | |
| 32 | + name: string; | |
| 33 | + picture: string; | |
| 34 | + provider?: string; | |
| 35 | + exp: number; // epoch (secondes) | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const now = () => Math.floor(Date.now() / 1000); | |
| 39 | + | |
| 40 | +function authSecret(): Buffer | null { | |
| 41 | + const s = process.env.KA_AUTH_SECRET; | |
| 42 | + return s ? Buffer.from(s, "utf8") : null; | |
| 43 | +} | |
| 44 | + | |
| 45 | +function ssoSecret(): Buffer | null { | |
| 46 | + const s = process.env.KA_SSO_SECRET; | |
| 47 | + return s ? Buffer.from(s, "utf8") : null; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */ | |
| 51 | +function safeEqual(a: Buffer, b: Buffer): boolean { | |
| 52 | + return a.length === b.length && timingSafeEqual(a, b); | |
| 53 | +} | |
| 54 | + | |
| 55 | +// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------ | |
| 56 | + | |
| 57 | +export 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 | +} | |
| 64 | + | |
| 65 | +export 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 | +} | |
| 85 | + | |
| 86 | +// -- state SSO (anti-forgerie, transporte aussi la destination `next`) -------- | |
| 87 | + | |
| 88 | +/** State opaque signé localement — expire en 10 minutes. */ | |
| 89 | +export function newState(next: string): string | null { | |
| 90 | + return signLocal({ | |
| 91 | + n: randomBytes(12).toString("base64url"), | |
| 92 | + next, | |
| 93 | + exp: now() + 600, | |
| 94 | + }); | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Vérifie le state et rend la destination `next` (chemin local sûr). */ | |
| 98 | +export 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 | +} | |
| 104 | + | |
| 105 | +/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */ | |
| 106 | +export function sanitizeNext(next: string | null | undefined): string { | |
| 107 | + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/compte"; | |
| 108 | + return next; | |
| 109 | +} | |
| 110 | + | |
| 111 | +// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) --------------- | |
| 112 | + | |
| 113 | +export 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; | |
| 124 | + | |
| 125 | + const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8")); | |
| 126 | + if (header?.alg !== "HS256") return null; | |
| 127 | + | |
| 128 | + 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 | +} | |
| 140 | + | |
| 141 | +// -- session locale ------------------------------------------------------------ | |
| 142 | + | |
| 143 | +/** Construit le cookie de session (30 jours) depuis les claims du hub. */ | |
| 144 | +export 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 — requis | |
| 149 | + 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.name | |
| 156 | + ? claims.name | |
| 157 | + : 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 | +} | |
| 165 | + | |
| 166 | +/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */ | |
| 167 | +export 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 | ||