Keycloak configurato, realm creato, client registrato. Il passo successivo è collegare concretamente un frontend React e un backend Express all’Identity Provider. L’[articolo introduttivo]({{< ref “/posts/keycloak/01-keycloak-intro” >}}) copre il concetto di delegare l’autenticazione. Qui si implementa il pezzo pratico.
L’implementazione usa Authorization Code Flow con PKCE in MockMart, un e-commerce demo. Il risultato: l’utente clicca “Login”, viene reindirizzato a Keycloak, inserisce le credenziali, e torna nell’app con un token JWT che il backend valida ad ogni richiesta. L’applicazione non tocca mai le password.
Obiettivo
Al termine di questo tutorial:
- Keycloak avrà un realm
techstorecon un client public configurato per PKCE - Il frontend React (
shop-ui) reindirizzerà a Keycloak per il login - Il backend Express (
shop-api) validerà i token JWT ricevuti - L’utente potrà fare login, vedere i prodotti e completare il checkout
Prerequisiti
- Docker e Docker Compose installati
- MockMart clonato in locale
- Keycloak avviato (vedi [articolo introduttivo]({{< ref “/posts/keycloak/01-keycloak-intro” >}}))
Come funziona il login con PKCE
Prima di toccare la configurazione, vediamo cosa succede quando l’utente clicca “Login”:
- Il frontend genera un
code_verifierrandom e ne calcola l’hash (code_challenge) - Il browser viene reindirizzato alla pagina di login di Keycloak, con il
code_challengenella query string - L’utente inserisce le credenziali su Keycloak (non sull’app)
- Keycloak reindirizza il browser all’app con un
codetemporaneo - Il frontend scambia il
code+code_verifiercon Keycloak e riceve i token (access, id, refresh) - Il frontend usa l’access token per chiamare le API del backend
Il code_verifier è la parte PKCE: garantisce che solo chi ha iniziato il flusso possa completarlo. Se un attaccante intercetta il code, non può scambiarlo senza il verifier originale.
Perché PKCE e non un client secret? Una SPA gira nel browser: qualsiasi secret nel codice JavaScript è leggibile da chiunque apra i DevTools. PKCE risolve il problema con un segreto generato per ogni singolo login e mai esposto nell’URL.
Configurazione Keycloak
MockMart usa un realm export che configura tutto automaticamente. Ma per capire cosa succede sotto, vediamo i passi manuali.
Creare il realm
Dalla console admin (http://localhost:8080):
- Passa il mouse sul realm corrente (in alto a sinistra) e clicca Create Realm
- Inserisci
techstorecome Realm name - Clicca Create
Il realm è il contenitore isolato di tutta la configurazione: utenti, client, ruoli. Ogni progetto ne ha uno.
Creare il client public
In Clients → Create client:
| Campo | Valore |
|---|---|
| Client ID | shop-ui |
| Client authentication | OFF (client public) |
| Standard flow | ON |
| Direct access grants | OFF |
Nella scheda Settings, configura gli URL:
| Campo | Valore |
|---|---|
| Valid redirect URIs | http://localhost:3000/* |
| Valid post logout redirect URIs | http://localhost:3000/* |
| Web origins | http://localhost:3000 |
Nella scheda Advanced, sotto Proof Key for Code Exchange:
| Campo | Valore |
|---|---|
| Code Challenge Method | S256 |
S256indica che ilcode_challengeè un hash SHA-256 delcode_verifier. È l’unico metodo raccomandato: il metodoplain(che invia il verifier in chiaro) non offre protezione reale.
Creare i ruoli
In Realm roles → Create role, crea due ruoli:
user: utente base, può navigare e fare checkoutadmin: può anche gestire prodotti e vedere tutti gli ordini
Creare utenti di test
In Users → Add user, crea due utenti:
Utente: mario
| Campo | Valore |
|---|---|
| Username | mario |
mario@techstore.local | |
| Email verified | ON |
Nella scheda Credentials, imposta la password mario123 con Temporary = OFF.
Nella scheda Role mapping, assegna il ruolo user.
Utente: admin
| Campo | Valore |
|---|---|
| Username | admin |
admin@techstore.local | |
| Email verified | ON |
Password admin123, ruoli user e admin.
Verificare con la CLI
Una volta configurato, è possibile verificare che Keycloak risponda correttamente:
# OIDC Discovery: elenca tutti gli endpoint disponibili
curl -s http://localhost:8080/realms/techstore/.well-known/openid-configuration | jq '.authorization_endpoint, .token_endpoint'
Output atteso:
"http://localhost:8080/realms/techstore/protocol/openid-connect/auth"
"http://localhost:8080/realms/techstore/protocol/openid-connect/token"
Integrazione Frontend (React + keycloak-js)
Il frontend usa keycloak-js, l’adapter JavaScript ufficiale. Gestisce automaticamente il redirect, lo scambio dei token e il refresh.
Inizializzazione
// AuthContext.jsx
import Keycloak from 'keycloak-js';
const KEYCLOAK_URL = import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
const KEYCLOAK_REALM = import.meta.env.VITE_KEYCLOAK_REALM || 'techstore';
const KEYCLOAK_CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'shop-ui';
const kc = new Keycloak({
url: KEYCLOAK_URL,
realm: KEYCLOAK_REALM,
clientId: KEYCLOAK_CLIENT_ID,
});
L’URL di Keycloak è configurabile via variabile d’ambiente. In produzione sarà diverso da localhost: hardcodarlo è uno degli errori più comuni (vedi sezione errori).
Init con PKCE
async function initKeycloak() {
const authenticated = await kc.init({
onLoad: 'check-sso',
pkceMethod: 'S256',
checkLoginIframe: false,
});
if (authenticated) {
// L'utente ha già una sessione Keycloak attiva
const tokenParsed = kc.tokenParsed;
setUser({
id: tokenParsed.sub,
username: tokenParsed.preferred_username,
email: tokenParsed.email,
roles: tokenParsed.realm_access?.roles || [],
});
}
}
Tre parametri chiave:
onLoad: 'check-sso'- controlla se l’utente ha già una sessione Keycloak attiva (SSO), senza forzare il loginpkceMethod: 'S256'- abilita PKCE con hash SHA-256. Dalla versione 24+ dikeycloak-js, PKCE con S256 è il default: il parametro è ridondante ma lo rendiamo esplicito per chiarezzacheckLoginIframe: false- disabilita l’iframe di verifica sessione. Sulocalhostè necessario per evitare problemi di cookie cross-origin. In produzione è consigliabile riabilitarlo
Login e Logout
function login() {
kc.login();
// Il browser viene reindirizzato a Keycloak
// Dopo il login, Keycloak reindirizza alla redirect URI
// keycloak-js gestisce automaticamente lo scambio code → token
}
function logout() {
kc.logout({
redirectUri: window.location.origin,
});
// Termina sia la sessione locale che quella su Keycloak (SSO logout)
}
Token nelle chiamate API
Ogni richiesta al backend deve includere l’access token nell’header Authorization:
async function apiFetch(url, options = {}) {
// Aggiorna il token se sta per scadere (margine di 30 secondi)
await kc.updateToken(30);
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${kc.token}`,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
},
});
}
kc.updateToken(30) è il refresh proattivo: se il token scade entro 30 secondi, keycloak-js lo rinnova automaticamente usando il refresh token. Il rinnovo avviene in modo trasparente.
Integrazione Backend (Express + jose)
Il backend non partecipa al login. Riceve l’access token dal frontend e lo valida ad ogni richiesta.
Validazione JWT con JWKS
// middleware/auth.js
const { createRemoteJWKSet, jwtVerify } = require('jose');
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://keycloak:8080';
const KEYCLOAK_PUBLIC_URL = process.env.KEYCLOAK_PUBLIC_URL || 'http://localhost:8080';
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'techstore';
const ISSUER = `${KEYCLOAK_PUBLIC_URL}/realms/${KEYCLOAK_REALM}`;
const JWKS_URL = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`;
// jose scarica e cacha automaticamente le chiavi pubbliche
let jwks = null;
function getJWKS() {
if (!jwks) {
jwks = createRemoteJWKSet(new URL(JWKS_URL));
}
return jwks;
}
Due URL diversi per Keycloak, ed è intenzionale:
KEYCLOAK_URL(http://keycloak:8080) - l’URL interno via Docker DNS, usato dal backend per scaricare le chiavi JWKSKEYCLOAK_PUBLIC_URL(http://localhost:8080) - l’URL che il browser raggiunge, e che Keycloak inserisce comeissnel token
Se i due non corrispondono, la validazione dell’issuer fallisce con un 401 inspiegabile. È il problema più comune in staging e produzione.
Il middleware
async function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const { payload } = await jwtVerify(token, getJWKS(), {
issuer: ISSUER,
audience: 'shop-api', // Richiede un audience mapper in Keycloak (vedi nota sotto)
clockTolerance: 30, // Dev: 30s per comodità. In produzione usare 5-10s
});
req.user = {
id: payload.sub,
username: payload.preferred_username,
email: payload.email,
roles: payload.realm_access?.roles || [],
};
next();
} catch (error) {
if (error.code === 'ERR_JWT_EXPIRED') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
jwtVerify della libreria jose fa tre cose:
- Verifica la firma - scarica la chiave pubblica dall’endpoint JWKS di Keycloak e controlla che il token sia stato firmato con la chiave privata corrispondente
- Controlla l’issuer - il claim
issdeve corrispondere all’URL di Keycloak atteso - Controlla la scadenza - il claim
expnon deve essere nel passato (con 30 secondi di tolleranza)
Se una qualsiasi di queste verifiche fallisce, il token viene rifiutato.
Audience validation: La validazione del claim
audiencenon è opzionale. Senza di essa, un token emesso per un altro servizio potrebbe essere accettato dal backend (token confusion attack). Keycloak di default impostaauda"account": per aggiungere l’audience della propria API, configurare un Audience mapper nel client (Client → Mappers → Add mapper → Audience, con valoreshop-api).
Proteggere le route
// Route protette: richiedono un token valido
app.get('/api/products', requireAuth, getProducts);
app.post('/api/checkout', requireAuth, handleCheckout);
app.get('/api/orders', requireAuth, getOrders);
// Route pubblica: nessun token necessario
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
Test End-to-End
Con MockMart avviato (make up), il flusso completo:
1. Avviare lo stack
cd MockMart
make up
| Servizio | URL |
|---|---|
| Shop UI (frontend) | http://localhost:3000 |
| Shop API (backend) | http://localhost:3001 |
| Keycloak | http://localhost:8080 |
2. Login dall’interfaccia
- Apri
http://localhost:3000 - Clicca Login → il browser viene reindirizzato a Keycloak
- Inserisci
mario/mario123 - Dopo il redirect, l’utente è autenticato nell’app
3. Verificare il token
Apri i DevTools del browser (F12 → Network). Nelle chiamate a /api/products, osserva l’header:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
È possibile decodificare il payload del token (la parte tra i due punti) su jwt.io o da terminale:
# Decodifica il payload del JWT (seconda parte, separata da '.')
echo "eyJhbGci..." | cut -d'.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq
Output (semplificato):
{
"iss": "http://localhost:8080/realms/techstore",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"preferred_username": "mario",
"email": "mario@techstore.local",
"realm_access": {
"roles": ["user"]
},
"exp": 1708000000
}
4. Verificare PKCE con curl
Per vedere il flusso PKCE passo per passo, è possibile simularlo da terminale.
Generare code_verifier e code_challenge:
# Genera un code_verifier random (43-128 caratteri)
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=/+' | head -c 128)
# Calcola il code_challenge (SHA-256 del verifier, codificato base64url)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
echo "code_verifier: $CODE_VERIFIER"
echo "code_challenge: $CODE_CHALLENGE"
Authorization request (questo normalmente lo fa il browser via redirect):
http://localhost:8080/realms/techstore/protocol/openid-connect/auth?
response_type=code
&client_id=shop-ui
&redirect_uri=http://localhost:3000/
&scope=openid profile email
&code_challenge_method=S256
&code_challenge=<CODE_CHALLENGE>
&state=random123
Dopo il login, Keycloak reindirizza a:
http://localhost:3000/?code=SplxlOBeZQQ...&state=random123
Token request (scambio code → token, con il verifier originale):
curl -s -X POST \
'http://localhost:8080/realms/techstore/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=authorization_code" \
-d "client_id=shop-ui" \
-d "code=<CODE_RICEVUTO>" \
-d "redirect_uri=http://localhost:3000/" \
-d "code_verifier=$CODE_VERIFIER" | jq
Se il code_verifier corrisponde al code_challenge inviato nella authorization request, Keycloak restituisce i token. Altrimenti: errore.
Dove si rompe
Redirect URI mismatch
error: invalid_redirect_uri
error_description: Invalid redirect uri
L’URL a cui Keycloak deve reindirizzare dopo il login non corrisponde a quelli registrati nel client. In Keycloak, Valid redirect URIs è un filtro esatto (con supporto wildcard *).
Fix: La redirect_uri nella richiesta deve corrispondere esattamente a una di quelle configurate nel client. Su localhost, http://localhost:3000/* copre tutte le route. In produzione, è preferibile evitare i wildcard e registrare gli URI esatti per prevenire attacchi di open redirect.
Issuer mismatch (401 inspiegabile)
JWT validation failed: unexpected "iss" claim value
Il token contiene iss: http://localhost:8080/realms/techstore, ma il backend sta validando contro http://keycloak:8080/realms/techstore. Succede quando KEYCLOAK_PUBLIC_URL non è configurato o punta all’URL Docker interno.
Fix: KEYCLOAK_PUBLIC_URL deve corrispondere all’URL che il browser usa per raggiungere Keycloak. Sono due URL diversi per design: uno per la rete Docker (JWKS), uno per il browser (issuer).
CORS bloccato
Access to fetch at 'http://localhost:8080/...' has been blocked by CORS policy
Il browser blocca richieste cross-origin se Keycloak non include l’header Access-Control-Allow-Origin. Succede quando Web origins nel client non è configurato.
Fix: In Keycloak → Client → Settings → Web origins, aggiungere http://localhost:3000. In alternativa, + usa automaticamente tutte le redirect URI registrate.
Token scaduto ma nessun refresh
L’utente naviga normalmente, poi improvvisamente riceve 401 su tutte le chiamate. Il token è scaduto (default: 5 minuti) e il frontend non ha rinnovato.
Fix: Chiamare kc.updateToken(30) prima di ogni richiesta API. Con un wrapper come apiFetch (visto sopra), il refresh è automatico. Se il refresh token è scaduto (default: 30 minuti di inattività tramite SSO Session Idle, o 10 ore in totale tramite SSO Session Max), l’utente deve ri-loggarsi. Entrambi i valori sono configurabili nel realm.
PKCE challenge fallito
{
"error": "invalid_grant",
"error_description": "PKCE verification failed"
}
Il code_verifier inviato nel token request non corrisponde al code_challenge della authorization request. Può succedere se la libreria non gestisce correttamente la persistenza del verifier tra i due step (es. dopo un refresh della pagina durante il login).
Fix: keycloak-js gestisce PKCE automaticamente. Per implementazioni manuali, il code_verifier va salvato in sessionStorage (non localStorage) e riletto nel callback.
Cosa contiene il JWT
Il JWT che il frontend riceve è quello che il backend usa per tutte le decisioni. Ecco cosa contiene.
Access Token
| Claim | Descrizione | Esempio |
|---|---|---|
iss | Chi ha emesso il token (Keycloak) | http://localhost:8080/realms/techstore |
sub | ID univoco dell’utente | f47ac10b-58cc-... |
aud | Destinatario del token | account |
exp | Scadenza (Unix timestamp) | 1708000300 |
azp | Client che ha richiesto il token | shop-ui |
realm_access.roles | Ruoli dell’utente nel realm | ["user"] |
preferred_username | Username leggibile | mario |
ID Token vs Access Token
Due token con scopi diversi:
| ID Token | Access Token | |
|---|---|---|
| Destinatario | Il frontend (chi ha fatto login) | Il backend (chi protegge le risorse) |
| Contiene | Identità dell’utente | Autorizzazioni concesse |
| Uso | Mostrare “Ciao, Mario” nell’UI | Validare le richieste API |
| Inviarlo al backend? | No | Sì, nell’header Authorization |
keycloak-jsespone entrambi:kc.token(access token) ekc.idToken(ID token). Per le chiamate API, usa sempre l’access token.
Conclusione
Il flusso di autenticazione coperto in questo articolo:
- Keycloak gestisce realm, client, utenti e ruoli
- React + keycloak-js gestisce il redirect, PKCE, e il refresh dei token
- Express + jose valida i JWT ad ogni richiesta, usando le chiavi pubbliche JWKS
Il codice dell’applicazione non tocca mai le credenziali. Il frontend non ha secret. Il backend non ha un database utenti. Tutto è delegato a Keycloak.
Nei prossimi articoli vedremo come autenticare servizi tra loro senza utente ([Client Credentials]({{< ref “/posts/keycloak/03-keycloak-m2m” >}})) e i problemi reali che emergono quando l’integrazione incontra la produzione ([6 Problemi Reali]({{< ref “/posts/keycloak/04-keycloak-e2e” >}})).