Llega un correo al equipo de Tienda Aroma. CataBox, una aplicación de terceros que ayuda a los aficionados al café a llevar un cuaderno de catas, quiere ofrecer a sus usuarios que importen automáticamente los cafés que han comprado en Aroma. La petición técnica es simple: «¿podemos leer los pedidos de un cliente vuestro?».

Con la autenticación que construimos en 03-06 solo hay una respuesta posible, y es mala: que el cliente le dé a CataBox su correo y su contraseña de Tienda Aroma, y que CataBox llame a POST /v1/sesiones haciéndose pasar por él. Eso significa que una empresa que no controlas guarda las contraseñas de tus clientes, que el token que obtiene tiene todos los permisos —incluido crear pedidos y cambiar la dirección de envío—, que no puedes revocarle el acceso sin obligar al cliente a cambiar la contraseña, y que no tienes forma de saber qué peticiones son del cliente y cuáles de CataBox.

OAuth 2.0 existe exactamente para eso. Esta lección explica qué problema resuelve, cómo se articula, qué flujos se usan hoy y cuáles están desaconsejados, cómo OpenID Connect añade encima la pieza que le falta —la identidad—, y cómo se valida un token de un servidor de autorización externo en nuestra API. Al terminar, src/middleware/autenticacion-oauth.js existirá y convivirá con el autenticar de 03-06.

Advertencia. OAuth 2.0 es un protocolo de seguridad y sus detalles importan: un parámetro mal validado convierte un flujo correcto en una toma de cuentas. El código de esta lección es didáctico y debe revisarlo un profesional de seguridad antes de un despliegue real. Todos los datos, dominios y credenciales son ficticios.

Contenido

  1. El problema: delegar acceso sin compartir credenciales
  2. Los cuatro roles de OAuth 2.0
  3. Tokens: acceso, refresco y ámbitos
  4. Tokens opacos frente a JWT: introspección o validación local
  5. Authorization Code + PKCE
  6. Por qué el flujo implícito y el de contraseña están desaconsejados
  7. Client Credentials: RápidoEnvíos, máquina a máquina
  8. Refresh Token y Device Code
  9. state, CSRF y los parámetros de la petición de autorización
  10. OpenID Connect: la identidad encima de OAuth
  11. Validar el token en la API: JWKS, kid y las claims
  12. El middleware autenticarOAuth y su convivencia con autenticar
  13. Ámbitos y roles combinados
  14. Registro de clientes, secretos y redirect_uri
  15. Revocación y cierre de sesión
  16. Por qué no debes implementar tu propio servidor de autorización
  17. Errores de OAuth y su traducción al catálogo de Aroma

  1. El problema: delegar acceso sin compartir credenciales

Antes de OAuth, el patrón habitual se llamaba antipatrón de la contraseña compartida, y así se veía:

Contraseña compartida OAuth 2.0
Qué guarda CataBox Correo y contraseña del cliente Un token que caduca
Qué puede hacer Todo lo que puede hacer el cliente Solo lo autorizado (pedidos.leer)
Cuánto dura Para siempre Minutos u horas, con refresco revocable
Cómo se revoca Cambiando la contraseña (y rompiendo todo lo demás) Un clic en «aplicaciones conectadas»
Auditoría Imposible distinguir cliente de aplicación Cada petición identifica al cliente OAuth
Segundo factor Incompatible: CataBox no puede pasarlo Compatible: lo resuelve el servidor de autorización
Si CataBox sufre una brecha Las contraseñas de tus clientes están fuera Se revocan los tokens y se acabó

La idea central de OAuth 2.0 cabe en una frase: el cliente nunca ve las credenciales; el usuario se autentica en un sitio de confianza y lo que vuelve es un permiso acotado y revocable.

Y una precisión que evita el 90 % de la confusión inicial: OAuth 2.0 es un protocolo de autorización delegada, no de autenticación. No sirve para «iniciar sesión con», por mucho que se use así en todas partes. Lo que sirve para eso es OpenID Connect, que se construye encima (apartado 10).

  1. Los cuatro roles de OAuth 2.0

Rol Nombre en el estándar Quién es en nuestro caso
Propietario del recurso Resource Owner Marta García (cli_842), la persona dueña de los pedidos
Cliente Client CataBox, la aplicación que quiere acceder. También la SPA y Aroma Móvil
Servidor de autorización Authorization Server (AS) https://auth.tiendaaroma.example — autentica y emite tokens
Servidor de recursos Resource Server (RS) https://api.tiendaaroma.example/v1nuestra API

La consecuencia práctica más importante para este curso: nuestra API es solo el servidor de recursos. No muestra pantallas de inicio de sesión, no gestiona contraseñas, no emite tokens y no sabe nada de consentimientos. Su único trabajo en OAuth es:

  1. Recibir un Authorization: Bearer <token>.
  2. Verificar que ese token es auténtico, vigente y está dirigido a ella.
  3. Extraer quién es el sujeto y qué ámbitos tiene.
  4. Decidir si esa combinación puede hacer lo que pide.

Eso es todo. Nada más de esta lección se implementa dentro de nuestra API, y esa separación es precisamente el valor del protocolo.

graph LR
  U[Marta - propietario del recurso] -->|1. autoriza| AS[auth.tiendaaroma.example<br/>Servidor de autorizacion]
  C[CataBox - cliente] -->|2. pide token| AS
  AS -->|3. emite access token| C
  C -->|4. Bearer token| RS[api.tiendaaroma.example<br/>Servidor de recursos: NUESTRA API]
  RS -->|5. valida firma con JWKS| AS
  RS -->|6. datos autorizados| C

Fíjate en el paso 5: la API no pregunta al servidor de autorización en cada petición. Descarga sus claves públicas una vez, las cachea y verifica localmente. Por qué, en el apartado 4.

  1. Tokens: acceso, refresco y ámbitos

Access token y refresh token

Access token Refresh token
Para qué sirve Acceder a la API Obtener un access token nuevo
A quién se presenta Al servidor de recursos (nuestra API) Solo al servidor de autorización
Duración típica 5–60 minutos Días o meses
Se envía en Authorization: Bearer Cuerpo de POST /token
Si se roba Daño acotado por el tiempo de vida Grave: acceso prolongado
Nuestra API lo ve Sí, en cada petición Nunca

La razón de que existan dos es un compromiso entre seguridad y usabilidad: quieres que el token que viaja constantemente por la red caduque pronto, pero no quieres pedirle la contraseña al usuario cada quince minutos. El refresh token viaja poco, se guarda mejor y se puede revocar de forma centralizada.

Esta arquitectura es la misma que ya construimos a mano en 03-06 con nuestros propios access y refresh tokens. La diferencia es quién los emite: allí los emitía nuestra API; aquí los emite un servidor de autorización especializado, y nuestra API solo los verifica.

Ámbitos (scopes)

Un ámbito es una etiqueta que acota lo que un token permite hacer. Se piden en la petición de autorización, el usuario los ve en la pantalla de consentimiento y viajan dentro del token.

Los de Tienda Aroma:

Ámbito Permite Quién lo pide Texto de consentimiento
cafes.leer Consultar el catálogo Todos «Ver el catálogo de cafés»
pedidos.leer Leer los pedidos del usuario CataBox, SPA, Aroma Móvil «Ver tu historial de pedidos»
pedidos.escribir Crear y pagar pedidos SPA, Aroma Móvil «Crear pedidos en tu nombre»
resenas.escribir Publicar reseñas SPA, Aroma Móvil «Publicar reseñas con tu nombre»
resenas.moderar Aprobar o rechazar reseñas Panel interno «Moderar reseñas de la tienda»
envios.escribir Marcar pedidos como enviados RápidoEnvíos (sin consentimiento: máquina)

Cuatro reglas de diseño de ámbitos que evitan la mayoría de los problemas:

  1. Granularidad recurso.accion, no un ámbito por endpoint. Con 24 URIs, un ámbito por endpoint produce una pantalla de consentimiento ilegible.
  2. Separar lectura de escritura siempre. CataBox pide pedidos.leer y jamás pedidos.escribir. Esta separación es el 80 % del valor.
  3. El ámbito acota, no concede. Que un token tenga pedidos.leer no significa que pueda leer todos los pedidos: significa que puede leer los del sujeto del token. La comprobación de propiedad (BOLA, 04-02) sigue siendo obligatoria.
  4. Redactar el texto que verá el usuario junto con el ámbito. Si no sabes explicarlo en una línea comprensible, el ámbito está mal diseñado.

  1. Tokens opacos frente a JWT: introspección o validación local

El access token puede ser de dos naturalezas, y la elección afecta directamente a cómo lo valida nuestra API.

Token opaco JWT (autocontenido)
Qué es Una cadena aleatoria: a7f3c9... Tres partes Base64 con las claims dentro
Quién sabe qué significa Solo el servidor de autorización Cualquiera que lo verifique
Cómo lo valida la API Introspección: POST /introspect al AS Localmente: verifica la firma
Latencia por petición Una llamada de red extra Cero
Revocación inmediata No: vale hasta su exp
Filtración del contenido No revela nada El payload es legible (¡no cifrado!)
Acoplamiento La API depende del AS en caliente La API solo necesita las claves públicas

La introspección (RFC 7662) se ve así:

POST /introspect HTTP/1.1
Host: auth.tiendaaroma.example
Authorization: Basic <credenciales del servidor de recursos>
Content-Type: application/x-www-form-urlencoded

token=a7f3c9d2e8b1...
{
  "active": true,
  "sub": "cli_842",
  "scope": "cafes.leer pedidos.leer",
  "client_id": "catabox",
  "exp": 1786000000
}

El campo decisivo es active: si es false, el token no vale, sin más explicaciones.

Qué elige Tienda Aroma: JWT con validación local, por tres razones. La latencia importa (una llamada extra por petición multiplica por dos el tiempo de respuesta del catálogo); la disponibilidad importa (si el AS cae, con introspección cae toda la API); y la revocación inmediata la conseguimos por otra vía, con tokens de vida corta (15 minutos) y revocación del refresh token.

El compromiso: un access token JWT revocado sigue siendo válido hasta que caduque. Si eso es inaceptable para alguna operación —anular un pedido, cambiar la contraseña—, se hace introspección solo para esas operaciones, o se consulta una lista de revocación en Redis. Es una decisión por endpoint, no global.

Y un recordatorio que nunca sobra: un JWT está firmado, no cifrado. Cualquiera que intercepte el token lee su contenido con un decodificador Base64. Nunca metas datos sensibles en las claims.

  1. Authorization Code + PKCE

Es el flujo. Si solo recuerdas uno de esta lección, que sea este: sirve para SPA, para aplicaciones móviles, para aplicaciones de servidor y para aplicaciones de terceros como CataBox.

PKCE (Proof Key for Code Exchange, se pronuncia «pixy») es la extensión que lo hace seguro para clientes que no pueden guardar un secreto. Hoy se considera obligatorio para todos los clientes, incluidos los confidenciales.

sequenceDiagram
  participant U as Marta (navegador)
  participant C as CataBox
  participant AS as auth.tiendaaroma.example
  participant RS as api.tiendaaroma.example

  C->>C: 1. Genera code_verifier aleatorio
  C->>C: 2. code_challenge = BASE64URL(SHA256(verifier))
  C->>U: 3. Redirige a /authorize con code_challenge y state
  U->>AS: 4. GET /authorize?...
  AS->>U: 5. Pantalla de login (contrasena + 2FA)
  U->>AS: 6. Credenciales
  AS->>U: 7. Pantalla de consentimiento: "CataBox quiere ver tus pedidos"
  U->>AS: 8. Acepta
  AS->>U: 9. Redirige a redirect_uri?code=xyz&state=...
  U->>C: 10. Entrega el code
  C->>AS: 11. POST /token con code + code_verifier
  AS->>AS: 12. Comprueba SHA256(verifier) == challenge
  AS->>C: 13. access_token + refresh_token
  C->>RS: 14. GET /v1/pedidos con Bearer
  RS->>RS: 15. Verifica firma (JWKS), iss, aud, exp, scope
  RS->>C: 16. 200 con los pedidos de Marta

Veamos las peticiones reales.

Paso 3-4: la petición de autorización. Ocurre en el navegador del usuario, no en el servidor de CataBox:

GET /authorize
  ?response_type=code
  &client_id=catabox
  &redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
  &scope=cafes.leer%20pedidos.leer
  &state=xY9fK2mQ7pL1
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: auth.tiendaaroma.example

Paso 9: la redirección de vuelta. El código llega por la URL, y es de un solo uso y vida muy corta (típicamente 30–60 segundos):

HTTP/1.1 302 Found
Location: https://catabox.example/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xY9fK2mQ7pL1

Paso 11: el canje del código por el token. Esto es una petición de servidor a servidor (o desde la app), nunca una redirección:

POST /token HTTP/1.1
Host: auth.tiendaaroma.example
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
&client_id=catabox
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFyb21hLTIwMjYtMDgifQ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "def50200a1b2c3...",
  "scope": "cafes.leer pedidos.leer"
}

Qué resuelve exactamente PKCE

El código de autorización viaja por la URL del navegador, y eso es un canal poco seguro: queda en el historial, puede aparecer en los logs de un proxy y, en móvil, otra aplicación puede registrar el mismo esquema de URI y robar la redirección. Antes de PKCE, un atacante que capturara el código podía canjearlo por un token, porque el AS no tenía forma de saber que quien canjea no es quien pidió.

PKCE ata las dos peticiones con un secreto de un solo uso:

// Cliente (SPA o app móvil). Se ejecuta ANTES de redirigir al usuario.
import crypto from 'node:crypto';

function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')      // Base64URL: + → -
    .replace(/\//g, '_')      //             / → _
    .replace(/=+$/, '');      //  sin relleno
}

// 1. Un secreto aleatorio de 32 bytes, distinto en cada intento de login.
const codeVerifier = base64url(crypto.randomBytes(32));

// 2. Su hash: es lo ÚNICO que viaja por la URL del navegador.
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());

// 3. El verifier se guarda localmente (sessionStorage en la SPA) y NO se envía todavía.
sessionStorage.setItem('pkce_verifier', codeVerifier);

// 4. Solo en el paso 11, junto con el código, se envía el verifier original.
//    El AS calcula SHA256(verifier) y lo compara con el challenge que recibió en el paso 4.

La propiedad criptográfica que lo hace funcionar: SHA-256 no se puede invertir. Un atacante que vea el code_challenge en la URL no puede deducir el code_verifier, así que aunque robe el código no puede canjearlo. Y code_challenge_method debe ser siempre S256; el valor plain existe por compatibilidad y no protege de nada.

  1. Por qué el flujo implícito y el de contraseña están desaconsejados

Los encontrarás en tutoriales antiguos. Las mejores prácticas actuales de OAuth 2.0 (y OAuth 2.1) los retiran.

Flujo Cómo funcionaba Por qué se retira Qué usar
Implícito (response_type=token) El AS devolvía el access token directamente en el fragmento de la URL El token queda en el historial, en el Referer y expuesto a cualquier script de la página; sin refresh token seguro Authorization Code + PKCE
Contraseña del propietario (password) La app pide usuario y contraseña y las manda al AS Reintroduce el antipatrón que OAuth vino a eliminar; incompatible con 2FA y con inicio de sesión federado Authorization Code + PKCE

El caso del flujo de contraseña merece un matiz porque genera dudas legítimas: se creó para aplicaciones de primera parte, es decir, tuyas. Pero incluso ahí es mala idea, porque tu propia app acaba manejando contraseñas, no puede pasar por un segundo factor y no se beneficia de nada de la infraestructura del AS. La SPA de Tienda Aroma y Aroma Móvil usan Authorization Code + PKCE igual que CataBox, aunque sean nuestras. La diferencia entre app propia y de terceros es que a la propia se le puede saltar la pantalla de consentimiento, no que use otro flujo.

  1. Client Credentials: RápidoEnvíos, máquina a máquina

Cuando no hay ningún usuario implicado, no hay a quién pedir consentimiento. RápidoEnvíos es un sistema que actúa en su propio nombre para marcar pedidos como enviados.

sequenceDiagram
  participant R as RapidoEnvios (backend)
  participant AS as auth.tiendaaroma.example
  participant RS as api.tiendaaroma.example

  R->>AS: POST /token (grant_type=client_credentials + client_secret)
  AS->>R: access_token (scope=envios.escribir, 1 h)
  R->>RS: POST /v1/pedidos/ped_5001/envio (Bearer)
  RS->>RS: Verifica firma, aud, scope=envios.escribir
  RS->>R: 200
POST /token HTTP/1.1
Host: auth.tiendaaroma.example
Authorization: Basic cmFwaWRvZW52aW9zOnNlY3JldG9GaWN0aWNpbw==
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=envios.escribir

Tres características que lo distinguen:

  • No hay redirect_uri, ni code, ni consentimiento: solo el cliente y su secreto.
  • No hay refresh token: cuando el access token caduca, se pide otro. Es barato porque no requiere a nadie delante.
  • El sub del token es el propio cliente (rapidoenvios), no una persona. En nuestra API eso corresponde al rol socio, y significa que las comprobaciones de propiedad por cliente no aplican: hay que autorizar por otra vía (qué pedidos puede tocar RápidoEnvíos y en qué estados).

Un cliente que usa este flujo es confidencial por definición: guarda un secreto en un servidor. Jamás se usa en una SPA ni en una app móvil, porque cualquiera puede extraer el secreto del código descargado.

  1. Refresh Token y Device Code

Refresh Token

POST /token HTTP/1.1
Host: auth.tiendaaroma.example
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=def50200a1b2c3...
&client_id=catabox

La respuesta es un access token nuevo y, en las implementaciones modernas, también un refresh token nuevo. Eso se llama rotación de refresh tokens y es una defensa importante: si un refresh token se usa dos veces, el AS sabe que uno de los dos usos es de un atacante —porque el legítimo ya recibió el sustituto— y revoca toda la familia de tokens, cerrando la sesión. Es la detección de reutilización, y conviene exigirla al elegir proveedor.

Dónde se guarda el refresh token, por tipo de cliente:

Cliente Almacenamiento Riesgo
Backend (CataBox) Base de datos cifrada Bajo
App móvil Llavero del sistema (Keychain / Keystore) Bajo
SPA Cookie HttpOnly+Secure+SameSite, gestionada por un backend propio Medio
SPA localStorage Alto: cualquier XSS lo roba

Para SPA, la recomendación actual es el patrón BFF (Backend For Frontend): un pequeño backend propio guarda los tokens y la SPA habla con él mediante una cookie de sesión. Si eso no es viable, refresh tokens rotativos con vida corta y nunca en localStorage.

Device Code

Para dispositivos sin teclado ni navegador —una pantalla de la cafetería que muestra el stock, un televisor—. El dispositivo pide un código, muestra al usuario «entra en tiendaaroma.example/activar e introduce KDLF-9XZQ», y mientras tanto consulta al AS hasta que el usuario completa la autorización en su móvil. Se menciona por completitud; no aplica a los consumidores actuales de Tienda Aroma.

  1. state, CSRF y los parámetros de la petición de autorización

Parámetro Obligatorio Qué es Riesgo si se omite o no se valida
response_type code
client_id Identificador público del cliente
redirect_uri Sí (recomendado) A dónde volver Redirección abierta si el AS no la compara exacta
scope Recomendado Permisos pedidos Se aplican los del registro
state Valor aleatorio opaco CSRF de inicio de sesión
code_challenge Hash del verifier Robo del código
code_challenge_method S256 plain no protege
nonce Sí en OIDC Aleatorio, vuelve en el id_token Reproducción de id_token
prompt No login, consent, none

Qué ataque evita state. Sin él, un atacante inicia el flujo con su cuenta de Tienda Aroma, captura el code de la redirección y engaña a la víctima para que su navegador visite https://catabox.example/callback?code=<el del atacante>. CataBox canjea ese código y asocia la cuenta de Tienda Aroma del atacante a la sesión de CataBox de la víctima. A partir de ahí, todo lo que la víctima guarde en CataBox va a una cuenta que el atacante controla.

La defensa es un valor aleatorio ligado a la sesión del navegador:

// Antes de redirigir: se genera y se guarda ligado a la sesión.
const state = base64url(crypto.randomBytes(16));
sessionStorage.setItem('oauth_state', state);
// ... redirección a /authorize?...&state=<state>

// En el callback: comparación OBLIGATORIA antes de canjear el código.
const recibido = new URLSearchParams(location.search).get('state');
const esperado = sessionStorage.getItem('oauth_state');
if (!recibido || recibido !== esperado) {
  throw new Error('state no coincide: posible CSRF. No se canjea el código.');
}
sessionStorage.removeItem('oauth_state');   // un solo uso

Con PKCE bien implementado el riesgo se reduce mucho, pero state sigue siendo obligatorio: protege de un ataque distinto (la fijación de la sesión del cliente, no el robo del código) y además sirve para recordar a dónde volver dentro de la aplicación.

  1. OpenID Connect: la identidad encima de OAuth

OAuth 2.0 responde a «¿puede esta aplicación hacer esto?». No responde a «¿quién es el usuario?». Usar un access token como prueba de identidad es un error clásico y peligroso: el token no dice nada verificable sobre quién lo obtuvo ni para qué aplicación se emitió, y por eso existieron los ataques de sustitución de token.

OpenID Connect (OIDC) es una capa fina y estandarizada sobre OAuth 2.0 que añade exactamente lo que falta.

OAuth 2.0 OpenID Connect
Pregunta ¿Qué puede hacer? ¿Quién es?
Ámbito clave pedidos.leer openid
Devuelve access_token access_token + id_token
Formato del resultado Libre id_token es siempre un JWT
Destinatario El servidor de recursos El cliente
Descubrimiento /.well-known/openid-configuration

Basta con añadir openid al scope para que el flujo se convierta en OIDC:

scope=openid profile email cafes.leer pedidos.leer

El id_token decodificado:

{
  "iss": "https://auth.tiendaaroma.example",
  "sub": "cli_842",
  "aud": "catabox",
  "exp": 1786003600,
  "iat": 1786000000,
  "nonce": "n-0S6_WzA2Mj",
  "auth_time": 1785999950,
  "name": "Marta García",
  "email": "[email protected]",
  "email_verified": true,
  "locale": "es-ES"
}

Las claims estándar más usadas:

Claim Significado
sub Identificador estable y único del usuario en ese emisor. La clave real
iss Quién emitió el token
aud Para qué cliente se emitió
nonce El aleatorio que envió el cliente: evita reproducir un id_token viejo
auth_time Cuándo se autenticó realmente (útil para exigir reautenticación)
name, email, picture Perfil (con scope=profile email)
email_verified Si el emisor verificó ese correo

Dos advertencias que causan bugs reales:

  • La clave del usuario es sub, nunca email. Un correo se puede cambiar, se puede reasignar y puede llegar sin verificar. Vincular cuentas por correo es una vía de toma de cuenta si email_verified es false.
  • El id_token es para el cliente, no para la API. No se envía en Authorization: Bearer al servidor de recursos. Nuestra API valida el access token; el id_token lo consume CataBox para saber a quién ha conectado.

El endpoint /userinfo completa el cuadro: se llama con el access token y devuelve las claims de perfil actualizadas. Se usa cuando el id_token se emitió hace tiempo o cuando se prefiere no engordarlo.

GET /userinfo HTTP/1.1
Host: auth.tiendaaroma.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Y el documento de descubrimiento, que evita configurar URLs a mano:

GET /.well-known/openid-configuration HTTP/1.1
Host: auth.tiendaaroma.example
{
  "issuer": "https://auth.tiendaaroma.example",
  "authorization_endpoint": "https://auth.tiendaaroma.example/authorize",
  "token_endpoint": "https://auth.tiendaaroma.example/token",
  "userinfo_endpoint": "https://auth.tiendaaroma.example/userinfo",
  "jwks_uri": "https://auth.tiendaaroma.example/.well-known/jwks.json",
  "revocation_endpoint": "https://auth.tiendaaroma.example/revoke",
  "introspection_endpoint": "https://auth.tiendaaroma.example/introspect",
  "scopes_supported": ["openid", "profile", "email", "cafes.leer", "pedidos.leer",
                       "pedidos.escribir", "resenas.moderar", "envios.escribir"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256"]
}

De ahí sale el jwks_uri, que es lo único que nuestra API necesita.

  1. Validar el token en la API: JWKS, kid y las claims

Aquí entra por fin nuestro código. La mecánica del JWT ya la vimos en 03-06; lo nuevo son dos cosas: la firma es asimétrica (el AS firma con su clave privada, nosotros verificamos con su clave pública) y la clave pública se descubre dinámicamente mediante JWKS.

Qué es JWKS

JWKS (JSON Web Key Set) es un documento público con las claves de verificación del emisor:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-08",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4...",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-05",
      "alg": "RS256",
      "n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23V0dpBB...",
      "e": "AQAB"
    }
  ]
}

Que haya dos claves a la vez no es un error: es la rotación. El AS empieza a firmar con aroma-2026-08 mientras los tokens firmados con aroma-2026-05 siguen vigentes hasta caducar. La cabecera de cada JWT indica cuál usar:

{ "alg": "RS256", "typ": "JWT", "kid": "aroma-2026-08" }

Esto resuelve de forma nativa el problema de rotación de secretos que planteamos en 04-02: no hay que redesplegar nada, la API descubre la clave nueva sola.

El middleware

npm install jose

jose es la biblioteca estándar de facto para JOSE/JWT en Node: implementa la caché de JWKS, la rotación y las verificaciones del estándar.

// src/config/oauth.js  (fichero NUEVO)
import { createRemoteJWKSet } from 'jose';
import { entorno } from './entorno.js';

/**
 * Conjunto remoto de claves públicas del servidor de autorización.
 *
 * createRemoteJWKSet devuelve una función que 'jose' usa para resolver la clave
 * a partir del 'kid' de la cabecera del token. Internamente:
 *  - descarga el JWKS la primera vez y lo mantiene en memoria;
 *  - si llega un 'kid' desconocido, vuelve a descargarlo (rotación automática);
 *  - limita esas recargas para que un token con 'kid' inventado no se convierta
 *    en un ataque de denegación de servicio contra el AS.
 */
export const jwks = createRemoteJWKSet(new URL(entorno.OAUTH_JWKS_URI), {
  cooldownDuration: 30_000,     // no rebusca más de una vez cada 30 s
  cacheMaxAge: 600_000,         // refresca el juego de claves cada 10 min
  timeoutDuration: 5_000,       // corta si el AS no responde en 5 s
});

export const OAUTH = {
  emisor: entorno.OAUTH_EMISOR,          // https://auth.tiendaaroma.example
  audiencia: entorno.OAUTH_AUDIENCIA,    // https://api.tiendaaroma.example
  algoritmos: ['RS256'],                 // lista blanca cerrada
  toleranciaReloj: '30s',
};
// src/middleware/autenticacion-oauth.js  (fichero NUEVO)
import { jwtVerify, errors as erroresJose } from 'jose';
import { jwks, OAUTH } from '../config/oauth.js';
import { errores } from '../errores/error-api.js';

/**
 * Autentica la petición con un access token emitido por el servidor de
 * autorización externo. Deja en req.usuario el sujeto y sus ámbitos.
 */
export async function autenticarOAuth(req, res, next) {
  const cabecera = req.get('Authorization') ?? '';

  // 1. El esquema debe ser exactamente 'Bearer'.
  const [esquema, token] = cabecera.split(' ');
  if (esquema !== 'Bearer' || !token) {
    res.set('WWW-Authenticate', `Bearer realm="api.tiendaaroma.example"`);
    return next(errores.noAutenticado('no_autenticado', 'Falta el token de acceso.'));
  }

  try {
    // 2. Verificación completa: firma + claims registradas.
    const { payload } = await jwtVerify(token, jwks, {
      issuer: OAUTH.emisor,             // iss debe ser NUESTRO servidor de autorización
      audience: OAUTH.audiencia,        // aud debe ser NUESTRA API
      algorithms: OAUTH.algoritmos,     // solo RS256: bloquea alg:none y confusión HS/RS
      clockTolerance: OAUTH.toleranciaReloj,   // margen para relojes desajustados
    });
    // jwtVerify ya ha comprobado exp (no caducado) y nbf (no usado antes de tiempo).

    // 3. Los ámbitos llegan como una cadena separada por espacios.
    const ambitos = new Set((payload.scope ?? '').split(' ').filter(Boolean));

    // 4. Sujeto normalizado: la API lo usa igual que el de 03-06.
    req.usuario = {
      id: payload.sub,                      // cli_842, o 'rapidoenvios' en client credentials
      rol: payload.rol ?? deducirRol(payload),
      ambitos,
      clienteOauth: payload.client_id ?? payload.azp ?? null,   // quién actúa: catabox, spa...
      origenToken: 'oauth',
    };
    return next();
  } catch (error) {
    return next(traducirErrorJose(error, res));
  }
}

/**
 * Traduce los errores de 'jose' al catálogo de Tienda Aroma y fija
 * WWW-Authenticate según el RFC 6750 (Bearer Token Usage).
 */
function traducirErrorJose(error, res) {
  if (error instanceof erroresJose.JWTExpired) {
    res.set('WWW-Authenticate',
      `Bearer error="invalid_token", error_description="The access token expired"`);
    return errores.noAutenticado('token_caducado', 'El token de acceso ha caducado.');
  }
  if (error instanceof erroresJose.JWKSNoMatchingKey) {
    // 'kid' desconocido: token de otro emisor o clave retirada.
    res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
    return errores.noAutenticado('no_autenticado', 'El token de acceso no es válido.');
  }
  if (error instanceof erroresJose.JWKSTimeout) {
    // El servidor de autorización no responde: NO es culpa del cliente.
    return errores.servicioNoDisponible(
      'servicio_no_disponible',
      'No se ha podido verificar el token en este momento.'
    );
  }
  res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
  return errores.noAutenticado('no_autenticado', 'El token de acceso no es válido.');
}

/**
 * Exige uno o varios ámbitos. Se compone DESPUÉS de autenticarOAuth.
 */
export function exigirAmbito(...requeridos) {
  return (req, res, next) => {
    const tiene = requeridos.every((a) => req.usuario?.ambitos?.has(a));
    if (!tiene) {
      res.set('WWW-Authenticate',
        `Bearer error="insufficient_scope", scope="${requeridos.join(' ')}"`);
      return next(
        errores.permisoDenegado(
          'permisos_insuficientes',
          `El token no incluye el ámbito requerido: ${requeridos.join(', ')}.`
        )
      );
    }
    return next();
  };
}

Las claims que hay que verificar, y qué pasa si te saltas alguna:

Claim Qué comprueba Si no la validas
firma Que lo emitió quien dice Cualquiera fabrica tokens
iss Quién lo emitió Aceptas tokens de otro emisor cualquiera
aud Para quién es Aceptas un token emitido para otra API: el ataque de confusión de audiencia
exp No caducado Los tokens no caducan nunca
nbf No usado antes de tiempo Aceptas tokens pre-emitidos
alg (lista blanca) Algoritmo esperado alg:none y confusión RS/HS
scope Permiso concreto Cualquier token vale para todo

aud es la que más se olvida y una de las más graves. Si un usuario tiene un token para otra aplicación del mismo servidor de autorización y tu API no comprueba aud, ese token abre tu API.

Sobre el reloj desincronizado: exp y nbf son instantes absolutos. Si el reloj de tu servidor va treinta segundos adelantado, rechazarás tokens recién emitidos con un token_caducado incomprensible. Por eso clockTolerance, y por eso los servidores llevan NTP. Una tolerancia razonable son 30–60 segundos; más que eso empieza a ser un riesgo.

Sobre la caché de claves: sin ella harías una petición HTTP al AS por cada petición entrante, con lo que habrías perdido la ventaja de la validación local y habrías creado una dependencia dura. Con ella, el AS puede estar caído diez minutos sin que tu API deje de autenticar.

  1. El middleware autenticarOAuth y su convivencia con autenticar

No hay que elegir de golpe. Durante la migración, los dos mecanismos conviven: los tokens propios de 03-06 (HS256, emitidos por nuestra API) y los de OAuth (RS256, emitidos por el AS). Un middleware selector decide por la cabecera del token:

// src/middleware/autenticacion.js  (MODIFICADO: se añade el selector)
import { decodeProtectedHeader } from 'jose';
import { autenticarPropio } from './autenticacion-propia.js';   // el de 03-06, renombrado
import { autenticarOAuth } from './autenticacion-oauth.js';

/**
 * Punto de entrada único de autenticación.
 * Elige el verificador según el algoritmo declarado en la cabecera del token.
 * Nota: la cabecera NO es de fiar por sí sola; solo se usa para ENRUTAR,
 * y cada verificador impone después su propia lista blanca de algoritmos.
 */
export function autenticar(req, res, next) {
  const token = (req.get('Authorization') ?? '').split(' ')[1];
  if (!token) return autenticarPropio(req, res, next);   // responderá 401 con WWW-Authenticate

  let cabecera;
  try {
    cabecera = decodeProtectedHeader(token);   // solo decodifica, NO verifica
  } catch {
    return autenticarPropio(req, res, next);
  }

  return cabecera.alg === 'RS256'
    ? autenticarOAuth(req, res, next)
    : autenticarPropio(req, res, next);
}

El comentario del código señala lo importante: la cabecera del token es entrada no confiable. Se usa solo para decidir qué verificador atiende; ese verificador impone después algorithms: ['RS256'] o ['HS256'] según corresponda, así que un atacante no gana nada mintiendo en el alg.

Posición en la cadena. No cambia respecto a 03-06: la autenticación sigue siendo lo primero dentro de cada ruta, no en src/app.js. El orden de la cadena de ruta pasa a ser:

autenticar → exigirRol / exigirAmbito → exigirClaveIdempotencia → validar(esquema, origen) → asincrono(controlador)

Y src/app.js no se toca en esta lección: OAuth no añade middleware global.

  1. Ámbitos y roles combinados

Aquí hay una confusión frecuente que conviene despejar con precisión, porque son dos mecanismos que responden a preguntas distintas:

Rol (cliente, empleado, administrador, socio) Ámbito (pedidos.leer)
Responde a Quién es el sujeto Qué le dejó hacer a esta aplicación
Lo decide Tienda Aroma, en su base de datos El usuario, en la pantalla de consentimiento
Cambia Raramente En cada autorización
Ejemplo Marta es cliente Marta permitió a CataBox pedidos.leer

La autorización efectiva es la intersección de los dos. Un token con resenas.moderar cuyo sujeto sea un cliente no puede moderar: el ámbito dice lo que la aplicación pidió, pero el rol dice lo que la persona puede. Y al revés: una administradora que usa CataBox, que solo pidió pedidos.leer, no puede moderar reseñas desde CataBox aunque su rol se lo permita en el panel.

// src/rutas/resenas.js  (MODIFICADO)
router.post(
  '/:id/aprobacion',
  autenticar,                                   // ¿quién eres? (token propio u OAuth)
  exigirRol('empleado', 'administrador'),       // ¿tu persona puede moderar?
  exigirAmbito('resenas.moderar'),              // ¿esta aplicación tiene permiso para ello?
  validar(esquemaAprobacion, 'body'),
  asincrono(controladores.resenas.aprobar)
);

Y una regla de compatibilidad para la convivencia: un token propio de 03-06 no lleva scope. Para que exigirAmbito no rompa las rutas existentes, el autenticarPropio rellena ambitos con el conjunto completo que corresponde al rol; es decir, un token de primera parte se comporta como si el usuario hubiera consentido todo. Es coherente, porque en ese flujo el usuario está usando directamente nuestra aplicación.

  1. Registro de clientes, secretos y redirect_uri

Antes de que CataBox pueda pedir nada, se registra en el servidor de autorización y obtiene:

Dato Ejemplo Público
client_id catabox
client_secret cbx_sk_9f3a... (ficticio) No, solo si es confidencial
redirect_uri https://catabox.example/callback
Ámbitos permitidos cafes.leer pedidos.leer
Tipo de cliente Confidencial / público

Confidencial frente a público:

Confidencial Público
Puede guardar un secreto Sí (servidor bajo su control) No
Ejemplos Backend de CataBox, RápidoEnvíos SPA de la tienda, Aroma Móvil
Autenticación en /token client_secret Solo PKCE
Client Credentials Permitido Prohibido

Un error muy repetido: incrustar el client_secret en una SPA o en una app móvil. Todo lo que se descarga al dispositivo del usuario es público, por mucho que esté ofuscado o compilado. Esa es la razón exacta de que PKCE exista.

La redirect_uri se compara byte a byte. No por prefijo, no por comodín, no ignorando el fragmento. Un AS que acepte https://catabox.example/* permite que un atacante que controle cualquier ruta de ese dominio (una página de perfil con contenido subido, por ejemplo) reciba los códigos de autorización. Reglas: HTTPS obligatorio, sin comodines, sin parámetros dinámicos —lo que necesites recordar va en state— y en móvil, esquemas propios o App Links verificados en lugar de un esquema que cualquier app pueda registrar.

  1. Revocación y cierre de sesión

Revocación de token (RFC 7009):

POST /revoke HTTP/1.1
Host: auth.tiendaaroma.example
Authorization: Basic <credenciales del cliente>
Content-Type: application/x-www-form-urlencoded

token=def50200a1b2c3...&token_type_hint=refresh_token

Responde 200 incluso si el token no existía, deliberadamente: así no se puede usar el endpoint para averiguar si un token es válido.

Qué revocar y qué efecto tiene:

Acción Efecto inmediato Efecto diferido
Revocar el refresh token No se pueden pedir tokens nuevos El access token vive hasta su exp (≤ 15 min)
Revocar el access token (opaco) Deja de valer ya
Revocar el access token (JWT) Ninguno, salvo lista de revocación Caduca solo
El usuario retira el consentimiento Se revoca toda la familia Ídem
Cambio de contraseña Se revocan todas las sesiones Ídem

Cierre de sesión. Aquí hay tres niveles que conviene no confundir, porque el usuario cree que «cerrar sesión» es uno solo:

  1. Local: la aplicación borra sus tokens. El usuario sigue con sesión abierta en el AS.
  2. RP-Initiated Logout (OIDC): la aplicación redirige a end_session_endpoint y el AS cierra también su sesión.
  3. Back-Channel Logout: el AS notifica por detrás a todas las aplicaciones conectadas para que cierren la sesión. Es lo que hace falta en un escenario de inicio de sesión único real.

En la SPA de Tienda Aroma, «cerrar sesión» debe ser al menos el nivel 2; si solo haces el 1, pulsar «entrar» otra vez devuelve al usuario dentro sin pedirle nada, y en un ordenador compartido eso es un problema de seguridad.

  1. Por qué no debes implementar tu propio servidor de autorización

Ya lo habrás intuido leyendo la lección: implementar un servidor de autorización OAuth 2.0 correcto es un proyecto en sí mismo, y hacerlo mal no produce un fallo visible, produce una vulnerabilidad silenciosa.

Lo que hay que construir y mantener correctamente:

  • Emisión y validación de códigos de un solo uso, con vida corta y ligados al cliente.
  • PKCE con S256, comparación exacta de redirect_uri, validación de state y nonce.
  • Firma asimétrica, publicación de JWKS y rotación de claves sin cortar el servicio.
  • Rotación de refresh tokens con detección de reutilización y revocación en cascada.
  • Pantallas de consentimiento, registro de consentimientos y su retirada.
  • Gestión de contraseñas, segundo factor, recuperación de cuenta, bloqueo por intentos.
  • Endpoints de descubrimiento, introspección, revocación y userinfo.
  • Cumplimiento con las actualizaciones del estándar y con los avisos de seguridad.

Además, tu servidor de autorización es el activo más crítico del sistema: quien lo compromete, lo compromete todo.

Opción Cuándo tiene sentido Consideraciones
Auth0 / Okta SaaS, quieres cero mantenimiento Coste por usuario activo; dependencia externa
AWS Cognito / Azure AD B2C Ya estás en esa nube Buena integración; personalización limitada
Keycloak Autoalojado, control total, sin coste de licencia Tú operas, actualizas y aseguras el servicio
Ory Hydra Quieres el AS y gestionar tú el login Ligero, pero más piezas que integrar
Implementarlo tú Prácticamente nunca Solo con un equipo de seguridad dedicado

La decisión de Tienda Aroma: un proveedor gestionado en auth.tiendaaroma.example. Nuestra API sigue siendo únicamente servidor de recursos, que es exactamente el papel que hemos implementado.

Y una precisión útil: esto no invalida el trabajo de 03-06. Los tokens propios siguen siendo perfectamente razonables para el caso de primera parte —tu SPA hablando con tu API, sin terceros—. OAuth se incorpora cuando aparece un tercero, cuando necesitas inicio de sesión federado o cuando varias APIs comparten identidad.

  1. Errores de OAuth y su traducción al catálogo de Aroma

OAuth define sus propios códigos de error, con esta forma:

{
  "error": "invalid_grant",
  "error_description": "The authorization code has expired",
  "error_uri": "https://auth.tiendaaroma.example/docs/errores#invalid_grant"
}

Ese formato lo emite el servidor de autorización, no nuestra API. Los errores que ve un cliente de CataBox se reparten así:

Error OAuth Lo emite Significado En el catálogo de Aroma
invalid_request AS Falta un parámetro obligatorio
invalid_client AS client_id/client_secret erróneos
invalid_grant AS Código caducado, ya usado, o refresh revocado
unauthorized_client AS Ese cliente no puede usar ese flujo
unsupported_grant_type AS grant_type desconocido
invalid_scope AS Ámbito inexistente o no permitido al cliente
access_denied AS El usuario rechazó el consentimiento
invalid_token Nuestra API Token no verificable, mal formado o de otro emisor no_autenticado (401)
invalid_token (expirado) Nuestra API exp pasado token_caducado (401)
insufficient_scope Nuestra API Faltan ámbitos permisos_insuficientes (403)

Nuestra API mantiene su formato propio en el cuerpo y usa la cabecera WWW-Authenticate para hablar el idioma de OAuth, que es lo que el RFC 6750 espera:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="pedidos.escribir"
Content-Type: application/json

{
  "error": {
    "codigo": "permisos_insuficientes",
    "mensaje": "El token no incluye el ámbito requerido: pedidos.escribir.",
    "detalles": []
  }
}

Así se cumplen las dos cosas a la vez: la consistencia del catálogo hacia nuestros consumidores (04-01) y la interoperabilidad con las bibliotecas OAuth genéricas, que leen WWW-Authenticate para decidir si deben refrescar el token o pedir más permisos. No hace falta ampliar el catálogo de errores: no_autenticado, token_caducado, permisos_insuficientes y servicio_no_disponible cubren todos los casos que emite el servidor de recursos.

Errores Comunes y Consejos

Usar el access token como prueba de identidad. El access token es para la API; la identidad la da el id_token de OIDC. Confundirlos abre ataques de sustitución de token.

No validar aud. Un token emitido para otra aplicación del mismo emisor abriría tu API. Es la comprobación más olvidada.

Aceptar cualquier algoritmo. Sin lista blanca, alg:none y la confusión RS/HS son explotables. Fija ['RS256'].

Guardar tokens en localStorage. Cualquier XSS los roba. Cookie HttpOnly con un BFF, o el llavero del sistema en móvil.

Incrustar un client_secret en una SPA o app móvil. Es público por definición. Cliente público + PKCE.

Aceptar redirect_uri con comodines. Una sola ruta controlable en tu dominio se convierte en robo de códigos.

Omitir state porque «ya usamos PKCE». Protegen de ataques distintos. Los dos, siempre.

Pedir todos los ámbitos «por si acaso». Baja la conversión —el usuario ve una pantalla alarmante— y aumenta el daño de una brecha. Pide el mínimo y amplía cuando haga falta.

Consejo: empieza por el documento de descubrimiento. /.well-known/openid-configuration te da todas las URLs. Configurar token_endpoint a mano es una fuente de errores tontos.

Consejo: prueba la caducidad de verdad. Configura un AS de pruebas con tokens de 30 segundos y comprueba que tu cliente refresca solo y que tu API responde token_caducado con el WWW-Authenticate correcto.

Consejo: registra client_id en los logs. Saber que una oleada de 429 viene de CataBox y no de tu SPA cambia por completo el diagnóstico. Lo veremos en 04-07.

Ejercicios

Ejercicio 1: elegir el flujo

Para cada consumidor de Tienda Aroma, indica el flujo correcto, el tipo de cliente y los ámbitos mínimos. Justifica cada elección.

  1. La SPA de la tienda (https://tiendaaroma.example), que permite comprar y reseñar.
  2. La app Aroma Móvil, con las mismas funciones.
  3. El backend de RápidoEnvíos, que marca pedidos como enviados.
  4. CataBox, que importa el historial de compras del usuario.
  5. Un script interno nocturno que exporta estadísticas de ventas.

Ejercicio 2: encontrar los fallos en una validación

Este middleware se ha propuesto para validar tokens OAuth. Encuentra al menos cuatro fallos de seguridad y corrígelos.

import jwt from 'jsonwebtoken';

export async function autenticarOAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const cabecera = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
  const jwksRespuesta = await fetch('https://auth.tiendaaroma.example/.well-known/jwks.json');
  const { keys } = await jwksRespuesta.json();
  const clave = keys.find((k) => k.kid === cabecera.kid) ?? keys[0];
  const payload = jwt.verify(token, aPem(clave));
  req.usuario = { id: payload.email, ambitos: payload.scope };
  next();
}

Ejercicio 3: diseñar los ámbitos de una función nueva

Tienda Aroma va a permitir que aplicaciones de terceros gestionen la suscripción mensual de café de un cliente: consultarla, pausarla, reanudarla y cambiar la variedad. Diseña los ámbitos necesarios, el texto de consentimiento de cada uno, y decide qué combinación de rol y ámbito exige cada endpoint.

Soluciones

Solución 1

Consumidor Flujo Tipo de cliente Ámbitos mínimos
1. SPA de la tienda Authorization Code + PKCE (idealmente con BFF) Público openid cafes.leer pedidos.leer pedidos.escribir resenas.escribir
2. Aroma Móvil Authorization Code + PKCE Público Los mismos
3. RápidoEnvíos Client Credentials Confidencial envios.escribir
4. CataBox Authorization Code + PKCE Confidencial (tiene backend) openid pedidos.leer
5. Script interno Client Credentials Confidencial ventas.leer (ámbito nuevo)

Justificaciones:

  • 1 y 2 son públicos aunque sean nuestros: el código se descarga al dispositivo y no puede guardar un secreto. Y usan Authorization Code + PKCE, no el flujo de contraseña, para poder pasar por 2FA y no manejar contraseñas.
  • 3 y 5 no tienen usuario delante, así que Client Credentials. El sub es la propia máquina y no hay consentimiento.
  • 4 es confidencial porque el canje ocurre en su servidor, pero usa PKCE igualmente: la recomendación actual es aplicarlo siempre. Pide solo pedidos.leer; si pidiera pedidos.escribir habría que rechazar el registro, porque no lo necesita para su caso de uso.
  • El caso 5 requiere ampliar el catálogo de ámbitos con ventas.leer, y conviene notarlo explícitamente: los ámbitos, como los códigos de error, forman parte del contrato y se documentan en openapi.yaml.

Solución 2

Fallos:

  1. No valida iss ni aud: acepta tokens de cualquier emisor y emitidos para cualquier otra API.
  2. No fija algorithms: vulnerable a alg:none y a la confusión RS/HS.
  3. ?? keys[0]: si el kid no coincide con ninguna clave, usa la primera «a ver si cuela». Un kid desconocido debe ser un rechazo.
  4. Descarga el JWKS en cada petición: latencia añadida, dependencia dura del AS y un vector de denegación de servicio (basta con mandar tokens con kid inventados).
  5. id: payload.email: el identificador estable es sub. El correo puede cambiar o llegar sin verificar.
  6. ambitos: payload.scope deja una cadena donde se espera un conjunto; .has() fallaría o, peor, .includes() daría falsos positivos (pedidos.leer «incluye» a pedidos.le).
  7. Sin try/catch: si falta la cabecera o el token está mal formado, el split o el JSON.parse lanzan un TypeError y acaba en un 500 en vez de un 401 — y sin WWW-Authenticate.

La corrección es el middleware del apartado 12: createRemoteJWKSet con caché para 3 y 4, jwtVerify con issuer, audience y algorithms para 1 y 2, payload.sub para 5, un Set para 6 y el try/catch con traducción de errores para 7.

Solución 3

Ámbitos:

Ámbito Texto de consentimiento Justificación
suscripciones.leer «Ver tu suscripción de café y su próxima entrega» Solo lectura, riesgo bajo
suscripciones.escribir «Pausar, reanudar y cambiar tu suscripción de café» Modifica el estado; separado de la lectura

Deliberadamente no se crea un ámbito por acción (suscripciones.pausar, suscripciones.reanudar…): produciría una pantalla de consentimiento ilegible y ninguna de esas acciones tiene un perfil de riesgo distinto de las otras. Tampoco se reutiliza pedidos.escribir, porque una aplicación que gestiona suscripciones no debe poder crear pedidos sueltos: mezclar los dos ampliaría el permiso más allá de lo necesario.

Endpoints:

Endpoint Rol Ámbito Nota
GET /v1/clientes/{id}/suscripcion cliente (propio), empleado, administrador suscripciones.leer Comprobación de propiedad obligatoria
POST /v1/clientes/{id}/suscripcion/pausa cliente (propio), administrador suscripciones.escribir Idempotente: pausar dos veces deja igual
POST /v1/clientes/{id}/suscripcion/reanudacion cliente (propio), administrador suscripciones.escribir Ídem
PATCH /v1/clientes/{id}/suscripcion cliente (propio), administrador suscripciones.escribir Cambia la variedad; parche absoluto
DELETE /v1/clientes/{id}/suscripcion cliente (propio) suscripciones.escribir Cancelación; un empleado no cancela por su cuenta
router.post(
  '/:id/suscripcion/pausa',
  autenticar,
  exigirRol('cliente', 'administrador'),
  exigirAmbito('suscripciones.escribir'),
  asincrono(controladores.suscripciones.pausar)   // dentro: comprobación de propiedad
);

Dos observaciones finales. El exigirRol('cliente', ...) no basta para impedir que un cliente pause la suscripción de otro: eso es un BOLA y se comprueba en el servicio, como vimos en 04-02. Y las acciones se modelan como subrecursos sustantivos (/pausa, /reanudacion) en lugar de verbos, coherentemente con 02-02 y con los antipatrones de 04-01.

Conclusión

OAuth 2.0 resuelve un problema muy concreto que la autenticación de 03-06 no podía resolver: que una aplicación de terceros acceda a los datos de un usuario sin conocer su contraseña, con permisos acotados, caducidad y revocación. Has visto los cuatro roles y, sobre todo, que nuestra API es solo el servidor de recursos: recibe un token, lo verifica y decide; nada más. Conoces la diferencia entre access token y refresh token, los ámbitos diseñados para Tienda Aroma y por qué se separa siempre lectura de escritura; la elección entre token opaco con introspección y JWT con validación local, con su compromiso explícito sobre la revocación; el flujo Authorization Code + PKCE con el detalle de qué ataque evita exactamente el code_verifier, por qué el implícito y el de contraseña están retirados, Client Credentials para RápidoEnvíos y la rotación de refresh tokens con detección de reutilización. Has visto que OpenID Connect es la capa que responde «quién es» con el id_token, sub como clave estable y /userinfo para el perfil. Y en el proyecto tienes ya src/config/oauth.js y src/middleware/autenticacion-oauth.js, con verificación por JWKS y kid —que resuelve de paso la rotación de claves—, validación completa de iss, aud, exp, nbf y algoritmo, caché de claves, tolerancia de reloj, el nuevo exigirAmbito y un selector que hace convivir los tokens propios con los de OAuth.

Ya sabes quién llama y qué puede hacer. Falta cuánto puede llamar. En 04-04, Rate limiting y throttling, pondremos límites: veremos por qué toda API pública los necesita —abuso, scraping del catálogo, bucles de clientes mal programados, coste y equidad—, la diferencia entre limitar, estrangular y poner cuotas, y los cuatro algoritmos clásicos con su comparativa, incluido un cubo de fichas implementado y comentado. Decidiremos qué se usa como clave (IP con el problema de NAT y X-Forwarded-For, cliente autenticado, client_id de OAuth) y con qué niveles para anónimos, clientes, RápidoEnvíos y panel; construiremos src/middleware/limite-peticiones.js con express-rate-limit y su posición en src/app.js, con almacén en Redis para varias instancias; devolveremos el 429 con Retry-After y las cabeceras Aroma-RateLimit-*; y veremos qué debe hacer un cliente bien educado con su backoff exponencial y su jitter.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados