spb/groupe-ka Public
Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 93.9%
CSS 6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID — sessions (JWT HS256 en témoin HttpOnly) + hachage scrypt.3import { SignJWT, jwtVerify } from "jose";4import { cookies } from "next/headers";5import crypto from "crypto";6import { findUserById, type UserRow } from "./db";78export const SESSION_COOKIE = "ka_session";9export const BASE_URL = process.env.BASE_URL ?? "https://www.groupe-ka.com";1011function secret(): Uint8Array {12 const s = process.env.KA_AUTH_SECRET;13 if (!s) {14 if (process.env.NODE_ENV === "production")15 throw new Error("KA_AUTH_SECRET manquant");16 return new TextEncoder().encode("ka-dev-secret-non-production");17 }18 return new TextEncoder().encode(s);19}2021/* ---------- mots de passe (scrypt natif, zéro dépendance) ---------- */2223export function hashPassword(password: string): string {24 const salt = crypto.randomBytes(16).toString("hex");25 const hash = crypto.scryptSync(password, salt, 64).toString("hex");26 return `${salt}:${hash}`;27}2829export function verifyPassword(password: string, stored: string): boolean {30 const [salt, hash] = stored.split(":");31 if (!salt || !hash) return false;32 const test = crypto.scryptSync(password, salt, 64);33 return crypto.timingSafeEqual(test, Buffer.from(hash, "hex"));34}3536/* ---------- sessions ---------- */3738export type SessionUser = {39 id: number;40 email: string;41 name: string;42 avatarUrl: string | null;43 hasGoogle: boolean;44 hasApple: boolean;45 hasPassword: boolean;46 kaId: string | null;47 createdAt: string;48 lastLogin: string | null;49 role: string | null;50};5152export async function mintSessionToken(userId: number): Promise<string> {53 return await new SignJWT({ uid: userId })54 .setProtectedHeader({ alg: "HS256" })55 .setIssuedAt()56 .setIssuer(BASE_URL)57 .setExpirationTime("30d")58 .sign(secret());59}6061export const sessionCookieOptions = {62 httpOnly: true,63 secure: process.env.NODE_ENV === "production",64 sameSite: "lax" as const,65 path: "/",66 maxAge: 60 * 60 * 24 * 30,67};6869export async function getSessionUser(): Promise<SessionUser | null> {70 const jar = await cookies();71 const token = jar.get(SESSION_COOKIE)?.value;72 if (!token) return null;73 try {74 const { payload } = await jwtVerify(token, secret(), {75 issuer: BASE_URL,76 });77 const row = findUserById(Number(payload.uid)) as UserRow | undefined;78 if (!row) return null;79 return {80 id: row.id,81 email: row.email,82 name: row.name,83 avatarUrl: row.avatar_url,84 hasGoogle: !!row.google_sub,85 hasApple: !!row.apple_sub,86 hasPassword: !!row.password_hash,87 kaId: row.ka_id,88 createdAt: row.created_at,89 lastLogin: row.last_login,90 role: row.role,91 };92 } catch {93 return null;94 }95}9697/* ---------- redirections sûres ---------- */9899// N'accepte que les chemins internes (« /compte ») — jamais d'URL absolue,100// sauf un retour SSO interne déjà validé par /sso/authorize.101export function safeNext(next: string | null | undefined): string {102 if (!next) return "/compte";103 if (next.startsWith("/") && !next.startsWith("//")) return next;104 return "/compte";105}106