// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Retour du flux Sign in with Apple : échange du code, vérification de // l'id_token (JWKS Apple), création/liaison du KA ID, ouverture de session. // // Différences avec Google : // - le retour arrive en POST (form_post) et non en GET ; // - le client_secret est un JWT ES256 signé avec la clé privée .p8 // (APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY), regénéré à chaque // échange ; // - le nom (`user`) n'est transmis qu'à la PREMIÈRE autorisation ; // - email_verified peut être un booléen ou la chaîne "true". import { NextRequest, NextResponse } from "next/server"; import { createRemoteJWKSet, jwtVerify, SignJWT, importPKCS8 } from "jose"; import { db, findUserByEmail, findUserByAppleSub, ensureKaId, touchLastLogin, } from "@/lib/db"; import { BASE_URL, mintSessionToken, SESSION_COOKIE, sessionCookieOptions, safeNext, } from "@/lib/auth"; const APPLE_JWKS = createRemoteJWKSet( new URL("https://appleid.apple.com/auth/keys"), ); function fail(reason: string) { return NextResponse.redirect( new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL), // 303 : on répond à un POST par une redirection GET. { status: 303 }, ); } /** client_secret Apple : JWT ES256 signé avec la clé .p8, valable 5 min. */ async function appleClientSecret(clientId: string): Promise { const teamId = process.env.APPLE_TEAM_ID; const keyId = process.env.APPLE_KEY_ID; const pem = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n"); if (!teamId || !keyId || !pem) return null; const key = await importPKCS8(pem, "ES256"); return await new SignJWT({}) .setProtectedHeader({ alg: "ES256", kid: keyId }) .setIssuedAt() .setIssuer(teamId) .setSubject(clientId) .setAudience("https://appleid.apple.com") .setExpirationTime("5m") .sign(key); } export async function POST(req: NextRequest) { const clientId = process.env.APPLE_CLIENT_ID; if (!clientId) return fail("apple-non-configure"); const clientSecret = await appleClientSecret(clientId).catch(() => null); if (!clientSecret) return fail("apple-non-configure"); const form = await req.formData().catch(() => null); if (!form) return fail("etat-oauth-invalide"); if (form.get("error")) return fail("apple-refuse"); const code = form.get("code")?.toString(); const state = form.get("state")?.toString(); const cookieState = req.cookies.get("ka_apple_state")?.value; const next = safeNext(req.cookies.get("ka_apple_next")?.value); if (!code || !state || !cookieState || state !== cookieState) return fail("etat-oauth-invalide"); // Nom complet : transmis par Apple UNIQUEMENT à la première autorisation. let firstAuthName: string | null = null; try { const u = JSON.parse(form.get("user")?.toString() ?? "") as { name?: { firstName?: string; lastName?: string }; }; const full = [u.name?.firstName, u.name?.lastName] .filter(Boolean) .join(" ") .trim(); if (full) firstAuthName = full; } catch { /* pas de champ user (connexions suivantes) */ } // Échange code → jetons const tokenRes = await fetch("https://appleid.apple.com/auth/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/apple`, grant_type: "authorization_code", }), }); if (!tokenRes.ok) return fail("echange-apple-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 | string; }; try { const { payload } = await jwtVerify(tokens.id_token, APPLE_JWKS, { issuer: "https://appleid.apple.com", audience: clientId, }); claims = payload as typeof claims; } catch { return fail("id-token-invalide"); } const emailVerified = claims.email_verified === true || claims.email_verified === "true"; if (!claims.sub || !claims.email || !emailVerified) return fail("profil-apple-incomplet"); const email = claims.email.toLowerCase(); const name = firstAuthName ?? email.split("@")[0]; // Upsert : par apple_sub, sinon liaison par courriel, sinon création. let user = findUserByAppleSub(claims.sub); if (!user) { const byEmail = findUserByEmail(email); if (byEmail) { db.prepare("UPDATE users SET apple_sub = ? WHERE id = ?").run( claims.sub, byEmail.id, ); user = { ...byEmail, apple_sub: claims.sub }; } else { db.prepare( "INSERT INTO users (email, name, apple_sub) VALUES (?, ?, ?)", ).run(email, name, claims.sub); user = findUserByAppleSub(claims.sub)!; ensureKaId(user.id); } } ensureKaId(user.id); touchLastLogin(user.id); const res = NextResponse.redirect(new URL(next, BASE_URL), { status: 303 }); res.cookies.set( SESSION_COOKIE, await mintSessionToken(user.id), sessionCookieOptions, ); res.cookies.set("ka_apple_state", "", { path: "/", maxAge: 0 }); res.cookies.set("ka_apple_next", "", { path: "/", maxAge: 0 }); return res; }