Job schedulati, webhook, eventi asincroni: in questi casi non c’è nessun utente davanti allo schermo, ma i servizi devono comunque autenticarsi tra loro. La comunicazione machine-to-machine richiede un meccanismo di autenticazione diverso da quello basato su browser e redirect.
L’approccio standard è il flusso Client Credentials di OAuth 2.0. Questa guida copre il setup in Keycloak, l’implementazione lato chiamante e lato ricevente, e gli errori più comuni.
Il Problema: Servizi che Parlano Senza Utente
Lo Scenario
In MockMart, quando un utente completa un ordine, shop-api deve notificare notification-service:
shop-api (checkout) ──────► notification-service (send email)
Il problema: questa chiamata avviene dopo che il checkout è completato. Non c’è un utente “attivo” in quel momento - è una chiamata server-to-server.
Soluzioni Sbagliate
Hardcodare API key nei servizi:
// NON FARE QUESTO
headers: { 'X-API-Key': 'super-secret-key-123' }
Problemi: se una chiave viene compromessa, va cambiata ovunque. Nessuna scadenza, nessun audit.
Passare il token dell’utente:
// NON FARE QUESTO
headers: { 'Authorization': `Bearer ${userToken}` }
Problemi: il token scade (tipicamente 5 minuti), ha permessi dell’utente (non del servizio), e non funziona per job schedulati.
Nessuna autenticazione (“tanto è rete interna”):
// NON FARE QUESTO
await fetch('http://notification-service/send', { body: data });
Problemi: qualsiasi servizio compromesso può chiamare qualsiasi altro servizio. Zero tracciabilità.
La Domanda
Come fa shop-api a dimostrare la propria identità a notification-service, senza coinvolgere un utente?
La Soluzione: Client Credentials Flow
Il flusso Client Credentials permette a un servizio di autenticarsi come “se stesso”, non per conto di un utente.
Come Funziona
shop-api ─── (1) credentials ───► Keycloak
shop-api ◄── (2) access_token ─── Keycloak
shop-api ─── (3) Bearer token ───► notification-service
notification-service ─── (4) validate JWKS ───► Keycloak
shop-apiinvia le proprie credenziali (client_id + secret) a Keycloak- Keycloak verifica e rilascia un access token
shop-apiusa il token per chiamarenotification-servicenotification-servicevalida il token via JWKS (chiavi pubbliche di Keycloak)
Differenze con Authorization Code
| Authorization Code | Client Credentials | |
|---|---|---|
| Chi si autentica | Utente (tramite browser) | Servizio (backend) |
| Richiede browser | Sì | No |
| Refresh token | Sì | No (richiedi nuovo token) |
| Uso tipico | Login frontend | M2M, job, webhook |
Setup Keycloak: Service Account
Per usare Client Credentials, serve un client Keycloak con service account abilitato.
Configurazione Client
In Keycloak Admin Console → Clients → Create:
{
"clientId": "shop-api",
"secret": "shop-api-secret",
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false
}
Punti chiave:
serviceAccountsEnabled: true- abilita il flusso Client CredentialsstandardFlowEnabled: false- disabilita Authorization Code (non serve per un servizio)directAccessGrantsEnabled: false- disabilita Resource Owner Password (deprecato)
Test: Ottenere un Token
curl -X POST "http://localhost:8080/auth/realms/techstore/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=shop-api" \
-d "client_secret=shop-api-secret"
Nota: A partire da Keycloak 17+ (distribuzione Quarkus, ora l’unica supportata), il prefisso
/authè stato rimosso dal context path di default. Se usi una versione recente, l’URL diventahttp://localhost:8080/realms/techstore/protocol/openid-connect/token. Nel codice di MockMart, la variabileKEYCLOAK_AUTH_PATHpermette di gestire entrambi i casi.
Risposta:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 300,
"token_type": "Bearer"
}
Il token ha vita breve (default 5 minuti). Questo è intenzionale: riduce la finestra di rischio se viene compromesso.
Implementazione nel Codice
Lato Chiamante: shop-api
Il servizio che deve chiamare altri servizi ha bisogno di:
- Ottenere un token da Keycloak
- Cacharlo per evitare richieste inutili
- Rinnovarlo prima della scadenza
// lib/service-token.js
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://keycloak:8080';
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'techstore';
const KEYCLOAK_AUTH_PATH = process.env.KEYCLOAK_AUTH_PATH || '/auth';
const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || 'shop-api';
const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET;
const TOKEN_ENDPOINT = `${KEYCLOAK_URL}${KEYCLOAK_AUTH_PATH}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
// Token cache
let cachedToken = null;
let tokenExpiry = null;
let pendingRequest = null;
const EXPIRY_BUFFER_SECONDS = 60; // Rinnova 60s prima della scadenza
async function getServiceToken() {
const now = Date.now();
// Riusa token se ancora valido
if (cachedToken && tokenExpiry && now < tokenExpiry) {
return cachedToken;
}
// Se c'è già una richiesta in corso, aspetta quella
// (evita N chiamate parallele al token endpoint)
if (pendingRequest) {
return pendingRequest;
}
// Prima richiesta: chiama Keycloak e condividi la Promise
pendingRequest = fetchNewToken();
return pendingRequest;
}
async function fetchNewToken() {
try {
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
const data = await response.json();
// Cache con buffer di sicurezza
cachedToken = data.access_token;
const expiresIn = data.expires_in || 300;
tokenExpiry = Date.now() + (expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
return cachedToken;
} finally {
pendingRequest = null;
}
}
module.exports = { getServiceToken };
Uso:
const { getServiceToken } = require('./lib/service-token');
async function notifyOrder(orderId, userEmail) {
const token = await getServiceToken();
const response = await fetch(`${NOTIFICATION_URL}/api/notifications/order`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ orderId, userEmail })
});
return response.json();
}
Lato Ricevente: notification-service
Il servizio che riceve la chiamata deve:
- Validare il token JWT (firma, scadenza, issuer)
- Verificare che il chiamante sia un service account autorizzato
// 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 KEYCLOAK_AUTH_PATH = process.env.KEYCLOAK_AUTH_PATH || '/auth';
const ISSUER = `${KEYCLOAK_PUBLIC_URL}${KEYCLOAK_AUTH_PATH}/realms/${KEYCLOAK_REALM}`;
const JWKS_URL = `${KEYCLOAK_URL}${KEYCLOAK_AUTH_PATH}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`;
// Cache JWKS
let jwks = null;
function getJWKS() {
if (!jwks) {
jwks = createRemoteJWKSet(new URL(JWKS_URL));
}
return jwks;
}
async function requireServiceAuth(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,
clockTolerance: 30
});
// Verifica che sia un service account
// Il claim client_id (snake_case) è presente nei token di service account Keycloak
// Deve coincidere con azp (authorized party) per confermare che il token è emesso per quel client
const isServiceAccount = payload.client_id !== undefined
&& payload.client_id === payload.azp;
if (!isServiceAccount) {
return res.status(403).json({
error: 'This endpoint only accepts service account tokens'
});
}
// Verifica che sia il servizio autorizzato
if (payload.azp !== 'shop-api') {
return res.status(403).json({
error: 'Unauthorized service'
});
}
req.serviceAccount = payload;
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' });
}
}
module.exports = { requireServiceAuth };
Punto critico: Due check distinti proteggono l’endpoint. Il primo (isServiceAccount) verifica che il token provenga da un service account tramite il claim client_id (presente nei token Keycloak per service account). Il secondo (payload.azp !== 'shop-api') restringe l’accesso al solo servizio autorizzato. Senza entrambi, un token utente o di un altro servizio passerebbe la validazione.
Verso la produzione: In questo esempio l’
azpè hardcodato, ma in un sistema con molti servizi questo approccio diventa fragile. L’alternativa scalabile è assegnare client roles o scopes (es.notifications:send) al service account in Keycloak e verificarli nel middleware, invece di controllare il singolo client ID. In questo modo si disaccoppia l’autorizzazione dall’identità specifica del chiamante.
Errori Comuni
Errore 1: Token Scaduto, Nessun Retry
POST /api/notifications/order → 401 Unauthorized
Il token Client Credentials ha vita breve (default 5 minuti). Senza cache con rinnovo anticipato, le chiamate falliscono dopo la scadenza.
Fix: Il pattern expiresIn - EXPIRY_BUFFER_SECONDS rinnova il token 60 secondi prima della scadenza effettiva.
Errore 2: Validare Solo la Firma
// INSICURO
const { payload } = await jwtVerify(token, getJWKS());
// Accetta QUALSIASI token valido!
Un token utente rubato dal frontend passa questa validazione.
Fix: Verificare sempre azp (authorized party):
if (payload.azp !== 'shop-api') {
return res.status(403).json({ error: 'Unauthorized service' });
}
Errore 3: Secret nel Codice
// MAI FARE QUESTO
const CLIENT_SECRET = 'shop-api-secret';
Se il repo è pubblico (o viene compromesso), il secret è esposto.
Fix: Environment variable, mai nel repository:
# .env (aggiungi a .gitignore)
KEYCLOAK_CLIENT_SECRET=shop-api-secret
Debug con OpenTelemetry
Con MockMart puoi tracciare l’intero flusso M2M:
make up-otel-keycloak
In Grafana → Explore → Tempo vedrai:
shop-api keycloak notification
│ │ │
├── POST /token ────────────►│ │
│◄── 200 {access_token} ─────│ │
│ │ │
├── POST /api/notifications/order ─────────────────────►│
│ │◄── GET /certs (JWKS) ─────│
│◄── 200 OK ─────────────────┼───────────────────────────│
La trace mostra chi ha chiamato chi, con quale token, e quanto tempo ha impiegato ogni step.
Quando Usare Client Credentials
| Scenario | Client Credentials? |
|---|---|
| Job schedulati (cron, batch) | ✅ |
| Webhook in ingresso | ✅ |
| Eventi async (queue consumer) | ✅ |
| Servizio chiama servizio | ✅ |
| Utente loggato chiama API | ❌ usa il token utente |
Checklist
- Client Keycloak con
serviceAccountsEnabled: true - Secret in environment variable, mai nel codice
- Cache token con rinnovo anticipato e lock su richieste concorrenti (
pendingRequest) - Validare
clientId+ ruolo service-account eazpnel servizio ricevente, non solo la firma - Tracing abilitato per debug in produzione
Conclusione
Abbiamo visto come:
- Il flusso Client Credentials permette ai servizi di autenticarsi tra loro senza coinvolgere un utente
- La cache con rinnovo anticipato e deduplicazione delle richieste evita chiamate inutili a Keycloak
- Il servizio ricevente deve validare non solo la firma del token, ma anche verificare che il chiamante sia un service account autorizzato
- In produzione, preferire roles e scopes rispetto a check hardcodati su
azpper un’architettura disaccoppiata
L’autenticazione M2M implementata correttamente diventa un meccanismo trasparente: i servizi si identificano, il token viene validato, la chiamata passa. Implementata male, è un vettore di accesso laterale tra servizi che in rete interna sembrano fidati.
Risorse
- MockMart: Demo E-commerce con OTEL
- RFC 6749: OAuth 2.0 Client Credentials Grant
- Keycloak Documentation: Service Accounts