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
- Autenticació enfront d'autorització, i per què la sessió del monòlit no val
- OAuth 2.0 i OpenID Connect a la pràctica: rols i fluxos
- Anatomia d'un JWT: estructura, claims i signatura amb JWKS
- Keycloak per a TechCorp: realm, clients, rols i claims propis
- Validació al gateway:
requereixTokencomplet ambjose - Defensa en profunditat:
autenticar()a@techcorp/comu-http - Autorització: rols, propietari del recurs i scopes
- Servei a servei: client credentials i què passa amb RabbitMQ
- Errors 401 i 403, tancament de sessió i revocació
- Proves: tokens signats amb una clau local a Jest
- 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.
- 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.
- 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) iscopesón claims estàndard (RFC 7519 i OAuth).azp(authorized party: el client que l'ha demanat),preferred_usernameirealm_access.rolessó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 descobrimenthttps://auth.techcorp.example/realms/techcorp/.well-known/openid-configuration(jwks_uri,token_endpoint,authorization_endpoint...). Elkidde 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).
- 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+ PKCES256per abotiga-web: una SPA no pot guardar secrets; PKCE substitueix el secret.directAccessGrantsEnabled: falsedesactiva el password grant.serviceAccountsEnabled: trueistandardFlowEnabled: falseper aservei-comandes: només client credentials; el seu compte de servei rep el rolservei(ambkcadm.sh add-roles --uusername service-account-servei-comandes --rolename servei).- El client scope
techcorp-apiafegeixaud: techcorp-apia tots els tokens (Keycloak no posa unaudútil per defecte) i el claimclientIddes de l'atribut d'usuariclientId, queservei-clientsescriu a l'alta a través de l'API d'administració de Keycloak (Clients és conformist respecte a Keycloak, 02-03, i desakeycloak_sub, 02-04). Així cap servei no necessita traduirsub→clientIdamb una crida. - Els rols de realm
client,operador,admins'assignen a persones;servei, a comptes de servei. Els scopescomandes:crear,comandes:llegir,productes:llegir,clients:llegiracoten 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.
- Validació al gateway:
requereixToken complet amb jose
requereixToken complet amb joseSubstituï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 aservei-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.
- Defensa en profunditat:
autenticar() a @techcorp/comu-http
autenticar() a @techcorp/comu-httpCada 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.
- 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.
- 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.
- 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 sí 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.
- 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. Sensealgorithms: ['RS256'], un atacant pot enviaralg: noneoHS256signat 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 scopetechcorp-api. - Descarregar el JWKS a cada petició o desar-lo en memòria cau per sempre.
createRemoteJWKSetambcacheMaxAgeicooldownDurationé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_secretal ConfigMap, a la imatge o en un.envversionat. Va al Secretcomandes-oidc(07-04) i mai als logs: afegeix*.client_secretiOIDC_CLIENT_SECRETalredactde 06-01 i aconfigPerALog()de 04-03. - Fer servir
403per 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(elsub) iazp, maipreferred_usernameniemail: identifiquen la persona i elredactde 06-01 no els cobreix. - Consell: les sondes
/health/*i/metricsno porten token; muntaautenticar()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
- Conceptes Bàsics de Microserveis
- Avantatges i Desavantatges dels Microserveis
- Comparació amb l'Arquitectura Monolítica
- Quan Adoptar Microserveis: Criteris de Decisió
- El Cas Pràctic del Curs: la Botiga Online de TechCorp
Mòdul 2: Disseny de Microserveis
- Principis de Disseny de Microserveis
- Descomposició d'Aplicacions Monolítiques
- Definició de Bounded Contexts
- Gestió de Dades: una Base de Dades per Servei
- Consistència Distribuïda: Sagues, CQRS i Event Sourcing
Mòdul 3: Comunicació entre Microserveis
- APIs RESTful
- Missatgeria Asíncrona
- Protocols de Comunicació: gRPC, GraphQL
- API Gateway i Backend for Frontend
- Descobriment de Serveis i Balanceig de Càrrega
- Contractes i Versionat d'APIs
Mòdul 4: Implementació de Microserveis
- Elecció de Tecnologies i Eines
- Desenvolupament d'un Microservei Simple
- Gestió de Configuració
- Integració Pràctica: Consumir APIs i Publicar Esdeveniments
- Proves en Microserveis: Unitàries, d'Integració i de Contracte
Mòdul 5: Desplegament i Orquestració
- Contenidors i Docker
- Orquestració amb Kubernetes
- CI/CD per a Microserveis
- Estratègies de Desplegament: Rolling, Blue-Green i Canary
- Service Mesh: Istio i Linkerd
Mòdul 6: Monitoratge i Manteniment
- Monitoratge i Logging
- Traçabilitat Distribuïda amb OpenTelemetry
- Gestió d'Errors i Recuperació
- Escalabilitat i Rendiment
- SLOs, Alertes i Gestió d'Incidents
Mòdul 7: Seguretat en Microserveis
- Autenticació i Autorització
- Seguretat en la Comunicació
- Pràctiques de Seguretat
- Seguretat en Contenidors i Kubernetes
