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 66.3% HTML 29.4% CSS 4.3%
5.3 KB · 160 lines typescript
Raw Blame History
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