El módulo 6 cerró con una frase incómoda: el sistema de TechCorp ya es observable, resiliente, escalable y operable, pero todavía no es seguro. La primera grieta es la más visible: el requiereToken del gateway (03-04) solo comprueba que la cabecera Authorization empieza por Bearer, y ningún servicio sabe todavía quién está llamando. Esta lección cierra esa grieta: explica la diferencia entre autenticar y autorizar, por qué la sesión en memoria del monolito no sirve en un sistema distribuido, cómo funcionan OAuth 2.0 y OpenID Connect en la práctica con Keycloak como servidor de identidad, qué hay dentro de un JWT y cómo se valida con las claves públicas del realm, y cómo se decide después si Ana Ruiz puede ver el pedido ped-88213. Todo el código amplía lo ya construido: el gateway de 03-04, la librería @techcorp/comun-http y servicio-pedidos. Lo que toca a cifrado del canal (07-02), validación de entrada (07-03) y secretos en Kubernetes (07-04) solo se nombra.
Aviso. Los flujos, configuraciones y decisiones de esta lección son didácticos. Antes de exponer un sistema real, la configuración de Keycloak, los tiempos de vida de los tokens y las políticas de autorización deben revisarse con un profesional de seguridad y, cuando haya datos personales o de pago, con el responsable de cumplimiento.
Contenido
- Autenticación frente a autorización, y por qué la sesión del monolito no vale
- OAuth 2.0 y OpenID Connect en la práctica: roles y flujos
- Anatomía de un JWT: estructura, claims y firma con JWKS
- Keycloak para TechCorp: realm, clientes, roles y claims propios
- Validación en el gateway:
requiereTokencompleto conjose - Defensa en profundidad:
autenticar()en@techcorp/comun-http - Autorización: roles, propietario del recurso y scopes
- Servicio a servicio: client credentials y qué pasa con RabbitMQ
- Errores 401 y 403, cierre de sesión y revocación
- Pruebas: tokens firmados con una clave local en Jest
- Autenticación frente a autorización, y por qué la sesión del monolito no vale
Dos preguntas distintas que se confunden a diario:
| Autenticación (authn) | Autorización (authz) | |
|---|---|---|
| Pregunta | ¿Quién eres? | ¿Qué puedes hacer? |
| Respuesta | Una identidad verificada: sub, roles, scopes |
Permitido / denegado para esta acción sobre este recurso |
| Quién la resuelve en TechCorp | Keycloak emite; gateway y servicios verifican | Cada servicio, con su regla de negocio |
| Error HTTP si falla | 401 NO_AUTENTICADO |
403 PROHIBIDO |
En el monolito techcorp-shop, Ana hacía login, Express guardaba una sesión en memoria (o en Redis) y una cookie connect.sid la identificaba en cada petición. Eso no funciona con siete procesos: servicio-pedidos no comparte memoria con servicio-clientes; una sesión compartida en Redis acopla a todos los servicios a un almacén y a un formato, y cada petición interna tendría que consultarlo. La alternativa que adopta toda la industria es la identidad portable: un token firmado que viaja con cada petición y que cualquier servicio puede verificar sin preguntar a nadie, solo con la clave pública del emisor. Ese token es un JWT, y el protocolo para obtenerlo es OAuth 2.0 / OpenID Connect.
- OAuth 2.0 y OpenID Connect en la práctica: roles y flujos
OAuth 2.0 es un marco de autorización delegada; OpenID Connect (OIDC) es la capa de autenticación encima de OAuth (añade el ID token y el endpoint userinfo). En la práctica se usan juntos y con cuatro papeles:
| Papel OAuth | En TechCorp |
|---|---|
| Resource owner | Ana Ruiz (c-1024), un operador de tienda, un administrador |
| Client | tienda-web (SPA), bff-movil, servicio-pedidos cuando llama a otro servicio |
| Authorization server | Keycloak, https://auth.techcorp.example/realms/techcorp |
| Resource server | El gateway y cada servicio-* que expone una API |
Los flujos (grants) que TechCorp usa, y el que no:
| Flujo | Para qué | Quién lo usa |
|---|---|---|
| Authorization Code + PKCE | Personas en navegador o app: redirige a Keycloak, el usuario se autentica allí, vuelve un código que se canjea por tokens. PKCE (code_verifier/code_challenge) protege el canje sin necesitar secreto de cliente |
tienda-web, bff-movil |
| Client Credentials | Servicio a servicio, sin usuario: client_id + client_secret a cambio de un token que representa al servicio |
servicio-pedidos → Catálogo/Clientes (apartado 8) |
| Refresh Token | Renovar un access token corto sin volver a pedir contraseña | Todos los clientes de personas |
| ~~Password grant~~ | El cliente recoge usuario y contraseña y los envía a Keycloak | No se usa: la contraseña pasa por código de TechCorp, no permite MFA ni social login, y OAuth 2.1 lo elimina |
El login de Ana y su primer pedido, de punta a punta:
sequenceDiagram
participant A as Ana (navegador)
participant W as tienda-web (SPA)
participant KC as Keycloak (realm techcorp)
participant GW as Gateway 8080
participant P as servicio-pedidos 3002
A->>W: Pulsa "Entrar"
W->>KC: GET /auth?response_type=code&client_id=tienda-web&code_challenge=…&scope=openid pedidos:crear pedidos:leer
KC->>A: Formulario de login (y MFA si está activo)
A->>KC: usuario + contraseña
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: Pulsa "Comprar"
W->>GW: POST /api/v1/pedidos Authorization: Bearer <access_token>
GW->>GW: requiereToken: firma, iss, aud, exp (JWKS cacheado)
GW->>P: POST /v1/pedidos + Authorization + X-Usuario-Id + X-Usuario-Roles
P->>P: autenticar() de nuevo, requiereScope('pedidos:crear'), clienteId del token
P-->>GW: 202 Location: /v1/pedidos/ped-88213
GW-->>W: 202
Dos detalles importantes: la contraseña de Ana solo la ve Keycloak (la SPA nunca la toca), y el gateway no llama a Keycloak en cada petición: verifica la firma localmente con la clave pública que descargó una vez.
- Anatomía de un JWT: estructura, claims y firma con JWKS
Un JWT son tres partes en Base64URL separadas por puntos: cabecera.carga.firma.
// Cabecera: { "alg": "RS256", "typ": "JWT", "kid": "k1-2026-08" }
// Carga (payload) del access token de Ana, emitido por Keycloak:
{
"iss": "https://auth.techcorp.example/realms/techcorp",
"sub": "3f2a9c1e-7b44-4d0a-9e51-2c8f0b6a1d77",
"aud": "techcorp-api",
"azp": "tienda-web",
"exp": 1786790400, "iat": 1786790100,
"scope": "openid pedidos:crear pedidos:leer",
"preferred_username": "ana.ruiz", "realm_access": { "roles": ["cliente"] }, "clienteId": "c-1024"
}
// Firma: RS256(base64url(cabecera) + "." + base64url(carga), clave privada del realm)iss(emisor),sub(identificador estable del sujeto),aud(para quién es el token),exp/iat(caducidad y emisión, en segundos Unix) yscopeson claims estándar (RFC 7519 y OAuth).azp(authorized party: el cliente que lo pidió),preferred_usernameyrealm_access.rolesson de Keycloak.clienteIdes un claim propio que añadiremos en el apartado 4.- La firma es RS256 (RSA + SHA-256, asimétrica): Keycloak firma con su clave privada; cualquiera verifica con la pública. Las claves públicas se publican en el JWKS (
JSON Web Key Set), cuya URL figura en el documento de descubrimientohttps://auth.techcorp.example/realms/techcorp/.well-known/openid-configuration(jwks_uri,token_endpoint,authorization_endpoint...). Elkidde la cabecera dice qué clave del conjunto usar, y por eso Keycloak puede rotar claves sin romper nada. - Un JWT no está cifrado: cualquiera con el token lee la carga (
echo <carga> | base64 -d). Nunca se meten en él datos sensibles; la firma garantiza integridad, no confidencialidad.
Los tres tokens que devuelve Keycloak:
| Token | Para quién | Contenido | Vida en TechCorp | Se envía a la API |
|---|---|---|---|---|
| Access token | El resource server (gateway, servicios) | Claims de autorización (aud, scope, roles) |
5 min | Sí, Authorization: Bearer |
| Refresh token | Solo Keycloak | Opaco para el cliente | 30 min deslizantes (sesión máx. 8 h) | Nunca |
| ID token | El cliente (SPA/app) | Identidad para la interfaz (name, email) |
5 min | Nunca |
Vidas cortas es la decisión clave: un access token robado sirve cinco minutos; el cliente lo renueva con el refresh token de forma transparente. Los servicios de TechCorp no aceptan ID tokens (aud distinto).
- Keycloak para TechCorp: realm, clientes, roles y claims propios
Keycloak se despliega en el clúster (imagen oficial, PostgreSQL propio, Ingress en auth.techcorp.example; el equipo Plataforma lo opera). Un realm es un espacio aislado de usuarios, clientes y claves; TechCorp usa uno: techcorp. Su configuración esencial, exportable como JSON e importable en el arranque (--import-realm):
{
"realm": "techcorp",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 28800,
"roles": { "realm": [ { "name": "cliente" }, { "name": "operador" }, { "name": "admin" }, { "name": "servicio" } ] },
"clientScopes": [
{ "name": "pedidos:crear", "protocol": "openid-connect" },
{ "name": "pedidos:leer", "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": "clienteId", "protocolMapper": "oidc-usermodel-attribute-mapper",
"config": { "user.attribute": "clienteId", "claim.name": "clienteId", "access.token.claim": "true", "jsonType.label": "String" } }
] }
],
"clients": [
{ "clientId": "tienda-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", "pedidos:crear", "pedidos:leer"] },
{ "clientId": "bff-movil", "publicClient": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "redirectUris": ["techcorp://callback"], "defaultClientScopes": ["techcorp-api", "pedidos:crear", "pedidos:leer"] },
{ "clientId": "servicio-pedidos", "publicClient": false, "standardFlowEnabled": false, "serviceAccountsEnabled": true,
"defaultClientScopes": ["techcorp-api", "productos:leer", "clientes:leer"] }
]
}Lo que cada línea decide:
publicClient: true+ PKCES256paratienda-web: una SPA no puede guardar secretos; PKCE sustituye al secreto.directAccessGrantsEnabled: falsedesactiva el password grant.serviceAccountsEnabled: trueystandardFlowEnabled: falseparaservicio-pedidos: solo client credentials; su cuenta de servicio recibe el rolservicio(conkcadm.sh add-roles --uusername service-account-servicio-pedidos --rolename servicio).- El client scope
techcorp-apiañadeaud: techcorp-apia todos los tokens (Keycloak no poneaudútil por defecto) y el claimclienteIddesde el atributo de usuarioclienteId, queservicio-clientesescribe en el alta a través de la API de administración de Keycloak (Clientes es conformist respecto a Keycloak, 02-03, y guardakeycloak_sub, 02-04). Así ningún servicio necesita traducirsub→clienteIdcon una llamada. - Los roles de realm
cliente,operador,adminse asignan a personas;servicio, a cuentas de servicio. Los scopespedidos:crear,pedidos:leer,productos:leer,clientes:leeracotan lo que cada cliente puede pedir, independientemente del usuario.
El client_secret de servicio-pedidos vive en el Secret pedidos-oidc de Kubernetes (OIDC_CLIENT_SECRET), junto a OIDC_ISSUER y OIDC_TOKEN_URL en el ConfigMap; su gestión y rotación son de 07-04.
- Validación en el gateway:
requiereToken completo con jose
requiereToken completo con joseSustituimos el esqueleto de 03-04. jose (npm install jose) es la librería de referencia en Node.js para JWT/JWK; createRemoteJWKSet descarga el JWKS, lo cachea y lo refresca solo si llega un kid desconocido (rotación) o pasa 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, // reutiliza las claves 10 min sin ir a Keycloak
cooldownDuration: 30_000 }); // si llega un kid desconocido, como mucho una descarga cada 30 s (protege a Keycloak de un ataque con kids inventados)
function problema401(res, req, detalle, errorOauth = 'invalid_token') { // RFC 6750: WWW-Authenticate dice cómo autenticarse y por qué falló
res.set('WWW-Authenticate', `Bearer realm="techcorp", error="${errorOauth}", error_description="${detalle}"`);
return res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'No autenticado', status: 401, codigo: 'NO_AUTENTICADO', detail: detalle, requestId: req.requestId });
}
async function requiereToken(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 exacto: un token de otro realm o de un Keycloak falso no pasa
audience: AUDIENCE, // aud debe contener techcorp-api: un ID token o un token para otra API no pasa
algorithms: ['RS256'], // nunca aceptar alg:none ni HS256 (con HS256 la "clave pública" serviría para firmar)
clockTolerance: 30 // 30 s de tolerancia de reloj entre Keycloak y el gateway
});
req.usuario = { sub: payload.sub, clienteId: payload.clienteId ?? 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 caducado');
req.log?.warn({ err: err.code }, 'token rechazado'); // el motivo real, solo al log
return problema401(res, req, 'Token no válido');
}
}
module.exports = { requiereToken };Y en gateway/servidor.js la propagación hacia dentro. Antes de poner las cabeceras propias, se eliminan las que vengan del exterior (nadie debe poder enviarnos X-Usuario-Roles: admin desde Internet):
// gateway/servidor.js (fragmentos que cambian respecto a 03-04)
const { requiereToken } = require('./auth');
app.use((req, _res, next) => { // 0. Higiene: las cabeceras internas solo las pone el gateway
for (const h of Object.keys(req.headers)) if (h.startsWith('x-usuario-')) delete req.headers[h];
next();
});
// ... requestId, cors, rateLimit, log de acceso como en 03-04 ...
// En proxyHacia(), dentro de on.proxyReq, además del X-Request-Id:
// if (req.usuario) { // solo rutas que pasaron por requiereToken
// proxyReq.setHeader('X-Usuario-Id', req.usuario.sub);
// proxyReq.setHeader('X-Usuario-Roles', req.usuario.roles.join(','));
// }
// La cabecera Authorization se reenvía tal cual: el servicio vuelve a verificar el JWT (apartado 6)
app.use('/api/v1/productos', proxyHacia(DESTINOS.catalogo, { quitarPrefijo: '/api' })); // pública
app.use('/api/v1/pedidos', requiereToken, proxyHacia(DESTINOS.pedidos, { quitarPrefijo: '/api' }));
app.use('/api/v1/clientes', requiereToken, proxyHacia(DESTINOS.clientes, { quitarPrefijo: '/api' }));
app.use('/api/graphql', requiereToken, proxyHacia(DESTINOS.bffMovil, { quitarPrefijo: '/api' }));Advertencia sobre
X-Usuario-*. Estas cabeceras solo son fiables si nadie salvo el gateway puede llegar aservicio-pedidos:3002. En la red del clúster eso hoy es una suposición, no una garantía: un pod comprometido en el mismo namespace podría llamar directamente al servicio con la cabecera que quiera. Por eso (a) los servicios vuelven a verificar el JWT (apartado 6) y usan las cabeceras solo como comodidad para logs, y (b) 07-02 y 07-04 cierran la red con NetworkPolicies, mTLS y control de quién habla con quién.
- Defensa en profundidad:
autenticar() en @techcorp/comun-http
autenticar() en @techcorp/comun-httpCada servicio verifica el token otra vez. Cuesta microsegundos (firma RSA con clave cacheada) y elimina la confianza ciega en la red. La librería expone autenticar(), requiereRol() y requiereScope(), con el JWKS inyectable para las pruebas del apartado 10:
// @techcorp/comun-http/src/auth.js
const { createRemoteJWKSet, jwtVerify, errors } = require('jose');
const { ErrorNegocio } = require('./errores');
function crearJwksRemoto(issuer) {
return createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`), { cacheMaxAge: 600_000, cooldownDuration: 30_000 });
}
// autenticar({ issuer, audience, jwks?, opcional? }) → middleware que deja req.usuario
function autenticar({ issuer, audience, jwks = crearJwksRemoto(issuer), opcional = false }) {
return async (req, res, next) => {
const auth = req.get('Authorization') ?? '';
if (!auth.startsWith('Bearer ')) {
if (opcional) return next(); // rutas públicas: sin usuario, pero sin error
res.set('WWW-Authenticate', 'Bearer realm="techcorp", error="invalid_request"');
return next(new ErrorNegocio('NO_AUTENTICADO', 'Falta el token', 401));
}
try {
const { payload } = await jwtVerify(auth.slice(7), jwks, { issuer, audience, algorithms: ['RS256'], clockTolerance: 30 });
req.usuario = { sub: payload.sub, clienteId: payload.clienteId ?? null, roles: payload.realm_access?.roles ?? [],
scopes: (payload.scope ?? '').split(' ').filter(Boolean), azp: payload.azp };
req.log?.setBindings?.({ usuarioId: payload.sub }); // correlación en los logs (06-01), sin datos personales
next();
} catch (err) {
const detalle = err instanceof errors.JWTExpired ? 'Token caducado' : 'Token no válido';
res.set('WWW-Authenticate', `Bearer realm="techcorp", error="invalid_token", error_description="${detalle}"`);
next(new ErrorNegocio('NO_AUTENTICADO', detalle, 401));
}
};
}
const requiereRol = (...roles) => (req, _res, next) =>
roles.some((r) => req.usuario?.roles.includes(r)) ? next() : next(new ErrorNegocio('PROHIBIDO', `Requiere rol ${roles.join(' o ')}`, 403));
const requiereScope = (scope) => (req, _res, next) =>
req.usuario?.scopes.includes(scope) ? next() : next(new ErrorNegocio('PROHIBIDO', `Requiere el scope ${scope}`, 403));
module.exports = { autenticar, requiereRol, requiereScope, crearJwksRemoto };Como lanzan ErrorNegocio, el middlewareErrores de 04-02 ya los convierte en problem+json con codigo NO_AUTENTICADO o PROHIBIDO. En servicio-pedidos, crearApp recibe la configuración de OIDC y monta el middleware antes de las rutas de negocio y después de /health/* (las sondas de Kubernetes no llevan token):
// servicio-pedidos/src/app.js (fragmento)
function crearApp({ repositorio, catalogoCliente, clientesCliente, logger, comprobacionesSalud = {}, oidc, jwks }) {
// ... middlewareRequestId, pinoHttp, express.json, crearRutasSalud (sin token) ...
app.use('/v1', autenticar({ issuer: oidc.issuer, audience: oidc.audience, jwks })); // todo /v1/* exige token
app.use(crearRutasPedidos({ crearPedido, repositorio, requiereScope }));
// ... 404 y middlewareErrores ...
}src/config.js (04-03) gana OIDC_ISSUER: z.string().url() y OIDC_AUDIENCE: z.string().default('techcorp-api'); oidc se construye desde ahí en servidor.js.
- Autorización: roles, propietario del recurso y scopes
Autenticado no significa autorizado. TechCorp combina tres capas, de la más gruesa a la más fina:
| Capa | Pregunta | Mecanismo | Ejemplo |
|---|---|---|---|
| RBAC (roles) | ¿Tiene el papel adecuado? | requiereRol('operador', 'admin') |
Listar todos los pedidos del día |
| Scopes (por cliente) | ¿Este cliente OAuth puede pedir esto? | requiereScope('pedidos:crear') |
bff-movil de solo lectura no crea pedidos |
| Recurso (propietario) | ¿Es suyo? | Regla en el caso de uso o la ruta | Ana solo ve sus pedidos |
| ABAC / OPA | ¿Cumple atributos y políticas externas? | Motor de políticas (Open Policy Agent, Cedar) | Solo mención: cuando las reglas crezcan más allá de lo que cabe en código |
La regla del propietario en GET /v1/pedidos/{id} y la decisión 403 o 404:
// servicio-pedidos/src/rutas/pedidos.js (fragmento GET, amplía el de 04-04)
enrutador.get('/v1/pedidos/:id', requiereScope('pedidos:leer'), async (req, res, next) => {
try {
const pedido = await repositorio.obtener(req.params.id);
if (!pedido) throw new ErrorNegocio('PEDIDO_NO_EXISTE', `No existe ${req.params.id}`, 404);
const esOperador = req.usuario.roles.some((r) => ['operador', 'admin', 'servicio'].includes(r));
if (!esOperador && pedido.clienteId !== req.usuario.clienteId) {
req.log.warn({ pedidoId: pedido.pedidoId, usuarioId: req.usuario.sub }, 'acceso a pedido ajeno'); // señal para 07-03 (auditoría)
throw new ErrorNegocio('PEDIDO_NO_EXISTE', `No existe ${req.params.id}`, 404); // política: 404, no 403
}
// ... ETag y respuesta como en 04-04 ...
} catch (err) { next(err); }
});¿Por qué 404 cuando el pedido es de otro cliente, si 03-01 decía 403? Porque un 403 confirma que el identificador existe, y los ped-NNNNN son fáciles de enumerar (OWASP lo llama BOLA, 07-03): un atacante sabría cuántos pedidos hay y cuáles son válidos. Decisión de TechCorp: para clientes, un pedido ajeno se comporta como inexistente (404 PEDIDO_NO_EXISTE); para operadores, que sí ven todos, no hay caso. 403 PROHIBIDO queda para "estás autenticado pero tu rol/scope no permite esta acción" (p. ej. un cliente que llama a POST /v1/pedidos/{id}/cancelacion de otro: ahí también 404, misma regla; un cliente que llama a GET /v1/pedidos?estado=PENDIENTE sin filtro de cliente: 403). El contrato OpenAPI de 03-01 se actualiza: el ACCESO_DENEGADO de su tabla pasa a ser PROHIBIDO, único código de 403 en toda la plataforma.
En POST /v1/pedidos la misma idea al revés: el cuerpo trae clienteId (03-01), pero manda el token. Si el usuario tiene rol cliente, clienteId del cuerpo debe coincidir con req.usuario.clienteId (si no, 403 PROHIBIDO: aquí no hay nada que ocultar); un operador puede crear pedidos en nombre de otro (venta telefónica). El caso de uso crearPedido recibe usuario como parte de la petición y aplica la regla, de modo que la prueba unitaria de 04-05 la cubre sin HTTP.
- Servicio a servicio: client credentials y qué pasa con RabbitMQ
Cuando servicio-pedidos llama a GET /v1/clientes/c-1024 no hay usuario delante (o lo hay, pero el pedido puede crearse también desde un job). Pedidos se autentica como servicio con client credentials, y cachea el token hasta poco antes de exp. En @techcorp/comun-http:
// @techcorp/comun-http/src/proveedorToken.js
function crearProveedorToken({ tokenUrl, clientId, clientSecret, margenSegundos = 30 }) {
let cache = { token: null, expiraEn: 0 };
let enCurso = null; // evita 50 peticiones simultáneas a Keycloak al arrancar
async function pedir() {
const cuerpo = 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: cuerpo, signal: AbortSignal.timeout(3000) });
if (!res.ok) throw new Error(`Keycloak devolvió ${res.status} al pedir token`);
const { access_token, expires_in } = await res.json();
cache = { token: access_token, expiraEn: Date.now() + (expires_in - margenSegundos) * 1000 };
return access_token;
}
return { async obtener() {
if (cache.token && Date.now() < cache.expiraEn) return cache.token;
enCurso ??= pedir().finally(() => { enCurso = null; });
return enCurso;
} };
}
module.exports = { crearProveedorToken };crearClienteHttp (06-03) gana una opción proveedorToken: si existe, añade Authorization: Bearer <token> a cada petición. En servidor.js de Pedidos:
const proveedorToken = crearProveedorToken({ tokenUrl: config.OIDC_TOKEN_URL, clientId: 'servicio-pedidos', clientSecret: config.OIDC_CLIENT_SECRET });
const clientesCliente = crearClienteHttp({ urlBase: config.CLIENTES_URL, nombre: 'clientes', proveedorToken });
const catalogoCliente = crearClienteHttp({ urlBase: config.CATALOGO_URL, nombre: 'catalogo', proveedorToken });En el destino, servicio-clientes acepta GET /v1/clientes/{id} si el token tiene rol servicio y scope clientes:leer, o si es el propio cliente (clienteId del token = {id}), o un operador. Catálogo mantiene GET /v1/productos con autenticar({ opcional: true }): la ruta es pública a través del gateway, pero si llega token lo verifica y podría, por ejemplo, devolver precios especiales por rol.
¿Y los eventos por RabbitMQ? Un mensaje pedido.creado no lleva JWT: no hay "petición" que autorizar, y un token de 5 minutos no tiene sentido en un mensaje que puede procesarse dentro de una hora desde una DLQ (06-03). La autenticación es del servicio ante el broker: usuario pedidos con contraseña en el Secret pedidos-rabbitmq, permisos por vhost, exchange y cola. Qué puede publicar y consumir cada usuario, y el cifrado del canal amqps://, se detallan en 07-02. Si un consumidor necesita saber quién originó el pedido, ese dato viaja en la carga del evento (clienteId), no como credencial.
- Errores 401 y 403, cierre de sesión y revocación
Convención cerrada de TechCorp, en formato RFC 7807 con codigo (03-01):
| Situación | Status | codigo |
Cabecera |
|---|---|---|---|
Sin token, mal formado, firma inválida, iss/aud incorrectos, caducado |
401 |
NO_AUTENTICADO |
WWW-Authenticate: Bearer realm="techcorp", error="invalid_token" (o invalid_request si falta) |
| Token válido, pero rol o scope insuficiente | 403 |
PROHIBIDO |
— |
| Token válido, recurso de otro cliente | 404 |
PEDIDO_NO_EXISTE (política del apartado 7) |
— |
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="techcorp", error="invalid_token", error_description="Token caducado"
{"type":"about:blank","title":"No autenticado","status":401,"codigo":"NO_AUTENTICADO","detail":"Token caducado","requestId":"req-9c1e…"}Un 401 con error="invalid_token" es la señal para que la SPA renueve con el refresh token y repita; un 403 no se reintenta.
Cierre de sesión y revocación. Un JWT firmado es válido hasta exp aunque el usuario cierre sesión: los servicios no preguntan a Keycloak. TechCorp lo resuelve con tokens cortos (5 min) más refresh tokens que Keycloak sí revoca (logout con end_session_endpoint, cambio de contraseña, cuenta desactivada): tras el logout, el peor caso son cinco minutos de un token ya emitido. Para corte inmediato (un admin dado de baja) existen la introspección (/token/introspect, una llamada a Keycloak por petición) o una lista de revocación por jti en Redis, solo mencionadas: TechCorp no las necesita en la primera fase.
- Pruebas: tokens firmados con una clave local en Jest
Las pruebas de 04-05 no deben depender de un Keycloak. Como autenticar() acepta el jwks como dependencia, en las pruebas se genera un par de claves y se firman tokens a medida:
// servicio-pedidos/test/soporte/tokens.js
const { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } = require('jose');
const ISSUER = 'https://auth.test/realms/techcorp', AUDIENCE = 'techcorp-api';
let claves;
async function iniciarClaves() { // par de claves nuevo en cada ejecución: nada que guardar en el repo
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'RS256', use: 'sig' };
claves = { privateKey, jwks: createLocalJWKSet({ keys: [jwk] }) };
return { issuer: ISSUER, audience: AUDIENCE, jwks: claves.jwks };
}
async function tokenDe({ sub = 'sub-ana', clienteId = 'c-1024', roles = ['cliente'], scopes = ['pedidos:crear', 'pedidos:leer'], expiraEn = '5m', aud = AUDIENCE } = {}) {
return new SignJWT({ realm_access: { roles }, scope: scopes.join(' '), clienteId, azp: 'tienda-web' })
.setProtectedHeader({ alg: 'RS256', kid: 'test-1' }).setIssuer(ISSUER).setAudience(aud).setSubject(sub)
.setIssuedAt().setExpirationTime(expiraEn).sign(claves.privateKey);
}
module.exports = { iniciarClaves, tokenDe };// servicio-pedidos/test/pedidos.auth.test.js
const request = require('supertest');
const { crearApp } = require('../src/app');
const { iniciarClaves, tokenDe } = require('./soporte/tokens');
let app;
beforeAll(async () => {
const oidc = await iniciarClaves();
app = crearApp({ repositorio: repositorioEnMemoria([pedidoDeAna]), catalogoCliente, clientesCliente, logger, oidc, jwks: oidc.jwks });
});
const get = (token) => request(app).get('/v1/pedidos/ped-88213').set('Authorization', `Bearer ${token}`);
test('sin token → 401 NO_AUTENTICADO con WWW-Authenticate', async () => {
const res = await request(app).get('/v1/pedidos/ped-88213');
expect(res.status).toBe(401); expect(res.body.codigo).toBe('NO_AUTENTICADO'); expect(res.headers['www-authenticate']).toMatch(/Bearer/);
});
test('Ana ve su pedido; otro cliente recibe 404; token caducado → 401', async () => {
expect((await get(await tokenDe())).status).toBe(200);
expect((await get(await tokenDe({ sub: 'sub-otro', clienteId: 'c-2048' }))).status).toBe(404);
expect((await get(await tokenDe({ expiraEn: '-1m' }))).body.detail).toBe('Token caducado');
});
test('sin scope pedidos:crear → 403 PROHIBIDO', async () => {
const res = await request(app).post('/v1/pedidos').set('Authorization', `Bearer ${await tokenDe({ scopes: ['pedidos:leer'] })}`).send(cuerpoPedidoDeAna);
expect(res.status).toBe(403); expect(res.body.codigo).toBe('PROHIBIDO');
});Las pruebas de contrato con Pact (04-05) siguen igual: el proveedor arranca con este JWKS local y el consumer declara la cabecera Authorization: Bearer <cualquiera> con un matcher de tipo; la firma no forma parte del contrato.
Errores Comunes y Consejos
- Aceptar cualquier
alg. Sinalgorithms: ['RS256'], un atacante puede enviaralg: noneoHS256firmado con la clave pública. Fíjalo siempre. - No comprobar
aud. Un ID token, o un access token emitido para otra API del mismo Keycloak, pasaría.audiencees obligatorio; por eso existe el client scopetechcorp-api. - Descargar el JWKS en cada petición o cachearlo para siempre.
createRemoteJWKSetconcacheMaxAgeycooldownDurationes el punto medio: rota claves sin reinicios y no convierte a Keycloak en punto único de fallo. - Confiar en
X-Usuario-*sin verificar el JWT en el servicio. Es la falla que 07-02 y 07-04 acotan; hasta entonces, la doble validación es la red de seguridad. - Poner el
client_secreten el ConfigMap, en la imagen o en un.envversionado. Va en el Secretpedidos-oidc(07-04) y nunca en logs: añade*.client_secretyOIDC_CLIENT_SECRETalredactde 06-01 y aconfigParaLog()de 04-03. - Usar
403para pedidos ajenos y regalar la enumeración de identificadores. Decide la política y aplícala en toda la plataforma. - Consejo: guarda en el log
usuarioId(elsub) yazp, nuncapreferred_usernameniemail: identifican a la persona y elredactde 06-01 no los cubre. - Consejo: las sondas
/health/*y/metricsno llevan token; montaautenticar()después de ellas o exponlas por otro puerto.
Ejercicios
Ejercicio 1. Un desarrollador propone que bff-movil use el password grant "porque la app tiene su propia pantalla de login y así no abrimos el navegador". Escribe tres motivos para rechazarlo y la alternativa concreta con Keycloak.
Ejercicio 2. Implementa en servicio-clientes la regla de autorización de GET /v1/clientes/{id} descrita en el apartado 8: lo puede leer el propio cliente, un operador/admin, o un token de servicio con clientes:leer. Decide qué devolver cuando un cliente pide el perfil de otro y justifícalo.
Ejercicio 3. El access token de servicio-pedidos caduca cada 5 minutos y Pedidos hace ~2 llamadas a Clientes por pedido (≈6.000/día). ¿Cuántas peticiones al token_endpoint hace Pedidos al día con crearProveedorToken (margen 30 s) y cuántas haría sin caché? ¿Qué pasa si Keycloak está caído 2 minutos con y sin caché?
Soluciones
Solución 1. (1) La contraseña de la persona pasa por código de TechCorp (el BFF y la app), ampliando la superficie: cualquier fallo en ellos la expone; con Authorization Code + PKCE solo la ve Keycloak. (2) Se pierden MFA, social login, políticas de contraseña y detección de fuerza bruta de Keycloak, que se aplican en su formulario. (3) Está desaconsejado por la RFC 6819 y eliminado en OAuth 2.1; Keycloak lo trae desactivado (directAccessGrantsEnabled: false) y activarlo es una excepción que hay que justificar en auditoría. Alternativa: Authorization Code + PKCE con navegador del sistema (ASWebAuthenticationSession/Custom Tabs, o la librería AppAuth), redirectUri techcorp://callback, y si se quiere una experiencia "sin salir de la app", personalizar el tema del login de Keycloak.
Solución 2.
enrutador.get('/v1/clientes/:id', async (req, res, next) => {
try {
const u = req.usuario, id = req.params.id;
const permitido = u.clienteId === id || u.roles.some((r) => ['operador', 'admin'].includes(r)) || (u.roles.includes('servicio') && u.scopes.includes('clientes:leer'));
const cliente = permitido ? await repositorio.obtener(id) : null; // no tocar la BD si no está permitido
if (!cliente) throw new ErrorNegocio('CLIENTE_NO_EXISTE', `No existe ${id}`, 404);
res.json(aDto(cliente));
} catch (err) { next(err); }
});Misma política que en Pedidos: para un cliente, otro cliente "no existe" (404), porque los c-NNNN son enumerables y un 403 confirmaría cuáles están dados de alta; y la comprobación va antes de consultar la base de datos para que el tiempo de respuesta tampoco delate la existencia.
Solución 3. Con caché: un token dura 300 s y se renueva a los 270 s (margen 30 s); en 24 h son 86.400 / 270 ≈ 320 peticiones al token_endpoint, independientemente del tráfico. Sin caché: una por llamada saliente, ≈ 6.000 (y ×2 en campañas), además de añadir la latencia de Keycloak a cada pedido. Si Keycloak cae 2 minutos: con caché, Pedidos sigue funcionando mientras el token vigente no caduque (hasta 4,5 min en el mejor caso; en el peor, si la caída coincide con la renovación, obtener() falla y crearClienteHttp lo traduce a DEPENDENCIA_NO_DISPONIBLE 503 con Retry-After, como cualquier dependencia de 06-03); sin caché, todos los pedidos fallan durante los 2 minutos. La caché convierte a Keycloak en una dependencia blanda para el flujo síncrono; los tokens ya emitidos a usuarios también siguen valiendo, porque los servicios verifican con el JWKS cacheado.
Conclusión
TechCorp ha pasado de "hay una cabecera Authorization" a una identidad verificable en todo el sistema. Keycloak, en el realm techcorp, autentica a personas con Authorization Code + PKCE (tienda-web, bff-movil) y a servicios con client credentials (servicio-pedidos), emite access tokens RS256 de 5 minutos con iss, aud: techcorp-api, scope, realm_access.roles y el claim propio clienteId, y publica sus claves en el JWKS. El gateway valida con jose (createRemoteJWKSet cacheado, jwtVerify con issuer, audience y algorithms), limpia y propaga X-Usuario-Id/X-Usuario-Roles, y responde 401 NO_AUTENTICADO con WWW-Authenticate; cada servicio vuelve a verificar con autenticar() de @techcorp/comun-http y autoriza con requiereRol, requiereScope y la regla del propietario (pedido ajeno → 404, rol insuficiente → 403 PROHIBIDO); Pedidos obtiene su token con crearProveedorToken y lo cachea; RabbitMQ autentica al servicio, no al mensaje; y las pruebas firman tokens con una clave local. Queda una suposición sin garantía: que las peticiones internas y las cabeceras X-Usuario-* viajan por una red en la que nadie escucha ni suplanta. Asegurar el canal —TLS en el borde, mTLS entre servicios, RabbitMQ y bases de datos cifradas, webhooks firmados— es la siguiente lección: seguridad en la comunicación.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
