Authentication — PKCE Implementation
Complete PKCE flow code: generation, start-login, exchange, enrichment, cookies and logout.
Authentication — PKCE Implementation
This chapter covers the complete TypeScript code for each step of the flow. Read Authentication — Concepts & Flow first.
Step 2 — PKCE generation (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);
Step 3 — Calling start-login
httpPOST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "host": "app.test", "redirectUri": "https://app.test.wakastart.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<BASE64URL(SHA256(verifier))>", "codeChallengeMethod": "S256" }
200 response:
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" }
Errors:
| Status | Body | Cause |
|---|---|---|
400 | invalid_request | email + host supplied together, or neither |
400 | invalid_request | Missing or malformed body |
404 | not_found | No Network matches the host |
429 | rate limit | 5 req/60s · 20 req/600s (10 min) · 100 req/3600s (1h) per IP |
Step 4 — Extraction and storage
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); window.location.assign(keycloakUrl);
Step 7 — Exchanging the code for 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_urimust be byte-for-byte identical to the one sent during start-login. Otherwise Keycloak rejects it with400 invalid_redirect_uri.
Step 8 — Mandatory enrichment
httpPOST {WAKASTART_BACKEND_URL}/api/auth/enrich Authorization: Bearer {access_token}
Response:
json{ "token": "eyJhbGciOiJIUzI1NiJ9...", "payload": { "adminLevel": "CustomerAdmin", "partnerWid": "PTR001", "networkWid": "NET001", "customerWid": "ACM001", "roles": ["users.read", "billing.write"], "keycloakId": "kc-550e8400-e29b-41d4-a716-446655440000", "orgId": "acme-prod", "azp": "7OWWAA" } }
HDS roles (
hdsRoles), team rights (wgrl) andfeatures(wftr) are no longer in this payload — available only viaGET /me.
wakaToken claims:
| Claim | Type | Description |
|---|---|---|
adminLevel | string | Admin Level, platform level (WAKA_SUPER_ADMIN, PARTNER_ADMIN, NETWORK_ADMIN, CUSTOMER_ADMIN, USER, NONE) |
partnerWid | string | Partner WID |
networkWid | string | Network WID |
customerWid | string | Customer WID |
roles | string[] | Application rights resource.action (e.g. users.read, billing.write), derived from the user's profileRights — not a fixed enum of cross-cutting roles (the wakaRoles concept has been decommissioned) |
keycloakId | string | Keycloak UUID (JWT sub) |
orgId | string | Keycloak organization |
azp | string | Authorized party (client_id) |
The
userLevel(wusl) claim is no longer emitted in the wakaToken — ws-serv-token doesn't even select it in its enrichment query anymore. Do not rely on it, including in existing code that might still read it.
Without a wakaToken: services respond with
401 Missing or invalid enriched token. This token is mandatory for every post-auth API call.
Step 9 — Storing the 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 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: "/", });
Proactive refresh
httpPOST /api/auth/token/refresh Content-Type: application/json { "refreshToken": "xxx", "accessToken": "xxx" }
Response: new access_token, refresh_token, enriched_token.
Recommended strategy:
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(); }
Never loop on refresh after a 401: a maximum of 1 attempt, then redirect to
/login.
Logout
httpPOST /api/auth/logout Content-Type: application/json { "refreshToken": "xxx" }
Invalidates the refresh token on the Keycloak side. Complete it on the frontend side:
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");
Best practices
- Always generate a new
code_verifierfor every login attempt. - Verify the CSRF
stateon return from the callback (compare sessionStorage vs. query param). - Store tokens only in HttpOnly cookies — never in localStorage or sessionStorage.
- Implement a server-side proxy for API calls: the browser never reads the tokens.
- Log auth errors without including the Bearer token (
Authorization: Bearer [REDACTED]).
Common pitfalls
- Infinite refresh loop: on
401, attempt the refresh only once, then redirect to login. redirect_uriwith a trailing slash:https://app.test/callback≠https://app.test/callback/.client_idfallback: ifsessionStorage.getItem("login_client_id")returnsnull, do not fall back to a hardcoded value.sameSite: strictcookie: blocks the cookie from being received when returning from the Keycloak redirect. UsesameSite: lax.
Learn more
- Discovery:
start-loginand extracting thekeycloakUrl - User context /me: post-auth step
- Common pitfalls: diagnosing authentication errors