Authentification — OAuth2 PKCE
Implémenter le flow OAuth2 PKCE server-side avec Discovery : start-login, callback, exchange et enrich.
Authentification — OAuth2 PKCE (server-side resolution)
Depuis IAM-S0 / IAM-C4 (mai 2026) — Le realm Keycloak n'est plus construit côté frontend. Discovery expose un endpoint public
POST /discover/start-loginqui résout le realm + le client_id à partir du host et retourne une URL Keycloak prête à suivre. Aucune Wakapp ne détientINTERNAL_API_SECRET.
Pourquoi cette étape
L'authentification est le flux le plus critique de votre intégration. Un flux mal implémenté expose à des vulnérabilités (code interception, CSRF, token leakage). Le PKCE (Proof Key for Code Exchange) est obligatoire et garantit qu'un code OIDC volé en transit est inutilisable sans le code_verifier connu uniquement du client légitime.
Le flow Wakastart ajoute deux étapes propres à la plateforme : le start-login (Discovery résout le realm) et l'enrich (le BFF enrichit le JWT Keycloak avec les rôles métier).
Concepts clés
- code_verifier : chaîne aléatoire 43-128 chars, générée côté client, stockée en sessionStorage.
- code_challenge :
BASE64URL(SHA256(code_verifier)), envoyé à Keycloak. - access_token : JWT RS256 émis par Keycloak, durée de vie courte (5-15 min).
- refresh_token : token longue durée pour obtenir de nouveaux access_token.
- wakaToken : JWT HS256 émis par ws-serv-token, contient les droits métier. Obligatoire pour appeler les services downstream.
- enrichment : étape propriétaire qui transforme le JWT Keycloak basique en wakaToken enrichi.
Flow complet en 10 étapes
textWakapp frontend Wakapp backend Discovery (public) Keycloak ─────────────── ────────────── ────────────────── ──────── 1. User arrive sur l'app 2. Génère PKCE : code_verifier = random(43) code_challenge = B64URL(SHA256(verifier)) 3. POST /api/auth/start-login ──────────────► POST /discover/start-login ──────────► { host, redirectUri, state, codeChallenge, method="S256" } ◄── { keycloakUrl } 4. Extraire realm + client_id depuis keycloakUrl → sessionStorage["login_realm"] = realm → sessionStorage["login_client_id"] = client_id → sessionStorage["pkce_verifier"] = code_verifier 5. window.location.assign(keycloakUrl) ────────────► 6. Keycloak: login form ◄──────────── → redirect vers /api/auth/callback?code=xxx&state=xxx 7. POST keycloak/realms/{realm}/token ──────────────────────────────────► grant_type=authorization_code client_id=<from sessionStorage> code=<reçu en query param> redirect_uri=<même qu'au start-login> code_verifier=<from sessionStorage> ◄── { access_token (RS256), refresh_token, expires_in } 8. POST {BFF}/api/auth/enrich ──────────────► Authorization: Bearer <access_token> ◄── { token: <wakaToken HS256>, payload: { adminLevel, wakaRoles, ... } } 9. Set cookies HttpOnly : keycloak_token = access_token (RS256) wakastart_token = wakaToken (HS256 enrichi) refresh_token = refresh_token 10. GET /api/me → profil complet → naviguer vers le dashboard
É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
httpPOST {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 :
| Status | Body | Cause |
|---|---|---|
400 | invalid_request | email + host simultanés, ou aucun |
400 | invalid_request | Body manquant ou malformé |
404 | not_found | Aucun Network ne correspond au host |
429 | rate limit | 5 req/s · 20/10s · 100/60s par IP |
Étape 4 — Extraction et stockage
typescriptconst { 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); // Remplacer le state dans l'URL si nécessaire // (la keycloakUrl contient déjà le state passé en step 3) 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_uridoit être bit-à-bit identique à celui envoyé lors du start-login. Keycloak rejette400 invalid_redirect_urisinon.
Étape 8 — Enrichissement obligatoire
httpPOST {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) :
| Claim | Type | Description |
|---|---|---|
wadl | string | Admin Level (WAKA_SUPER_ADMIN, PARTNER_ADMIN, NETWORK_ADMIN, CUSTOMER_ADMIN, USER, NONE) |
pid | string | Partner WID |
nid | string | Network WID |
cid | string | Customer WID |
wakaRoles | string[] | Rôles métier (CONFIG, EXPLOIT, DPO, AUDIT, BILLING, CYBER) |
hdsRoles | string[] | Rôles HDS (HDS_ADMIN, HDS_PATIENT, etc.) |
wgrl | string | Team 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
httpPOST /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
httpPOST /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");
Pré-requis Keycloak — Client OIDC
Pour que le flow fonctionne, le client OIDC doit être configuré ainsi :
| Paramètre | Valeur | Raison |
|---|---|---|
| Client ID | Valeur de apps.client_id en DB (auto-généré du WID) | Discovery résout ce client_id |
| Client authentication | OFF (public client) | PKCE sans secret — obligatoire |
| Authorization | OFF | Non nécessaire |
| Standard flow | ON | Flow OAuth2 PKCE |
| Direct access grants | OFF (recommandé) | Éviter le Resource Owner Password Credentials |
| Implicit flow | OFF | Déprécié, non sécurisé |
| PKCE Method | S256 | Obligatoire |
| Valid redirect URIs | https://app.<tenant>.<platform>/* | Exact match Keycloak |
| Web origins | https://app.<tenant>.<platform> | CORS pre-flight |
Client authentication: ONcauseInvalid client or Invalid client credentialsau token exchange — Keycloak attend unclient_secretque PKCE public ne fournit pas. C'est la première chose à vérifier en cas de 401 au callback.
Organizations Keycloak (IAM-C4 — obligatoire)
ws-serv-token rejette tout token Keycloak sans le claim organization. Trois conditions doivent être remplies :
1. Realm — feature Organizations activée
Console Keycloak → realm → Realm settings → General → "Organizations enabled"
Cela crée automatiquement un client scope organization au niveau realm.
2. Client OIDC — scope organization attaché comme default
Console Keycloak → realm → Clients → {client_id} → Client scopes → ajouter "organization" en Default
Automatisé par npx prisma db seed (ws-serv-config).
3. User — membre d'une organisation Keycloak
Pour chaque Customer en DB, une organisation Keycloak existe (alias = customer.wid). Les users actifs doivent y être membres.
Automatisé par npx ts-node prisma/backfill-keycloak-orgs.ts (idempotent).
Sans ces 3 conditions : enrichment retourne 401 et l'utilisateur voit une erreur "Token enrichi manquant ou invalide".
Gestion d'erreurs au callback
| Cas | Symptôme | Solution |
|---|---|---|
redirect_uri mismatch | 400 Invalid redirect_uri Keycloak | Vérifier exact match URI dans la console Keycloak |
client_id incorrect | 401 Invalid client | Extraire client_id depuis la keycloakUrl, ne pas hardcoder |
code_verifier incorrect | 400 invalid_grant | Vérifier cohérence verifier/challenge (ne pas re-encoder) |
organization absent | 401 Token enrichi manquant | Activer Organizations + scope sur le client (voir ci-dessus) |
| Enrich échoue | wakastart_token absent des cookies | Logger la réponse HTTP de /api/auth/enrich |
Bonnes pratiques
- Toujours générer un nouveau
code_verifierà chaque tentative de login. - Vérifier le
stateCSRF 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]). - Implémenter une page d'erreur dédiée pour les états d'échec d'auth (lien expiré, erreur Keycloak, etc.).
Pièges classiques
- Boucle de refresh infinie : sur
401, tenter le refresh une seule fois puis redirect login. redirect_uriavec trailing slash :https://app.test/callback≠https://app.test/callback/.client_idfallback : sisessionStorage.getItem("login_client_id")retournenull, ne pas utiliser un fallback hardcodé — cela échouera avecInvalid client.- Cookie
sameSite: strict: bloque la réception du cookie au retour du redirect Keycloak (cross-site). UtilisersameSite: lax.
Aller plus loin
- Discovery :
start-loginet extraction dekeycloakUrl - Contexte utilisateur /me : étape post-auth
- Pièges classiques : diagnostic des erreurs d'authentification