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

  1. Autenticación frente a autorización, y por qué la sesión del monolito no vale
  2. OAuth 2.0 y OpenID Connect en la práctica: roles y flujos
  3. Anatomía de un JWT: estructura, claims y firma con JWKS
  4. Keycloak para TechCorp: realm, clientes, roles y claims propios
  5. Validación en el gateway: requiereToken completo con jose
  6. Defensa en profundidad: autenticar() en @techcorp/comun-http
  7. Autorización: roles, propietario del recurso y scopes
  8. Servicio a servicio: client credentials y qué pasa con RabbitMQ
  9. Errores 401 y 403, cierre de sesión y revocación
  10. Pruebas: tokens firmados con una clave local en Jest

  1. 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.

  1. 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.

  1. 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) y scope son claims estándar (RFC 7519 y OAuth). azp (authorized party: el cliente que lo pidió), preferred_username y realm_access.roles son de Keycloak. clienteId es 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 descubrimiento https://auth.techcorp.example/realms/techcorp/.well-known/openid-configuration (jwks_uri, token_endpoint, authorization_endpoint...). El kid de 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).

  1. 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 + PKCE S256 para tienda-web: una SPA no puede guardar secretos; PKCE sustituye al secreto. directAccessGrantsEnabled: false desactiva el password grant.
  • serviceAccountsEnabled: true y standardFlowEnabled: false para servicio-pedidos: solo client credentials; su cuenta de servicio recibe el rol servicio (con kcadm.sh add-roles --uusername service-account-servicio-pedidos --rolename servicio).
  • El client scope techcorp-api añade aud: techcorp-api a todos los tokens (Keycloak no pone aud útil por defecto) y el claim clienteId desde el atributo de usuario clienteId, que servicio-clientes escribe en el alta a través de la API de administración de Keycloak (Clientes es conformist respecto a Keycloak, 02-03, y guarda keycloak_sub, 02-04). Así ningún servicio necesita traducir subclienteId con una llamada.
  • Los roles de realm cliente, operador, admin se asignan a personas; servicio, a cuentas de servicio. Los scopes pedidos:crear, pedidos:leer, productos:leer, clientes:leer acotan 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.

  1. Validación en el gateway: requiereToken completo con jose

Sustituimos 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 a servicio-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.

  1. Defensa en profundidad: autenticar() en @techcorp/comun-http

Cada 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.

  1. 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.

  1. 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.

  1. 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 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.

  1. 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. Sin algorithms: ['RS256'], un atacante puede enviar alg: none o HS256 firmado 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. audience es obligatorio; por eso existe el client scope techcorp-api.
  • Descargar el JWKS en cada petición o cachearlo para siempre. createRemoteJWKSet con cacheMaxAge y cooldownDuration es 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_secret en el ConfigMap, en la imagen o en un .env versionado. Va en el Secret pedidos-oidc (07-04) y nunca en logs: añade *.client_secret y OIDC_CLIENT_SECRET al redact de 06-01 y a configParaLog() de 04-03.
  • Usar 403 para 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 (el sub) y azp, nunca preferred_username ni email: identifican a la persona y el redact de 06-01 no los cubre.
  • Consejo: las sondas /health/* y /metrics no llevan token; monta autenticar() 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

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados