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 66.3%
HTML 29.4%
CSS 4.3%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Retour du flux Google OAuth : échange du code, vérification de3// l'id_token (JWKS Google), création/liaison du KA ID, ouverture de session.4import { NextRequest, NextResponse } from "next/server";5import { createRemoteJWKSet, jwtVerify } from "jose";6import { db, findUserByEmail, findUserByGoogleSub, ensureKaId, touchLastLogin } from "@/lib/db";7import {8 BASE_URL,9 mintSessionToken,10 SESSION_COOKIE,11 sessionCookieOptions,12 safeNext,13} from "@/lib/auth";1415const GOOGLE_JWKS = createRemoteJWKSet(16 new URL("https://www.googleapis.com/oauth2/v3/certs"),17);1819function fail(reason: string) {20 return NextResponse.redirect(21 new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL),22 );23}2425export async function GET(req: NextRequest) {26 const clientId = process.env.GOOGLE_CLIENT_ID;27 const clientSecret = process.env.GOOGLE_CLIENT_SECRET;28 if (!clientId || !clientSecret) return fail("google-non-configure");2930 const code = req.nextUrl.searchParams.get("code");31 const state = req.nextUrl.searchParams.get("state");32 const cookieState = req.cookies.get("ka_oauth_state")?.value;33 const next = safeNext(req.cookies.get("ka_oauth_next")?.value);34 if (!code || !state || !cookieState || state !== cookieState)35 return fail("etat-oauth-invalide");3637 // Échange code → jetons38 const tokenRes = await fetch("https://oauth2.googleapis.com/token", {39 method: "POST",40 headers: { "Content-Type": "application/x-www-form-urlencoded" },41 body: new URLSearchParams({42 code,43 client_id: clientId,44 client_secret: clientSecret,45 redirect_uri: `${BASE_URL}/api/auth/callback/google`,46 grant_type: "authorization_code",47 }),48 });49 if (!tokenRes.ok) return fail("echange-google-refuse");50 const tokens = (await tokenRes.json()) as { id_token?: string };51 if (!tokens.id_token) return fail("id-token-absent");5253 // Vérification cryptographique de l'id_token54 let claims: {55 sub?: string;56 email?: string;57 email_verified?: boolean;58 name?: string;59 picture?: string;60 };61 try {62 const { payload } = await jwtVerify(tokens.id_token, GOOGLE_JWKS, {63 issuer: ["https://accounts.google.com", "accounts.google.com"],64 audience: clientId,65 });66 claims = payload as typeof claims;67 } catch {68 return fail("id-token-invalide");69 }70 if (!claims.sub || !claims.email || claims.email_verified === false)71 return fail("profil-google-incomplet");7273 const email = claims.email.toLowerCase();74 const name = claims.name ?? email.split("@")[0];75 const picture = claims.picture ?? null;7677 // Upsert : par google_sub, sinon liaison par courriel, sinon création.78 let user = findUserByGoogleSub(claims.sub);79 if (user) {80 db.prepare("UPDATE users SET name = ?, avatar_url = ? WHERE id = ?").run(81 name,82 picture,83 user.id,84 );85 } else {86 const byEmail = findUserByEmail(email);87 if (byEmail) {88 db.prepare(89 "UPDATE users SET google_sub = ?, avatar_url = COALESCE(?, avatar_url) WHERE id = ?",90 ).run(claims.sub, picture, byEmail.id);91 user = { ...byEmail, google_sub: claims.sub };92 } else {93 const info = db94 .prepare(95 "INSERT INTO users (email, name, avatar_url, google_sub) VALUES (?, ?, ?, ?)",96 )97 .run(email, name, picture, claims.sub);98 user = findUserByGoogleSub(claims.sub)!;99 ensureKaId(user.id);100 void info;101 }102 }103104 ensureKaId(user!.id);105 touchLastLogin(user!.id);106 const res = NextResponse.redirect(new URL(next, BASE_URL));107 res.cookies.set(108 SESSION_COOKIE,109 await mintSessionToken(user!.id),110 sessionCookieOptions,111 );112 res.cookies.set("ka_oauth_state", "", { path: "/", maxAge: 0 });113 res.cookies.set("ka_oauth_next", "", { path: "/", maxAge: 0 });114 return res;115}116