El mòdul 6 es va tancar amb una frase incòmoda: el sistema de TechCorp ja és observable, resilient, escalable i operable, però encara no és segur. La primera esquerda és la més visible: el requereixToken del gateway (03-04) només comprova que la capçalera Authorization comença per Bearer, i cap servei no sap encara qui està trucant. Aquesta lliçó tanca aquesta esquerda: explica la diferència entre autenticar i autoritzar, per què la sessió en memòria del monòlit no serveix en un sistema distribuït, com funcionen OAuth 2.0 i OpenID Connect a la pràctica amb Keycloak com a servidor d'identitat, què hi ha dins d'un JWT i com es valida amb les claus públiques del realm, i com es decideix després si l'Ana Ruiz pot veure la comanda com-88213. Tot el codi amplia el que ja hem construït: el gateway de 03-04, la llibreria @techcorp/comu-http i servei-comandes. El que toca al xifratge del canal (07-02), la validació d'entrada (07-03) i els secrets a Kubernetes (07-04) només s'esmenta.

Avís. Els fluxos, configuracions i decisions d'aquesta lliçó són didàctics. Abans d'exposar un sistema real, la configuració de Keycloak, els temps de vida dels tokens i les polítiques d'autorització s'han de revisar amb un professional de seguretat i, quan hi hagi dades personals o de pagament, amb el responsable de compliment normatiu.

Contingut

  1. Autenticació enfront d'autorització, i per què la sessió del monòlit no val
  2. OAuth 2.0 i OpenID Connect a la pràctica: rols i fluxos
  3. Anatomia d'un JWT: estructura, claims i signatura amb JWKS
  4. Keycloak per a TechCorp: realm, clients, rols i claims propis
  5. Validació al gateway: requereixToken complet amb jose
  6. Defensa en profunditat: autenticar() a @techcorp/comu-http
  7. Autorització: rols, propietari del recurs i scopes
  8. Servei a servei: client credentials i què passa amb RabbitMQ
  9. Errors 401 i 403, tancament de sessió i revocació
  10. Proves: tokens signats amb una clau local a Jest

  1. Autenticació enfront d'autorització, i per què la sessió del monòlit no val

Dues preguntes diferents que es confonen cada dia:

Autenticació (authn) Autorització (authz)
Pregunta Qui ets? Què pots fer?
Resposta Una identitat verificada: sub, rols, scopes Permès / denegat per a aquesta acció sobre aquest recurs
Qui la resol a TechCorp Keycloak emet; gateway i serveis verifiquen Cada servei, amb la seva regla de negoci
Error HTTP si falla 401 NO_AUTENTICAT 403 PROHIBIT

Al monòlit techcorp-shop, l'Ana feia login, Express desava una sessió en memòria (o a Redis) i una cookie connect.sid la identificava a cada petició. Això no funciona amb set processos: servei-comandes no comparteix memòria amb servei-clients; una sessió compartida a Redis acobla tots els serveis a un magatzem i a un format, i cada petició interna l'hauria de consultar. L'alternativa que adopta tota la indústria és la identitat portable: un token signat que viatja amb cada petició i que qualsevol servei pot verificar sense preguntar a ningú, només amb la clau pública de l'emissor. Aquest token és un JWT, i el protocol per obtenir-lo és OAuth 2.0 / OpenID Connect.

  1. OAuth 2.0 i OpenID Connect a la pràctica: rols i fluxos

OAuth 2.0 és un marc d'autorització delegada; OpenID Connect (OIDC) és la capa d'autenticació damunt d'OAuth (hi afegeix l'ID token i l'endpoint userinfo). A la pràctica s'usen junts i amb quatre papers:

Paper OAuth A TechCorp
Resource owner L'Ana Ruiz (c-1024), un operador de botiga, un administrador
Client botiga-web (SPA), bff-mobil, servei-comandes quan crida un altre servei
Authorization server Keycloak, https://auth.techcorp.example/realms/techcorp
Resource server El gateway i cada servei-* que exposa una API

Els fluxos (grants) que TechCorp fa servir, i el que no:

Flux Per a què Qui el fa servir
Authorization Code + PKCE Persones en navegador o app: redirigeix a Keycloak, l'usuari s'autentica allà, torna un codi que es bescanvia per tokens. PKCE (code_verifier/code_challenge) protegeix el bescanvi sense necessitar secret de client botiga-web, bff-mobil
Client Credentials Servei a servei, sense usuari: client_id + client_secret a canvi d'un token que representa el servei servei-comandes → Catàleg/Clients (apartat 8)
Refresh Token Renovar un access token curt sense tornar a demanar la contrasenya Tots els clients de persones
~~Password grant~~ El client recull usuari i contrasenya i els envia a Keycloak No es fa servir: la contrasenya passa per codi de TechCorp, no permet MFA ni social login, i OAuth 2.1 l'elimina

El login de l'Ana i la seva primera comanda, de punta a punta:

sequenceDiagram
    participant A as Ana (navegador)
    participant W as botiga-web (SPA)
    participant KC as Keycloak (realm techcorp)
    participant GW as Gateway 8080
    participant P as servei-comandes 3002
    A->>W: Prem "Entra"
    W->>KC: GET /auth?response_type=code&client_id=botiga-web&code_challenge=…&scope=openid comandes:crear comandes:llegir
    KC->>A: Formulari de login (i MFA si està actiu)
    A->>KC: usuari + contrasenya
    KC-->>W: 302 …/callback?code=abc123
    W->>KC: POST /token (code, code_verifier)
    KC-->>W: access_token (5 min) + refresh_token (30 min) + id_token
    A->>W: Prem "Compra"
    W->>GW: POST /api/v1/comandes  Authorization: Bearer <access_token>
    GW->>GW: requereixToken: signatura, iss, aud, exp (JWKS en memòria cau)
    GW->>P: POST /v1/comandes + Authorization + X-Usuari-Id + X-Usuari-Rols
    P->>P: autenticar() de nou, requereixScope('comandes:crear'), clientId del token
    P-->>GW: 202 Location: /v1/comandes/com-88213
    GW-->>W: 202

Dos detalls importants: la contrasenya de l'Ana només la veu Keycloak (la SPA no la toca mai), i el gateway no crida Keycloak a cada petició: verifica la signatura localment amb la clau pública que va descarregar un cop.

  1. Anatomia d'un JWT: estructura, claims i signatura amb JWKS

Un JWT són tres parts en Base64URL separades per punts: capcalera.carrega.signatura.

// Capçalera:  { "alg": "RS256", "typ": "JWT", "kid": "k1-2026-08" }
// Càrrega (payload) de l'access token de l'Ana, emès per Keycloak:
{
  "iss": "https://auth.techcorp.example/realms/techcorp",
  "sub": "3f2a9c1e-7b44-4d0a-9e51-2c8f0b6a1d77",
  "aud": "techcorp-api",
  "azp": "botiga-web",
  "exp": 1786790400, "iat": 1786790100,
  "scope": "openid comandes:crear comandes:llegir",
  "preferred_username": "ana.ruiz", "realm_access": { "roles": ["client"] }, "clientId": "c-1024"
}
// Signatura: RS256(base64url(capcalera) + "." + base64url(carrega), clau privada del realm)
  • iss (emissor), sub (identificador estable del subjecte), aud (per a qui és el token), exp/iat (caducitat i emissió, en segons Unix) i scope són claims estàndard (RFC 7519 i OAuth). azp (authorized party: el client que l'ha demanat), preferred_username i realm_access.roles són de Keycloak. clientId és un claim propi que afegirem a l'apartat 4.
  • La signatura és RS256 (RSA + SHA-256, asimètrica): Keycloak signa amb la seva clau privada; qualsevol verifica amb la pública. Les claus públiques es publiquen al JWKS (JSON Web Key Set), la URL del qual figura al document de descobriment https://auth.techcorp.example/realms/techcorp/.well-known/openid-configuration (jwks_uri, token_endpoint, authorization_endpoint...). El kid de la capçalera diu quina clau del conjunt cal fer servir, i per això Keycloak pot rotar claus sense trencar res.
  • Un JWT no està xifrat: qualsevol que tingui el token en llegeix la càrrega (echo <carrega> | base64 -d). No s'hi posen mai dades sensibles; la signatura garanteix integritat, no confidencialitat.

Els tres tokens que retorna Keycloak:

Token Per a qui Contingut Vida a TechCorp S'envia a l'API
Access token El resource server (gateway, serveis) Claims d'autorització (aud, scope, rols) 5 min Sí, Authorization: Bearer
Refresh token Només Keycloak Opac per al client 30 min lliscants (sessió màx. 8 h) Mai
ID token El client (SPA/app) Identitat per a la interfície (name, email) 5 min Mai

Vides curtes és la decisió clau: un access token robat serveix cinc minuts; el client el renova amb el refresh token de manera transparent. Els serveis de TechCorp no accepten ID tokens (aud diferent).

  1. Keycloak per a TechCorp: realm, clients, rols i claims propis

Keycloak es desplega al clúster (imatge oficial, PostgreSQL propi, Ingress a auth.techcorp.example; l'equip Plataforma l'opera). Un realm és un espai aïllat d'usuaris, clients i claus; TechCorp en fa servir un: techcorp. La seva configuració essencial, exportable com a JSON i importable en arrencar (--import-realm):

{
  "realm": "techcorp",
  "accessTokenLifespan": 300,
  "ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 28800,
  "roles": { "realm": [ { "name": "client" }, { "name": "operador" }, { "name": "admin" }, { "name": "servei" } ] },
  "clientScopes": [
    { "name": "comandes:crear", "protocol": "openid-connect" },
    { "name": "comandes:llegir",  "protocol": "openid-connect" },
    { "name": "techcorp-api", "protocol": "openid-connect",
      "protocolMappers": [
        { "name": "audiencia", "protocolMapper": "oidc-audience-mapper",
          "config": { "included.custom.audience": "techcorp-api", "access.token.claim": "true" } },
        { "name": "clientId", "protocolMapper": "oidc-usermodel-attribute-mapper",
          "config": { "user.attribute": "clientId", "claim.name": "clientId", "access.token.claim": "true", "jsonType.label": "String" } }
      ] }
  ],
  "clients": [
    { "clientId": "botiga-web", "publicClient": true, "standardFlowEnabled": true, "directAccessGrantsEnabled": false,
      "attributes": { "pkce.code.challenge.method": "S256" },
      "redirectUris": ["https://shop.techcorp.example/*"], "webOrigins": ["https://shop.techcorp.example"],
      "defaultClientScopes": ["techcorp-api", "comandes:crear", "comandes:llegir"] },
    { "clientId": "bff-mobil", "publicClient": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "redirectUris": ["techcorp://callback"], "defaultClientScopes": ["techcorp-api", "comandes:crear", "comandes:llegir"] },
    { "clientId": "servei-comandes", "publicClient": false, "standardFlowEnabled": false, "serviceAccountsEnabled": true,
      "defaultClientScopes": ["techcorp-api", "productes:llegir", "clients:llegir"] }
  ]
}

El que decideix cada línia:

  • publicClient: true + PKCE S256 per a botiga-web: una SPA no pot guardar secrets; PKCE substitueix el secret. directAccessGrantsEnabled: false desactiva el password grant.
  • serviceAccountsEnabled: true i standardFlowEnabled: false per a servei-comandes: només client credentials; el seu compte de servei rep el rol servei (amb kcadm.sh add-roles --uusername service-account-servei-comandes --rolename servei).
  • El client scope techcorp-api afegeix aud: techcorp-api a tots els tokens (Keycloak no posa un aud útil per defecte) i el claim clientId des de l'atribut d'usuari clientId, que servei-clients escriu a l'alta a través de l'API d'administració de Keycloak (Clients és conformist respecte a Keycloak, 02-03, i desa keycloak_sub, 02-04). Així cap servei no necessita traduir subclientId amb una crida.
  • Els rols de realm client, operador, admin s'assignen a persones; servei, a comptes de servei. Els scopes comandes:crear, comandes:llegir, productes:llegir, clients:llegir acoten el que cada client pot demanar, independentment de l'usuari.

El client_secret de servei-comandes viu al Secret comandes-oidc de Kubernetes (OIDC_CLIENT_SECRET), al costat d'OIDC_ISSUER i OIDC_TOKEN_URL al ConfigMap; la seva gestió i rotació són de 07-04.

  1. Validació al gateway: requereixToken complet amb jose

Substituïm l'esquelet de 03-04. jose (npm install jose) és la llibreria de referència a Node.js per a JWT/JWK; createRemoteJWKSet descarrega el JWKS, el desa en memòria cau i el refresca només si arriba un kid desconegut (rotació) o passa cacheMaxAge.

// gateway/auth.js
const { createRemoteJWKSet, jwtVerify, errors } = require('jose');

const ISSUER = process.env.OIDC_ISSUER ?? 'https://auth.techcorp.example/realms/techcorp';
const AUDIENCE = process.env.OIDC_AUDIENCE ?? 'techcorp-api';
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), {
  cacheMaxAge: 600_000,        // reutilitza les claus 10 min sense anar a Keycloak
  cooldownDuration: 30_000 }); // si arriba un kid desconegut, com a molt una descàrrega cada 30 s (protegeix Keycloak d'un atac amb kids inventats)

function problema401(res, req, detall, errorOauth = 'invalid_token') {   // RFC 6750: WWW-Authenticate diu com autenticar-se i per què ha fallat
  res.set('WWW-Authenticate', `Bearer realm="techcorp", error="${errorOauth}", error_description="${detall}"`);
  return res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'No autenticat', status: 401, codi: 'NO_AUTENTICAT', detail: detall, requestId: req.requestId });
}

async function requereixToken(req, res, next) {
  const auth = req.get('Authorization') ?? '';
  if (!auth.startsWith('Bearer ')) return problema401(res, req, 'Falta el token', 'invalid_request');
  try {
    const { payload } = await jwtVerify(auth.slice(7), JWKS, {
      issuer: ISSUER,               // iss exacte: un token d'un altre realm o d'un Keycloak fals no passa
      audience: AUDIENCE,           // aud ha de contenir techcorp-api: un ID token o un token per a una altra API no passa
      algorithms: ['RS256'],        // no acceptar mai alg:none ni HS256 (amb HS256 la "clau pública" serviria per signar)
      clockTolerance: 30            // 30 s de tolerància de rellotge entre Keycloak i el gateway
    });
    req.usuari = { sub: payload.sub, clientId: payload.clientId ?? null, roles: payload.realm_access?.roles ?? [],
                   scopes: (payload.scope ?? '').split(' ').filter(Boolean), azp: payload.azp };
    next();
  } catch (err) {
    if (err instanceof errors.JWTExpired) return problema401(res, req, 'Token caducat');
    req.log?.warn({ err: err.code }, 'token rebutjat');   // el motiu real, només al log
    return problema401(res, req, 'Token no vàlid');
  }
}
module.exports = { requereixToken };

I a gateway/servidor.js la propagació cap endins. Abans de posar les capçaleres pròpies, s'eliminen les que vinguin de l'exterior (ningú no ens ha de poder enviar X-Usuari-Rols: admin des d'Internet):

// gateway/servidor.js (fragments que canvien respecte a 03-04)
const { requereixToken } = require('./auth');

app.use((req, _res, next) => {           // 0. Higiene: les capçaleres internes només les posa el gateway
  for (const h of Object.keys(req.headers)) if (h.startsWith('x-usuari-')) delete req.headers[h];
  next();
});
// ... requestId, cors, rateLimit, log d'accés com a 03-04 ...
// A proxyCapA(), dins d'on.proxyReq, a més de l'X-Request-Id:
//   if (req.usuari) {                                              // només rutes que han passat per requereixToken
//     proxyReq.setHeader('X-Usuari-Id', req.usuari.sub);
//     proxyReq.setHeader('X-Usuari-Rols', req.usuari.roles.join(','));
//   }
//   La capçalera Authorization es reenvia tal qual: el servei torna a verificar el JWT (apartat 6)
app.use('/api/v1/productes', proxyCapA(DESTINS.cataleg, { treurePrefix: '/api' }));                    // pública
app.use('/api/v1/comandes',  requereixToken, proxyCapA(DESTINS.comandes, { treurePrefix: '/api' }));
app.use('/api/v1/clients',   requereixToken, proxyCapA(DESTINS.clients,  { treurePrefix: '/api' }));
app.use('/api/graphql',      requereixToken, proxyCapA(DESTINS.bffMobil, { treurePrefix: '/api' }));

Advertència sobre X-Usuari-*. Aquestes capçaleres només són fiables si ningú llevat del gateway pot arribar a servei-comandes:3002. A la xarxa del clúster això avui és una suposició, no una garantia: un pod compromès al mateix namespace podria cridar directament el servei amb la capçalera que vulgui. Per això (a) els serveis tornen a verificar el JWT (apartat 6) i fan servir les capçaleres només com a comoditat per als logs, i (b) 07-02 i 07-04 tanquen la xarxa amb NetworkPolicies, mTLS i control de qui parla amb qui.

  1. Defensa en profunditat: autenticar() a @techcorp/comu-http

Cada servei verifica el token un altre cop. Costa microsegons (signatura RSA amb clau en memòria cau) i elimina la confiança cega en la xarxa. La llibreria exposa autenticar(), requereixRol() i requereixScope(), amb el JWKS injectable per a les proves de l'apartat 10:

// @techcorp/comu-http/src/auth.js
const { createRemoteJWKSet, jwtVerify, errors } = require('jose');
const { ErrorNegoci } = require('./errors');

function crearJwksRemot(issuer) {
  return createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`), { cacheMaxAge: 600_000, cooldownDuration: 30_000 });
}

// autenticar({ issuer, audience, jwks?, opcional? }) → middleware que deixa req.usuari
function autenticar({ issuer, audience, jwks = crearJwksRemot(issuer), opcional = false }) {
  return async (req, res, next) => {
    const auth = req.get('Authorization') ?? '';
    if (!auth.startsWith('Bearer ')) {
      if (opcional) return next();                                            // rutes públiques: sense usuari, però sense error
      res.set('WWW-Authenticate', 'Bearer realm="techcorp", error="invalid_request"');
      return next(new ErrorNegoci('NO_AUTENTICAT', 'Falta el token', 401));
    }
    try {
      const { payload } = await jwtVerify(auth.slice(7), jwks, { issuer, audience, algorithms: ['RS256'], clockTolerance: 30 });
      req.usuari = { sub: payload.sub, clientId: payload.clientId ?? null, roles: payload.realm_access?.roles ?? [],
                     scopes: (payload.scope ?? '').split(' ').filter(Boolean), azp: payload.azp };
      req.log?.setBindings?.({ usuariId: payload.sub });                     // correlació als logs (06-01), sense dades personals
      next();
    } catch (err) {
      const detall = err instanceof errors.JWTExpired ? 'Token caducat' : 'Token no vàlid';
      res.set('WWW-Authenticate', `Bearer realm="techcorp", error="invalid_token", error_description="${detall}"`);
      next(new ErrorNegoci('NO_AUTENTICAT', detall, 401));
    }
  };
}

const requereixRol = (...roles) => (req, _res, next) =>
  roles.some((r) => req.usuari?.roles.includes(r)) ? next() : next(new ErrorNegoci('PROHIBIT', `Requereix rol ${roles.join(' o ')}`, 403));

const requereixScope = (scope) => (req, _res, next) =>
  req.usuari?.scopes.includes(scope) ? next() : next(new ErrorNegoci('PROHIBIT', `Requereix l'scope ${scope}`, 403));

module.exports = { autenticar, requereixRol, requereixScope, crearJwksRemot };

Com que llancen ErrorNegoci, el middlewareErrors de 04-02 ja els converteix en problem+json amb codi NO_AUTENTICAT o PROHIBIT. A servei-comandes, crearApp rep la configuració d'OIDC i munta el middleware abans de les rutes de negoci i després de /health/* (les sondes de Kubernetes no porten token):

// servei-comandes/src/app.js (fragment)
function crearApp({ repositori, catalegClient, clientsClient, logger, comprovacionsSalut = {}, oidc, jwks }) {
  // ... middlewareRequestId, pinoHttp, express.json, crearRutesSalut (sense token) ...
  app.use('/v1', autenticar({ issuer: oidc.issuer, audience: oidc.audience, jwks }));   // tot /v1/* exigeix token
  app.use(crearRutesComandes({ crearComanda, repositori, requereixScope }));
  // ... 404 i middlewareErrors ...
}

src/config.js (04-03) guanya OIDC_ISSUER: z.string().url() i OIDC_AUDIENCE: z.string().default('techcorp-api'); oidc es construeix des d'allà a servidor.js.

  1. Autorització: rols, propietari del recurs i scopes

Autenticat no vol dir autoritzat. TechCorp combina tres capes, de la més gruixuda a la més fina:

Capa Pregunta Mecanisme Exemple
RBAC (rols) Té el paper adequat? requereixRol('operador', 'admin') Llistar totes les comandes del dia
Scopes (per client) Aquest client OAuth pot demanar això? requereixScope('comandes:crear') bff-mobil de només lectura no crea comandes
Recurs (propietari) És seu? Regla al cas d'ús o a la ruta L'Ana només veu les seves comandes
ABAC / OPA Compleix atributs i polítiques externes? Motor de polítiques (Open Policy Agent, Cedar) Només esment: quan les regles creixin més enllà del que cap en codi

La regla del propietari a GET /v1/comandes/{id} i la decisió 403 o 404:

// servei-comandes/src/rutes/comandes.js (fragment GET, amplia el de 04-04)
encaminador.get('/v1/comandes/:id', requereixScope('comandes:llegir'), async (req, res, next) => {
  try {
    const comanda = await repositori.obtenir(req.params.id);
    if (!comanda) throw new ErrorNegoci('COMANDA_NO_EXISTEIX', `No existeix ${req.params.id}`, 404);
    const esOperador = req.usuari.roles.some((r) => ['operador', 'admin', 'servei'].includes(r));
    if (!esOperador && comanda.clientId !== req.usuari.clientId) {
      req.log.warn({ comandaId: comanda.comandaId, usuariId: req.usuari.sub }, 'accés a comanda aliena');   // senyal per a 07-03 (auditoria)
      throw new ErrorNegoci('COMANDA_NO_EXISTEIX', `No existeix ${req.params.id}`, 404);   // política: 404, no 403
    }
    // ... ETag i resposta com a 04-04 ...
  } catch (err) { next(err); }
});

Per què 404 quan la comanda és d'un altre client, si 03-01 deia 403? Perquè un 403 confirma que l'identificador existeix, i els com-NNNNN són fàcils d'enumerar (OWASP en diu BOLA, 07-03): un atacant sabria quantes comandes hi ha i quines són vàlides. Decisió de TechCorp: per a clients, una comanda aliena es comporta com a inexistent (404 COMANDA_NO_EXISTEIX); per a operadors, que sí que les veuen totes, no hi ha cas. 403 PROHIBIT queda per a "estàs autenticat però el teu rol/scope no permet aquesta acció" (p. ex. un client que crida POST /v1/comandes/{id}/cancellacio d'un altre: allà també 404, mateixa regla; un client que crida GET /v1/comandes?estat=PENDENT sense filtre de client: 403). El contracte OpenAPI de 03-01 s'actualitza: l'ACCES_DENEGAT de la seva taula passa a ser PROHIBIT, únic codi de 403 a tota la plataforma.

A POST /v1/comandes la mateixa idea a l'inrevés: el cos porta clientId (03-01), però mana el token. Si l'usuari té rol client, el clientId del cos ha de coincidir amb req.usuari.clientId (si no, 403 PROHIBIT: aquí no hi ha res a amagar); un operador pot crear comandes en nom d'un altre (venda telefònica). El cas d'ús crearComanda rep usuari com a part de la petició i aplica la regla, de manera que la prova unitària de 04-05 la cobreix sense HTTP.

  1. Servei a servei: client credentials i què passa amb RabbitMQ

Quan servei-comandes crida GET /v1/clients/c-1024 no hi ha cap usuari al davant (o n'hi ha, però la comanda també es pot crear des d'un job). Comandes s'autentica com a servei amb client credentials, i desa el token en memòria cau fins poc abans d'exp. A @techcorp/comu-http:

// @techcorp/comu-http/src/proveidorToken.js
function crearProveidorToken({ tokenUrl, clientId, clientSecret, margeSegons = 30 }) {
  let cache = { token: null, expiraEn: 0 };
  let enCurs = null;                                                    // evita 50 peticions simultànies a Keycloak en arrencar
  async function demanar() {
    const cos = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret });
    const res = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: cos, signal: AbortSignal.timeout(3000) });
    if (!res.ok) throw new Error(`Keycloak ha retornat ${res.status} en demanar el token`);
    const { access_token, expires_in } = await res.json();
    cache = { token: access_token, expiraEn: Date.now() + (expires_in - margeSegons) * 1000 };
    return access_token;
  }
  return { async obtenir() {
    if (cache.token && Date.now() < cache.expiraEn) return cache.token;
    enCurs ??= demanar().finally(() => { enCurs = null; });
    return enCurs;
  } };
}
module.exports = { crearProveidorToken };

crearClientHttp (06-03) guanya una opció proveidorToken: si existeix, afegeix Authorization: Bearer <token> a cada petició. A servidor.js de Comandes:

const proveidorToken = crearProveidorToken({ tokenUrl: config.OIDC_TOKEN_URL, clientId: 'servei-comandes', clientSecret: config.OIDC_CLIENT_SECRET });
const clientsClient = crearClientHttp({ urlBase: config.CLIENTS_URL, nom: 'clients', proveidorToken });
const catalegClient = crearClientHttp({ urlBase: config.CATALEG_URL, nom: 'cataleg', proveidorToken });

A la destinació, servei-clients accepta GET /v1/clients/{id} si el token té rol servei i scope clients:llegir, o si és el mateix client (clientId del token = {id}), o un operador. Catàleg manté GET /v1/productes amb autenticar({ opcional: true }): la ruta és pública a través del gateway, però si arriba token el verifica i podria, per exemple, retornar preus especials per rol.

I els esdeveniments per RabbitMQ? Un missatge comanda.creada no porta JWT: no hi ha cap "petició" a autoritzar, i un token de 5 minuts no té sentit en un missatge que es pot processar al cap d'una hora des d'una DLQ (06-03). L'autenticació és del servei davant del broker: usuari comandes amb contrasenya al Secret comandes-rabbitmq, permisos per vhost, exchange i cua. Què pot publicar i consumir cada usuari, i el xifratge del canal amqps://, es detallen a 07-02. Si un consumidor necessita saber qui ha originat la comanda, aquesta dada viatja a la càrrega de l'esdeveniment (clientId), no com a credencial.

  1. Errors 401 i 403, tancament de sessió i revocació

Convenció tancada de TechCorp, en format RFC 7807 amb codi (03-01):

Situació Status codi Capçalera
Sense token, mal format, signatura invàlida, iss/aud incorrectes, caducat 401 NO_AUTENTICAT WWW-Authenticate: Bearer realm="techcorp", error="invalid_token" (o invalid_request si falta)
Token vàlid, però rol o scope insuficient 403 PROHIBIT
Token vàlid, recurs d'un altre client 404 COMANDA_NO_EXISTEIX (política de l'apartat 7)
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="techcorp", error="invalid_token", error_description="Token caducat"

{"type":"about:blank","title":"No autenticat","status":401,"codi":"NO_AUTENTICAT","detail":"Token caducat","requestId":"req-9c1e…"}

Un 401 amb error="invalid_token" és el senyal perquè la SPA renovi amb el refresh token i ho repeteixi; un 403 no es reintenta.

Tancament de sessió i revocació. Un JWT signat és vàlid fins a exp encara que l'usuari tanqui la sessió: els serveis no pregunten a Keycloak. TechCorp ho resol amb tokens curts (5 min) més refresh tokens que Keycloak que revoca (logout amb end_session_endpoint, canvi de contrasenya, compte desactivat): després del logout, el pitjor cas són cinc minuts d'un token ja emès. Per a un tall immediat (un admin donat de baixa) existeixen la introspecció (/token/introspect, una crida a Keycloak per petició) o una llista de revocació per jti a Redis, només esmentades: TechCorp no les necessita en la primera fase.

  1. Proves: tokens signats amb una clau local a Jest

Les proves de 04-05 no han de dependre d'un Keycloak. Com que autenticar() accepta el jwks com a dependència, a les proves es genera un parell de claus i se signen tokens a mida:

// servei-comandes/test/suport/tokens.js
const { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } = require('jose');
const ISSUER = 'https://auth.test/realms/techcorp', AUDIENCE = 'techcorp-api';
let claus;
async function iniciarClaus() {                        // parell de claus nou a cada execució: res a guardar al repo
  const { publicKey, privateKey } = await generateKeyPair('RS256');
  const jwk = { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'RS256', use: 'sig' };
  claus = { privateKey, jwks: createLocalJWKSet({ keys: [jwk] }) };
  return { issuer: ISSUER, audience: AUDIENCE, jwks: claus.jwks };
}
async function tokenDe({ sub = 'sub-ana', clientId = 'c-1024', roles = ['client'], scopes = ['comandes:crear', 'comandes:llegir'], expiraEn = '5m', aud = AUDIENCE } = {}) {
  return new SignJWT({ realm_access: { roles }, scope: scopes.join(' '), clientId, azp: 'botiga-web' })
    .setProtectedHeader({ alg: 'RS256', kid: 'test-1' }).setIssuer(ISSUER).setAudience(aud).setSubject(sub)
    .setIssuedAt().setExpirationTime(expiraEn).sign(claus.privateKey);
}
module.exports = { iniciarClaus, tokenDe };
// servei-comandes/test/comandes.auth.test.js
const request = require('supertest');
const { crearApp } = require('../src/app');
const { iniciarClaus, tokenDe } = require('./suport/tokens');
let app;
beforeAll(async () => {
  const oidc = await iniciarClaus();
  app = crearApp({ repositori: repositoriEnMemoria([comandaDAna]), catalegClient, clientsClient, logger, oidc, jwks: oidc.jwks });
});
const get = (token) => request(app).get('/v1/comandes/com-88213').set('Authorization', `Bearer ${token}`);
test('sense token → 401 NO_AUTENTICAT amb WWW-Authenticate', async () => {
  const res = await request(app).get('/v1/comandes/com-88213');
  expect(res.status).toBe(401); expect(res.body.codi).toBe('NO_AUTENTICAT'); expect(res.headers['www-authenticate']).toMatch(/Bearer/);
});
test('l\'Ana veu la seva comanda; un altre client rep 404; token caducat → 401', async () => {
  expect((await get(await tokenDe())).status).toBe(200);
  expect((await get(await tokenDe({ sub: 'sub-altre', clientId: 'c-2048' }))).status).toBe(404);
  expect((await get(await tokenDe({ expiraEn: '-1m' }))).body.detail).toBe('Token caducat');
});
test('sense scope comandes:crear → 403 PROHIBIT', async () => {
  const res = await request(app).post('/v1/comandes').set('Authorization', `Bearer ${await tokenDe({ scopes: ['comandes:llegir'] })}`).send(cosComandaDAna);
  expect(res.status).toBe(403); expect(res.body.codi).toBe('PROHIBIT');
});

Les proves de contracte amb Pact (04-05) continuen igual: el proveïdor arrenca amb aquest JWKS local i el consumer declara la capçalera Authorization: Bearer <qualsevol> amb un matcher de tipus; la signatura no forma part del contracte.

Errors Comuns i Consells

  • Acceptar qualsevol alg. Sense algorithms: ['RS256'], un atacant pot enviar alg: none o HS256 signat amb la clau pública. Fixa-ho sempre.
  • No comprovar aud. Un ID token, o un access token emès per a una altra API del mateix Keycloak, passaria. audience és obligatori; per això existeix el client scope techcorp-api.
  • Descarregar el JWKS a cada petició o desar-lo en memòria cau per sempre. createRemoteJWKSet amb cacheMaxAge i cooldownDuration és el punt mitjà: rota claus sense reinicis i no converteix Keycloak en punt únic de fallada.
  • Confiar en X-Usuari-* sense verificar el JWT al servei. És la falla que 07-02 i 07-04 acoten; fins aleshores, la doble validació és la xarxa de seguretat.
  • Posar el client_secret al ConfigMap, a la imatge o en un .env versionat. Va al Secret comandes-oidc (07-04) i mai als logs: afegeix *.client_secret i OIDC_CLIENT_SECRET al redact de 06-01 i a configPerALog() de 04-03.
  • Fer servir 403 per a comandes alienes i regalar l'enumeració d'identificadors. Decideix la política i aplica-la a tota la plataforma.
  • Consell: desa al log usuariId (el sub) i azp, mai preferred_username ni email: identifiquen la persona i el redact de 06-01 no els cobreix.
  • Consell: les sondes /health/* i /metrics no porten token; munta autenticar() després d'elles o exposa-les per un altre port.

Exercicis

Exercici 1. Un desenvolupador proposa que bff-mobil faci servir el password grant "perquè l'app té la seva pròpia pantalla de login i així no obrim el navegador". Escriu tres motius per rebutjar-ho i l'alternativa concreta amb Keycloak.

Exercici 2. Implementa a servei-clients la regla d'autorització de GET /v1/clients/{id} descrita a l'apartat 8: la pot llegir el mateix client, un operador/admin, o un token de servei amb clients:llegir. Decideix què retornar quan un client demana el perfil d'un altre i justifica-ho.

Exercici 3. L'access token de servei-comandes caduca cada 5 minuts i Comandes fa ~2 crides a Clients per comanda (≈6.000/dia). Quantes peticions al token_endpoint fa Comandes al dia amb crearProveidorToken (marge 30 s) i quantes en faria sense memòria cau? Què passa si Keycloak està caigut 2 minuts amb i sense memòria cau?

Solucions

Solució 1. (1) La contrasenya de la persona passa per codi de TechCorp (el BFF i l'app), i amplia la superfície: qualsevol fallada en ells l'exposa; amb Authorization Code + PKCE només la veu Keycloak. (2) Es perden MFA, social login, polítiques de contrasenya i detecció de força bruta de Keycloak, que s'apliquen al seu formulari. (3) Està desaconsellat per la RFC 6819 i eliminat a OAuth 2.1; Keycloak el porta desactivat (directAccessGrantsEnabled: false) i activar-lo és una excepció que cal justificar en auditoria. Alternativa: Authorization Code + PKCE amb navegador del sistema (ASWebAuthenticationSession/Custom Tabs, o la llibreria AppAuth), redirectUri techcorp://callback, i si es vol una experiència "sense sortir de l'app", personalitzar el tema del login de Keycloak.

Solució 2.

encaminador.get('/v1/clients/:id', async (req, res, next) => {
  try {
    const u = req.usuari, id = req.params.id;
    const permes = u.clientId === id || u.roles.some((r) => ['operador', 'admin'].includes(r)) || (u.roles.includes('servei') && u.scopes.includes('clients:llegir'));
    const client = permes ? await repositori.obtenir(id) : null;              // no tocar la BD si no està permès
    if (!client) throw new ErrorNegoci('CLIENT_NO_EXISTEIX', `No existeix ${id}`, 404);
    res.json(aDto(client));
  } catch (err) { next(err); }
});

Mateixa política que a Comandes: per a un client, un altre client "no existeix" (404), perquè els c-NNNN són enumerables i un 403 confirmaria quins estan donats d'alta; i la comprovació va abans de consultar la base de dades perquè el temps de resposta tampoc no delati l'existència.

Solució 3. Amb memòria cau: un token dura 300 s i es renova als 270 s (marge 30 s); en 24 h són 86.400 / 270 ≈ 320 peticions al token_endpoint, independentment del trànsit. Sense memòria cau: una per crida sortint, ≈ 6.000 (i ×2 en campanyes), a més d'afegir la latència de Keycloak a cada comanda. Si Keycloak cau 2 minuts: amb memòria cau, Comandes continua funcionant mentre el token vigent no caduqui (fins a 4,5 min en el millor cas; en el pitjor, si la caiguda coincideix amb la renovació, obtenir() falla i crearClientHttp ho tradueix a DEPENDENCIA_NO_DISPONIBLE 503 amb Retry-After, com qualsevol dependència de 06-03); sense memòria cau, totes les comandes fallen durant els 2 minuts. La memòria cau converteix Keycloak en una dependència tova per al flux síncron; els tokens ja emesos als usuaris també continuen valent, perquè els serveis verifiquen amb el JWKS en memòria cau.

Conclusió

TechCorp ha passat de "hi ha una capçalera Authorization" a una identitat verificable a tot el sistema. Keycloak, al realm techcorp, autentica persones amb Authorization Code + PKCE (botiga-web, bff-mobil) i serveis amb client credentials (servei-comandes), emet access tokens RS256 de 5 minuts amb iss, aud: techcorp-api, scope, realm_access.roles i el claim propi clientId, i publica les seves claus al JWKS. El gateway valida amb jose (createRemoteJWKSet en memòria cau, jwtVerify amb issuer, audience i algorithms), neteja i propaga X-Usuari-Id/X-Usuari-Rols, i respon 401 NO_AUTENTICAT amb WWW-Authenticate; cada servei torna a verificar amb autenticar() de @techcorp/comu-http i autoritza amb requereixRol, requereixScope i la regla del propietari (comanda aliena → 404, rol insuficient → 403 PROHIBIT); Comandes obté el seu token amb crearProveidorToken i el desa en memòria cau; RabbitMQ autentica el servei, no el missatge; i les proves signen tokens amb una clau local. Queda una suposició sense garantia: que les peticions internes i les capçaleres X-Usuari-* viatgen per una xarxa en què ningú no escolta ni suplanta. Assegurar el canal —TLS a la vora, mTLS entre serveis, RabbitMQ i bases de dades xifrades, webhooks signats— és la lliçó següent: seguretat en la comunicació.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats