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 Sign in with Apple : échange du code, vérification de3// l'id_token (JWKS Apple), création/liaison du KA ID, ouverture de session.4//5// Différences avec Google :6// - le retour arrive en POST (form_post) et non en GET ;7// - le client_secret est un JWT ES256 signé avec la clé privée .p88// (APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY), regénéré à chaque9// échange ;10// - le nom (`user`) n'est transmis qu'à la PREMIÈRE autorisation ;11// - email_verified peut être un booléen ou la chaîne "true".12import { NextRequest, NextResponse } from "next/server";13import { createRemoteJWKSet, jwtVerify, SignJWT, importPKCS8 } from "jose";14import {15 db,16 findUserByEmail,17 findUserByAppleSub,18 ensureKaId,19 touchLastLogin,20} from "@/lib/db";21import {22 BASE_URL,23 mintSessionToken,24 SESSION_COOKIE,25 sessionCookieOptions,26 safeNext,27} from "@/lib/auth";2829const APPLE_JWKS = createRemoteJWKSet(30 new URL("https://appleid.apple.com/auth/keys"),31);3233function fail(reason: string) {34 return NextResponse.redirect(35 new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL),36 // 303 : on répond à un POST par une redirection GET.37 { status: 303 },38 );39}4041/** client_secret Apple : JWT ES256 signé avec la clé .p8, valable 5 min. */42async function appleClientSecret(clientId: string): Promise<string | null> {43 const teamId = process.env.APPLE_TEAM_ID;44 const keyId = process.env.APPLE_KEY_ID;45 const pem = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n");46 if (!teamId || !keyId || !pem) return null;47 const key = await importPKCS8(pem, "ES256");48 return await new SignJWT({})49 .setProtectedHeader({ alg: "ES256", kid: keyId })50 .setIssuedAt()51 .setIssuer(teamId)52 .setSubject(clientId)53 .setAudience("https://appleid.apple.com")54 .setExpirationTime("5m")55 .sign(key);56}5758export async function POST(req: NextRequest) {59 const clientId = process.env.APPLE_CLIENT_ID;60 if (!clientId) return fail("apple-non-configure");61 const clientSecret = await appleClientSecret(clientId).catch(() => null);62 if (!clientSecret) return fail("apple-non-configure");6364 const form = await req.formData().catch(() => null);65 if (!form) return fail("etat-oauth-invalide");66 if (form.get("error")) return fail("apple-refuse");6768 const code = form.get("code")?.toString();69 const state = form.get("state")?.toString();70 const cookieState = req.cookies.get("ka_apple_state")?.value;71 const next = safeNext(req.cookies.get("ka_apple_next")?.value);72 if (!code || !state || !cookieState || state !== cookieState)73 return fail("etat-oauth-invalide");7475 // Nom complet : transmis par Apple UNIQUEMENT à la première autorisation.76 let firstAuthName: string | null = null;77 try {78 const u = JSON.parse(form.get("user")?.toString() ?? "") as {79 name?: { firstName?: string; lastName?: string };80 };81 const full = [u.name?.firstName, u.name?.lastName]82 .filter(Boolean)83 .join(" ")84 .trim();85 if (full) firstAuthName = full;86 } catch {87 /* pas de champ user (connexions suivantes) */88 }8990 // Échange code → jetons91 const tokenRes = await fetch("https://appleid.apple.com/auth/token", {92 method: "POST",93 headers: { "Content-Type": "application/x-www-form-urlencoded" },94 body: new URLSearchParams({95 code,96 client_id: clientId,97 client_secret: clientSecret,98 redirect_uri: `${BASE_URL}/api/auth/callback/apple`,99 grant_type: "authorization_code",100 }),101 });102 if (!tokenRes.ok) return fail("echange-apple-refuse");103 const tokens = (await tokenRes.json()) as { id_token?: string };104 if (!tokens.id_token) return fail("id-token-absent");105106 // Vérification cryptographique de l'id_token107 let claims: {108 sub?: string;109 email?: string;110 email_verified?: boolean | string;111 };112 try {113 const { payload } = await jwtVerify(tokens.id_token, APPLE_JWKS, {114 issuer: "https://appleid.apple.com",115 audience: clientId,116 });117 claims = payload as typeof claims;118 } catch {119 return fail("id-token-invalide");120 }121 const emailVerified =122 claims.email_verified === true || claims.email_verified === "true";123 if (!claims.sub || !claims.email || !emailVerified)124 return fail("profil-apple-incomplet");125126 const email = claims.email.toLowerCase();127 const name = firstAuthName ?? email.split("@")[0];128129 // Upsert : par apple_sub, sinon liaison par courriel, sinon création.130 let user = findUserByAppleSub(claims.sub);131 if (!user) {132 const byEmail = findUserByEmail(email);133 if (byEmail) {134 db.prepare("UPDATE users SET apple_sub = ? WHERE id = ?").run(135 claims.sub,136 byEmail.id,137 );138 user = { ...byEmail, apple_sub: claims.sub };139 } else {140 db.prepare(141 "INSERT INTO users (email, name, apple_sub) VALUES (?, ?, ?)",142 ).run(email, name, claims.sub);143 user = findUserByAppleSub(claims.sub)!;144 ensureKaId(user.id);145 }146 }147148 ensureKaId(user.id);149 touchLastLogin(user.id);150 const res = NextResponse.redirect(new URL(next, BASE_URL), { status: 303 });151 res.cookies.set(152 SESSION_COOKIE,153 await mintSessionToken(user.id),154 sessionCookieOptions,155 );156 res.cookies.set("ka_apple_state", "", { path: "/", maxAge: 0 });157 res.cookies.set("ka_apple_next", "", { path: "/", maxAge: 0 });158 return res;159}160