· 13 min Progettare

Keycloak in Pratica: 6 Problemi Reali nell'Integrazione di un E-Commerce

Keycloak OAuth2 OIDC Microservizi Security PKCE M2M

Un’integrazione Keycloak che funziona perfettamente su localhost e poi esplode al primo deploy in staging è uno scenario comune.

Keycloak configurato, login funzionante, checkout che gira. L’integrazione sembra completa. I problemi iniziano dopo: un 401 inspiegabile in staging, un token che passa la validazione di servizi a cui non era destinato, un service account che qualsiasi utente può impersonare.

Questo articolo documenta 6 problemi concreti emersi nell’integrazione di Keycloak in MockMart, un e-commerce con 5 microservizi Node.js. Per ogni problema: il sintomo, la causa nel codice, e la correzione.

ProblemaDoveImpatto
Issuer mismatch (401 inspiegabile)JWT validationAPI inaccessibile in staging/prod
Audience non validataJWT validationToken cross-client accettati
Service account detection fragileNotification serviceBypass della sicurezza M2M
canCheckout string vs booleanCheckoutAuthorization che fallisce silenziosamente
Race condition nel token cachingM2MKeycloak sovraccaricato sotto carico
Configurazione non portabileRealm + frontendTutto si rompe fuori da localhost

Architettura MockMart

┌─────────────────────────────────────────────────────────────┐
│                    Gateway (nginx:80)                        │
└────┬──────────────┬──────────────┬──────────────────────────┘
     │              │              │
┌────▼────┐   ┌─────▼─────┐  ┌─────▼─────┐
│ Shop UI │   │ Shop API  │  │ Keycloak  │
│ React   │   │ Express   │  │   :8080   │
│ :3000   │   │   :3001   │  └───────────┘
└─────────┘   └─────┬─────┘

      ┌─────────────┼─────────────┐
      │             │             │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼──────┐
│  Payment  │ │ Inventory │ │Notification│
│   :3010   │ │   :3011   │ │   :3009    │
└───────────┘ └───────────┘ └────────────┘

L’autenticazione usa tre pattern: Authorization Code + PKCE per il frontend, JWT validation via JWKS per l’API, Client Credentials per la comunicazione M2M tra servizi.

# Per seguire gli esempi
cd demo/MockMart
make up && make health

Problema 1: Il 401 Inspiegabile (Issuer Mismatch)

Sintomo

L’utente si logga con successo. Il frontend riceve il token. La chiamata GET /api/products con Authorization: Bearer <token> risponde 401. Il token è valido su jwt.io. I log dell’API dicono: JWT validation failed: unexpected "iss" claim value.

Causa

In middleware/auth.js, la validazione JWT controlla l’issuer:

const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://keycloak:8080';
const KEYCLOAK_PUBLIC_URL = process.env.KEYCLOAK_PUBLIC_URL || 'http://localhost:8080';
const KEYCLOAK_ISSUER = `${KEYCLOAK_PUBLIC_URL}${KEYCLOAK_AUTH_PATH}/realms/${KEYCLOAK_REALM}`;
const KEYCLOAK_JWKS_URL = `${KEYCLOAK_URL}${KEYCLOAK_AUTH_PATH}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`;

const { payload } = await jwtVerify(token, getJWKS(), {
  issuer: KEYCLOAK_ISSUER,   // Valida contro l'URL PUBBLICO
  clockTolerance: 30
});

Due URL distinti servono due scopi diversi:

  • KEYCLOAK_URL (http://keycloak:8080) - URL interno via Docker DNS, usato per scaricare le chiavi JWKS
  • KEYCLOAK_PUBLIC_URL (http://localhost:8080) - URL pubblico, usato per validare l’issuer del token

Il frontend genera token con iss: http://localhost:8080/auth/realms/techstore perché il browser raggiunge Keycloak su localhost. L’API deve validare contro lo stesso URL. Se l’API validasse contro l’URL Docker interno (http://keycloak:8080), l’issuer non corrisponderebbe.

Quando si rompe

Su localhost funziona perché il default di KEYCLOAK_PUBLIC_URL è http://localhost:8080. In staging o produzione, Keycloak è raggiungibile su un URL diverso (es. https://auth.example.com). Il frontend genera token con iss: https://auth.example.com/auth/realms/techstore. Se l’API non ha KEYCLOAK_PUBLIC_URL=https://auth.example.com, continua a validare contro http://localhost:8080. Risultato: 401 su tutte le chiamate.

Il problema è amplificato dal fatto che il frontend ha l’URL hardcoded:

// AuthContext.jsx
const KEYCLOAK_URL = 'http://localhost:8080/auth'

In produzione, anche questo valore deve cambiare. Due configurazioni da allineare, in due codebase diverse (frontend e backend), con errori che si manifestano solo a runtime.

Correzione

Rendere l’URL configurabile in entrambi i servizi e validare all’avvio:

// Validazione all'avvio del servizio
const KEYCLOAK_PUBLIC_URL = process.env.KEYCLOAK_PUBLIC_URL;
if (!KEYCLOAK_PUBLIC_URL) {
  throw new Error('KEYCLOAK_PUBLIC_URL is required - must match the issuer in JWT tokens');
}

Problema 2: Token Cross-Client Accettati (Audience Mancante)

Sintomo

Un token ottenuto per il frontend (shop-ui) viene usato per chiamare direttamente il payment service. La chiamata viene accettata. Qualsiasi token emesso dal realm techstore passa la validazione di qualsiasi servizio.

Causa

Il middleware requireAuth in shop-api/middleware/auth.js valida issuer e scadenza, ma non l’audience:

const { payload } = await jwtVerify(token, getJWKS(), {
  issuer: KEYCLOAK_ISSUER,
  clockTolerance: 30
  // Manca: audience: 'shop-api'
});

Lo stesso pattern si ripete in notification/server.js e payment/server.js. Nessun servizio controlla per chi il token è stato emesso.

Conseguenza

Il claim aud (audience) nel JWT indica a quale risorsa il token è destinato. Senza validazione, un token emesso per shop-ui (il frontend) è accettabile anche per payment-service o notification-service. In un realm con più applicazioni, qualsiasi token valido apre qualsiasi porta.

Scenario concreto: un attaccante intercetta un token frontend (es. da un log, da un proxy, da un’estensione browser). Senza audience validation, quel token può chiamare API interne che non dovrebbero essere raggiungibili dal frontend.

Correzione

Aggiungere audience alla validazione JWT in ogni servizio:

const { payload } = await jwtVerify(token, getJWKS(), {
  issuer: KEYCLOAK_ISSUER,
  audience: 'shop-api',    // Accetta solo token destinati a questo servizio
  clockTolerance: 30
});

Lato Keycloak, configurare un audience mapper nel client scope per includere l’aud corretto nel token.


Problema 3: Chiunque Può Essere un Service Account

Sintomo

Un utente senza email nel profilo Keycloak riesce a chiamare /api/notifications/order, un endpoint riservato alla comunicazione M2M tra servizi.

Causa

Il notification service distingue tra token utente e token di servizio con questa logica:

// notification/server.js
req.auth = {
  isServiceAccount: !payload.email && (payload.azp || payload.clientId),
  callingService: payload.azp || payload.clientId || 'unknown',
  subject: payload.sub
};

Il check si basa su due condizioni: assenza di email nel token E presenza di azp (authorized party). L’assunzione è: i service account non hanno email, gli utenti sì.

app.post('/api/notifications/order', requireAuth, async (req, res) => {
  if (!isServiceAccount) {
    return res.status(403).json({ error: 'This endpoint only accepts service account tokens' });
  }
  if (req.auth.callingService !== 'shop-api') {
    return res.status(403).json({ error: 'This endpoint only accepts calls from shop-api' });
  }
  // ...
});

Perché si rompe

L’assunzione è fragile. Un utente Keycloak può non avere email (campo non obbligatorio). Il suo token avrà azp: shop-ui (il client da cui ha fatto login) e nessun email. Il check:

!payload.email && (payload.azp || payload.clientId)
// !undefined && 'shop-ui'
// true && true = true → identificato come service account

Il primo controllo (isServiceAccount) passa. Il secondo (callingService !== 'shop-api') lo blocca solo perché azp è shop-ui, non shop-api. Ma se un altro client confidenziale nel realm viene compromesso, i suoi token passano entrambi i controlli.

Correzione

Non basare l’identificazione sull’assenza di un campo. Verificare un claim esplicito:

// Approccio più robusto: clientId è presente solo nei token client credentials (Keycloak 17+)
// e i token ottenuti via client credentials non hanno session_state
const isServiceAccount = payload.clientId !== undefined && !payload.session_state;

// In alternativa, preferred_username segue il pattern "service-account-<clientId>"
// const isServiceAccount = payload.preferred_username?.startsWith('service-account-');

La soluzione ideale è combinare audience validation (Problema 2) con un check esplicito sul tipo di token. Un token destinato a shop-api con azp: shop-api è un service account confermato.


Problema 4: canCheckout Che Smette di Funzionare

Sintomo

Dopo un aggiornamento di Keycloak, l’utente mario (che ha canCheckout: true nel profilo) riceve 403 al checkout: “You are not authorized to checkout.” Nessuna modifica al codice.

Causa

Il realm config definisce canCheckout come attributo utente (array di stringhe) e lo mappa nel token con un mapper di tipo boolean:

{
  "name": "canCheckout",
  "protocolMapper": "oidc-usermodel-attribute-mapper",
  "config": {
    "user.attribute": "canCheckout",
    "claim.name": "canCheckout",
    "jsonType.label": "boolean"
  }
}

L’attributo in Keycloak è salvato come ["true"] (array di stringhe). Il mapper dovrebbe convertirlo in true (boolean). Il codice gestisce entrambi i casi:

// shop-api/middleware/auth.js
canCheckout: payload.canCheckout === 'true' || payload.canCheckout === true

Perché si rompe

Il doppio check (=== 'true' || === true) copre stringa e boolean. Ma non copre il caso in cui il mapper invii il valore raw dell’attributo: ["true"] (array). In quel caso:

["true"] === 'true'  // false
["true"] === true     // false
// canCheckout = false → 403 Forbidden

Questo succede quando il comportamento del mapper cambia tra versioni di Keycloak, o quando la configurazione del mapper viene ricreata senza jsonType.label. L’utente vede un 403 senza spiegazione. Il token contiene il valore corretto (canCheckout: ["true"]), ma il parsing fallisce.

Lo stesso pattern si ripete nel frontend (AuthContext.jsx), dove l’utente potrebbe vedere l’interfaccia di checkout disabilitata senza motivo.

Correzione

Parsing difensivo che gestisce tutti i formati possibili:

function parseBoolean(value) {
  if (typeof value === 'boolean') return value;
  if (typeof value === 'string') return value === 'true';
  if (Array.isArray(value)) return value[0] === 'true';
  return false;
}

canCheckout: parseBoolean(payload.canCheckout)

In alternativa, evitare il problema alla radice: usare un ruolo Keycloak (can-checkout) invece di un attributo utente. I ruoli sono sempre array di stringhe nel token e il check diventa:

canCheckout: payload.realm_access?.roles?.includes('can-checkout') || false

Problema 5: Keycloak Sotto Carico (Race Condition M2M)

Sintomo

Con 50+ checkout simultanei, i log mostrano decine di richieste al token endpoint di Keycloak nello stesso secondo. Keycloak risponde 429 (Too Many Requests). I checkout falliscono con “Failed to obtain service token for M2M communication”.

Causa

In lib/service-token.js, il token caching non gestisce le richieste concorrenti:

let cachedToken = null;
let tokenExpiry = null;

async function getServiceToken() {
  const now = Date.now();
  if (cachedToken && tokenExpiry && now < tokenExpiry) {
    return cachedToken;    // Cache hit
  }

  // Cache miss: chiama Keycloak
  const tokenResponse = await fetchToken();
  cachedToken = tokenResponse.access_token;
  tokenExpiry = now + (tokenResponse.expires_in - EXPIRY_BUFFER_SECONDS) * 1000;
  return cachedToken;
}

Quando il token scade, tutte le richieste concorrenti trovano la cache vuota e chiamano Keycloak simultaneamente. Con 50 checkout, il token endpoint riceve 50 richieste identiche in parallelo.

Perché è un problema

Su localhost con un solo utente, il pattern funziona. In produzione, il checkout è un fan-out: ogni checkout chiama getServiceToken() prima di contattare inventory, payment, e notification. Un picco di traffico (es. flash sale) causa un’esplosione di richieste al token endpoint.

Il buffer di 60 secondi (EXPIRY_BUFFER_SECONDS) mitiga il problema in condizioni normali, rinnovando il token prima della scadenza. Ma se il servizio viene riavviato (deploy, crash), la cache si svuota e tutte le richieste colpiscono Keycloak insieme.

Correzione

Usare un lock asincrono per garantire una sola richiesta al token endpoint:

let cachedToken = null;
let tokenExpiry = null;
let pendingRequest = null;

async function getServiceToken() {
  const now = Date.now();
  if (cachedToken && tokenExpiry && now < tokenExpiry) {
    return cachedToken;
  }

  // Se c'è già una richiesta in corso, aspetta quella
  if (pendingRequest) {
    return pendingRequest;
  }

  // Prima richiesta: chiama Keycloak e condividi la Promise
  pendingRequest = fetchToken()
    .then(response => {
      cachedToken = response.access_token;
      tokenExpiry = Date.now() + (response.expires_in - EXPIRY_BUFFER_SECONDS) * 1000;
      return cachedToken;
    })
    .finally(() => {
      pendingRequest = null;
    });

  return pendingRequest;
}

Con questo pattern, 50 richieste concorrenti producono una sola chiamata a Keycloak. Le altre 49 attendono la stessa Promise.


Problema 6: Da localhost a Produzione

I problemi precedenti emergono nel codice applicativo. Questa sezione copre le configurazioni che funzionano su localhost e si rompono altrove.

sslRequired: "none" nel realm

{
  "realm": "techstore",
  "sslRequired": "none"
}

Necessario per Docker su HTTP. Ma se questo realm config viene usato come template per produzione, Keycloak accetta connessioni HTTP. Token, credenziali e sessioni viaggiano in chiaro. Il valore corretto per produzione è "external" (HTTPS obbligatorio per richieste esterne) o "all".

Secret nel codice e nel repository

Il client secret appare in tre posti:

// service-token.js - default nel codice
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || 'shop-api-secret';
// realm-config.json - committato nel repo
"secret": "shop-api-secret"
# docker-compose.yml - visibile in chiaro
- KEYCLOAK_CLIENT_SECRET=shop-api-secret

Chiunque abbia accesso al repository ha il secret. In produzione, il secret deve essere iniettato tramite un secret manager (Vault, AWS Secrets Manager, Kubernetes Secrets) e il fallback nel codice deve essere rimosso:

const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET;
if (!KEYCLOAK_CLIENT_SECRET) {
  throw new Error('KEYCLOAK_CLIENT_SECRET is required');
}

checkLoginIframe: false nel frontend

// AuthContext.jsx
const authenticated = await kc.init({
  pkceMethod: 'S256',
  checkLoginIframe: false
})

L’iframe di sessione verifica periodicamente che la sessione Keycloak sia ancora valida. Disabilitato per evitare problemi di cookie cross-origin su localhost, ma in produzione la conseguenza è che un utente che fa logout da un’altra tab resta autenticato nel frontend fino alla scadenza del token (5 minuti).

URL hardcoded nel frontend

// AuthContext.jsx
const KEYCLOAK_URL = 'http://localhost:8080/auth'

In produzione, questo valore deve provenire da una variabile d’ambiente iniettata al build time (Vite: import.meta.env.VITE_KEYCLOAK_URL) o a runtime da un file di configurazione.


Checklist di Sicurezza

Riepilogo delle verifiche da effettuare prima di portare un’integrazione Keycloak in produzione:

VerificaDoveStato in MockMart
Audience validation nei servizijwtVerify optionsMancante
Issuer URL configurabileenv var, no default localhostParziale (default fallback)
Service account detection esplicitaMiddleware M2MFragile (basata su assenza email)
Parsing robusto dei claim customMiddleware authParziale (non gestisce array)
Lock su token caching M2MgetServiceToken()Mancante
sslRequired: "external" o "all"Realm config"none"
Secret non nel codice/repoenv var senza defaultDefault hardcoded
checkLoginIframe abilitatoFrontend initDisabilitato
URL Keycloak configurabile nel frontendBuild/runtime configHardcoded
HTTPS per tutte le comunicazioniNginx, docker-composeHTTP

Considerazioni pratiche

  • L’issuer mismatch è il problema più comune. È il primo 401 che si incontra in staging. Due URL per Keycloak (interno ed esterno) è un pattern obbligatorio in ambienti containerizzati.

  • L’audience validation è il gap di sicurezza più sottile. Tutto funziona senza, fino a quando un token viene usato su un servizio a cui non era destinato. Il problema non genera errori: genera accessi non autorizzati.

  • Il token caching M2M non è un’ottimizzazione, è un requisito. Senza lock sulle richieste concorrenti, il token endpoint di Keycloak diventa un single point of failure sotto carico.

  • I claim custom sono fragili per design. Il formato del valore nel token dipende dalla versione di Keycloak, dal tipo di mapper, e dalla configurazione. I ruoli sono più stabili degli attributi per le decisioni di authorization.

Prossimi Passi

Questi 6 problemi coprono le trappole più comuni nell’integrazione applicativa. Ma la sicurezza non finisce nel codice: la configurazione del realm, i flussi di autenticazione custom e il monitoring in produzione aprono un altro insieme di sfide.

Con make up-otel-keycloak è possibile tracciare i flussi di autenticazione end-to-end in Grafana, utile per diagnosticare i problemi descritti in questo articolo. Se un 401 non si spiega dai log applicativi, le trace distribuite mostrano esattamente dove il flusso si interrompe.

Risorse Utili

Hai bisogno di una revisione architetturale?

Scopri il servizio →

Ti è piaciuto questo articolo?

Articoli correlati

Modifica su GitHub