SPB Git

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%
3.1 KB · 106 lines typescript
Raw Blame History
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