// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Retour du flux Google OAuth : échange du code, vérification de // l'id_token (JWKS Google), création/liaison du KA ID, ouverture de session. import { NextRequest, NextResponse } from "next/server"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { db, findUserByEmail, findUserByGoogleSub, ensureKaId, touchLastLogin } from "@/lib/db"; import { BASE_URL, mintSessionToken, SESSION_COOKIE, sessionCookieOptions, safeNext, } from "@/lib/auth"; const GOOGLE_JWKS = createRemoteJWKSet( new URL("https://www.googleapis.com/oauth2/v3/certs"), ); function fail(reason: string) { return NextResponse.redirect( new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL), ); } export async function GET(req: NextRequest) { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!clientId || !clientSecret) return fail("google-non-configure"); const code = req.nextUrl.searchParams.get("code"); const state = req.nextUrl.searchParams.get("state"); const cookieState = req.cookies.get("ka_oauth_state")?.value; const next = safeNext(req.cookies.get("ka_oauth_next")?.value); if (!code || !state || !cookieState || state !== cookieState) return fail("etat-oauth-invalide"); // Échange code → jetons const tokenRes = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: `${BASE_URL}/api/auth/callback/google`, grant_type: "authorization_code", }), }); if (!tokenRes.ok) return fail("echange-google-refuse"); const tokens = (await tokenRes.json()) as { id_token?: string }; if (!tokens.id_token) return fail("id-token-absent"); // Vérification cryptographique de l'id_token let claims: { sub?: string; email?: string; email_verified?: boolean; name?: string; picture?: string; }; try { const { payload } = await jwtVerify(tokens.id_token, GOOGLE_JWKS, { issuer: ["https://accounts.google.com", "accounts.google.com"], audience: clientId, }); claims = payload as typeof claims; } catch { return fail("id-token-invalide"); } if (!claims.sub || !claims.email || claims.email_verified === false) return fail("profil-google-incomplet"); const email = claims.email.toLowerCase(); const name = claims.name ?? email.split("@")[0]; const picture = claims.picture ?? null; // Upsert : par google_sub, sinon liaison par courriel, sinon création. let user = findUserByGoogleSub(claims.sub); if (user) { db.prepare("UPDATE users SET name = ?, avatar_url = ? WHERE id = ?").run( name, picture, user.id, ); } else { const byEmail = findUserByEmail(email); if (byEmail) { db.prepare( "UPDATE users SET google_sub = ?, avatar_url = COALESCE(?, avatar_url) WHERE id = ?", ).run(claims.sub, picture, byEmail.id); user = { ...byEmail, google_sub: claims.sub }; } else { const info = db .prepare( "INSERT INTO users (email, name, avatar_url, google_sub) VALUES (?, ?, ?, ?)", ) .run(email, name, picture, claims.sub); user = findUserByGoogleSub(claims.sub)!; ensureKaId(user.id); void info; } } ensureKaId(user!.id); touchLastLogin(user!.id); const res = NextResponse.redirect(new URL(next, BASE_URL)); res.cookies.set( SESSION_COOKIE, await mintSessionToken(user!.id), sessionCookieOptions, ); res.cookies.set("ka_oauth_state", "", { path: "/", maxAge: 0 }); res.cookies.set("ka_oauth_next", "", { path: "/", maxAge: 0 }); return res; }