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
- El problema: delegar acceso sin compartir credenciales
- Los cuatro roles de OAuth 2.0
- Tokens: acceso, refresco y ámbitos
- Tokens opacos frente a JWT: introspección o validación local
- Authorization Code + PKCE
- Por qué el flujo implícito y el de contraseña están desaconsejados
- Client Credentials: RápidoEnvíos, máquina a máquina
- Refresh Token y Device Code
state, CSRF y los parámetros de la petición de autorización- OpenID Connect: la identidad encima de OAuth
- Validar el token en la API: JWKS,
kidy las claims - El middleware
autenticarOAuthy su convivencia conautenticar - Ámbitos y roles combinados
- Registro de clientes, secretos y
redirect_uri - Revocación y cierre de sesión
- Por qué no debes implementar tu propio servidor de autorización
- Errores de OAuth y su traducción al catálogo de Aroma
- 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).
- 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/v1 — nuestra 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:
- Recibir un
Authorization: Bearer <token>. - Verificar que ese token es auténtico, vigente y está dirigido a ella.
- Extraer quién es el sujeto y qué ámbitos tiene.
- 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.
- 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:
- Granularidad
recurso.accion, no un ámbito por endpoint. Con 24 URIs, un ámbito por endpoint produce una pantalla de consentimiento ilegible. - Separar lectura de escritura siempre. CataBox pide
pedidos.leery jamáspedidos.escribir. Esta separación es el 80 % del valor. - El ámbito acota, no concede. Que un token tenga
pedidos.leerno 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. - 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.
- 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 | Sí | 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.
- 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.examplePaso 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=xY9fK2mQ7pL1Paso 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.
- 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.
- 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.escribirTres características que lo distinguen:
- No hay
redirect_uri, nicode, 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
subdel token es el propio cliente (rapidoenvios), no una persona. En nuestra API eso corresponde al rolsocio, 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.
- 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=cataboxLa 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.
state, CSRF y los parámetros de la petición de autorización
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 |
Sí | code |
— |
client_id |
Sí | 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 |
Sí | Valor aleatorio opaco | CSRF de inicio de sesión |
code_challenge |
Sí | Hash del verifier | Robo del código |
code_challenge_method |
Sí | 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 usoCon 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.
- 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:
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, nuncaemail. 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 siemail_verifiedesfalse. - El
id_tokenes para el cliente, no para la API. No se envía enAuthorization: Beareral servidor de recursos. Nuestra API valida el access token; elid_tokenlo 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.
Y el documento de descubrimiento, que evita configurar URLs a mano:
{
"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.
- Validar el token en la API: JWKS,
kid y las claims
kid y las claimsAquí 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:
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
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.
- El middleware
autenticarOAuth y su convivencia con autenticar
autenticarOAuth y su convivencia con autenticarNo 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.
- Á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.
- Registro de clientes, secretos y
redirect_uri
redirect_uriAntes de que CataBox pueda pedir nada, se registra en el servidor de autorización y obtiene:
| Dato | Ejemplo | Público |
|---|---|---|
client_id |
catabox |
Sí |
client_secret |
cbx_sk_9f3a... (ficticio) |
No, solo si es confidencial |
redirect_uri |
https://catabox.example/callback |
Sí |
| Ámbitos permitidos | cafes.leer pedidos.leer |
Sí |
| 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.
- 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_tokenResponde 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:
- Local: la aplicación borra sus tokens. El usuario sigue con sesión abierta en el AS.
- RP-Initiated Logout (OIDC): la aplicación redirige a
end_session_endpointy el AS cierra también su sesión. - 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.
- 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 deredirect_uri, validación destateynonce. - 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.
- 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.
- La SPA de la tienda (
https://tiendaaroma.example), que permite comprar y reseñar. - La app Aroma Móvil, con las mismas funciones.
- El backend de RápidoEnvíos, que marca pedidos como enviados.
- CataBox, que importa el historial de compras del usuario.
- 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
subes 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 pidierapedidos.escribirhabrí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 enopenapi.yaml.
Solución 2
Fallos:
- No valida
issniaud: acepta tokens de cualquier emisor y emitidos para cualquier otra API. - No fija
algorithms: vulnerable aalg:noney a la confusión RS/HS. ?? keys[0]: si elkidno coincide con ninguna clave, usa la primera «a ver si cuela». Unkiddesconocido debe ser un rechazo.- 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
kidinventados). id: payload.email: el identificador estable essub. El correo puede cambiar o llegar sin verificar.ambitos: payload.scopedeja una cadena donde se espera un conjunto;.has()fallaría o, peor,.includes()daría falsos positivos (pedidos.leer«incluye» apedidos.le).- Sin
try/catch: si falta la cabecera o el token está mal formado, elsplito elJSON.parselanzan unTypeErrory acaba en un500en vez de un401— y sinWWW-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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
