WakaStart
Authentification

Authentification — Implémentation PKCE

Code complet du flow PKCE : génération, start-login, échange, enrichissement, cookies et logout.

Version v1.05 min de lecture

Authentification — Implémentation PKCE

Ce chapitre couvre le code TypeScript complet de chaque étape du flow. Lisez Authentification — Concepts & Flow d'abord.


Étape 2 — Génération PKCE (TypeScript)

typescript
// Génération du code_verifier (43-128 chars URL-safe) function generateCodeVerifier(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } // Génération du code_challenge S256 async function generateCodeChallenge(verifier: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } // Usage : const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); sessionStorage.setItem('pkce_verifier', codeVerifier);

Étape 3 — Appel start-login

http
POST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "host": "app.test", "redirectUri": "https://app.test.wakastart-dev.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<BASE64URL(SHA256(verifier))>", "codeChallengeMethod": "S256" }

Réponse 200 :

json
{ "keycloakUrl": "https://auth.example.com/realms/acme-prod/protocol/openid-connect/auth?client_id=7OWWAA&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256&response_type=code" }

Erreurs :

StatusBodyCause
400invalid_requestemail + host simultanés, ou aucun
400invalid_requestBody manquant ou malformé
404not_foundAucun Network ne correspond au host
429rate limit5 req/s · 20/10s · 100/60s par IP

Étape 4 — Extraction et stockage

typescript
const { keycloakUrl } = await startLoginResponse.json(); const parsed = new URL(keycloakUrl); const realm = parsed.pathname.match(/\/realms\/([^/]+)\//)?.[1]; const clientId = parsed.searchParams.get("client_id"); // Stocker AVANT le redirect if (realm) sessionStorage.setItem("login_realm", realm); if (clientId) sessionStorage.setItem("login_client_id", clientId); // Générer et stocker un state CSRF const state = crypto.randomUUID(); sessionStorage.setItem("login_state", state); window.location.assign(keycloakUrl);

Étape 7 — Échange code → tokens (callback)

typescript
// Handler côté serveur (route /api/auth/callback) const realm = sessionStorage.getItem("login_realm"); const clientId = sessionStorage.getItem("login_client_id"); const verifier = sessionStorage.getItem("pkce_verifier"); const { code } = searchParams; // query param reçu de Keycloak const tokenResponse = await fetch( `${KEYCLOAK_URL}/realms/${realm}/protocol/openid-connect/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", client_id: clientId!, code: code, redirect_uri: REDIRECT_URI, // IDENTIQUE à celui du start-login code_verifier: verifier!, }), } ); const { access_token, refresh_token, expires_in } = await tokenResponse.json();

redirect_uri doit être bit-à-bit identique à celui envoyé lors du start-login. Keycloak rejette 400 invalid_redirect_uri sinon.


Étape 8 — Enrichissement obligatoire

http
POST {WAKASTART_BACKEND_URL}/api/auth/enrich Authorization: Bearer {access_token}

Réponse :

json
{ "token": "eyJhbGciOiJIUzI1NiJ9...", "payload": { "wadl": "CustomerAdmin", "pid": "PTR001", "nid": "NET001", "cid": "ACM001", "wakaRoles": ["CONFIG", "EXPLOIT"], "hdsRoles": [], "wgrl": "rw" } }

Claims du wakaToken (HS256) :

ClaimTypeDescription
wadlstringAdmin Level (WAKA_SUPER_ADMIN, PARTNER_ADMIN, NETWORK_ADMIN, CUSTOMER_ADMIN, USER, NONE)
pidstringPartner WID
nidstringNetwork WID
cidstringCustomer WID
wakaRolesstring[]Rôles métier (CONFIG, EXPLOIT, DPO, AUDIT, BILLING, CYBER)
hdsRolesstring[]Rôles HDS (HDS_ADMIN, HDS_PATIENT, etc.)
wgrlstringTeam rights encodés

Sans wakaToken : les services répondent 401 Token enrichi manquant ou invalide. Ce token est obligatoire pour tout appel API post-auth.


Étape 9 — Stockage des cookies

typescript
// Côté serveur Next.js (route handler ou middleware) import { cookies } from "next/headers"; const cookieStore = await cookies(); // access_token Keycloak RS256 cookieStore.set("keycloak_token", access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: expires_in, // secondes path: "/", }); // wakaToken HS256 cookieStore.set("wakastart_token", wakaToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: expires_in, path: "/", }); // refresh_token cookieStore.set("refresh_token", refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 30 * 24 * 60 * 60, // 30 jours path: "/", });

Rafraîchissement proactif

http
POST /api/auth/token/refresh Content-Type: application/json { "refreshToken": "xxx", "accessToken": "xxx" }

Réponse : nouveaux access_token, refresh_token, enriched_token.

Stratégie recommandée :

typescript
// Vérifier exp du JWT avant chaque requête API sensible function isTokenExpiringSoon(token: string, thresholdSeconds = 60): boolean { try { const payload = JSON.parse(atob(token.split(".")[1])); return (payload.exp - Math.floor(Date.now() / 1000)) < thresholdSeconds; } catch { return true; } } // Rafraîchir proactivement — pas après un 401 if (isTokenExpiringSoon(accessToken)) { await refreshTokens(); }

Ne jamais boucler sur le refresh après un 401 : maximum 1 tentative, puis redirect vers /login.


Logout

http
POST /api/auth/logout Content-Type: application/json { "refreshToken": "xxx" }

Invalide le refresh token côté Keycloak. Complétez côté frontend :

typescript
// Supprimer les cookies HttpOnly (côté serveur) cookieStore.delete("keycloak_token"); cookieStore.delete("wakastart_token"); cookieStore.delete("refresh_token"); // Vider le sessionStorage sessionStorage.removeItem("login_realm"); sessionStorage.removeItem("login_client_id"); sessionStorage.removeItem("pkce_verifier"); // Rediriger window.location.assign("/login");

Bonnes pratiques

  • Toujours générer un nouveau code_verifier à chaque tentative de login.
  • Vérifier le state CSRF au retour du callback (comparer sessionStorage vs query param).
  • Stocker les tokens uniquement en cookies HttpOnly — jamais dans localStorage ou sessionStorage.
  • Implémenter un proxy serveur pour les appels API : le browser ne lit jamais les tokens.
  • Logger les erreurs d'auth sans inclure le Bearer token (Authorization: Bearer [REDACTED]).

Pièges classiques

  • Boucle de refresh infinie : sur 401, tenter le refresh une seule fois puis redirect login.
  • redirect_uri avec trailing slash : https://app.test/callbackhttps://app.test/callback/.
  • client_id fallback : si sessionStorage.getItem("login_client_id") retourne null, ne pas utiliser un fallback hardcodé.
  • Cookie sameSite: strict : bloque la réception du cookie au retour du redirect Keycloak. Utiliser sameSite: lax.

Aller plus loin