Una API usa Express y otra NestJS. Ambas reciben access tokens de Microsoft Entra ID, así que terminan comprobando firmas, audiencias y expiraciones en lugares distintos. Al principio parece poco código. El problema aparece cuando una acepta una versión que la otra rechaza, una descarga claves en cada petición o una actualización de seguridad solo llega a uno de los proyectos.
Podemos extraer esa responsabilidad a una librería TypeScript pequeña. Cada API instala el paquete y lo ejecuta dentro de su propio proceso: no necesitamos introducir un servicio central al que consultar cada token. Los frameworks conservan sus rutas y sus reglas de negocio.
Compartir código de seguridad también comparte sus errores. Por eso vamos a definir primero qué acepta la librería, probar sus rechazos y comprobar que puede consumirse como un paquete independiente. El resultado será un laboratorio didáctico, no una solución certificada ni un reemplazo automático de middleware especializado.
Qué validamos y qué dejamos en la API
Microsoft Entra ID es la plataforma de identidad de Microsoft. Un tenant es el directorio que delimita sus identidades y configuración. Un App Registration describe una aplicación; su Application (client) ID identifica ese registro. Aunque el portal lo llame client ID, también existe un identificador de aplicación para el registro que representa a nuestra API. Aplicaciones y service principals en Entra.
El cliente obtiene un access token para llamar a un recurso. La API destinataria lo valida. Un token destinado a Microsoft Graph no sirve para nuestra API, aunque venga del mismo tenant y su firma sea auténtica. Tampoco debemos aceptar un ID token como sustituto: su finalidad es acreditar autenticación ante el cliente. Access tokens de Microsoft.
Aquí no implementaremos el inicio de sesión ni la obtención del token. Si necesitas ese recorrido, revisa OAuth 2.0, OpenID Connect y PKCE. Nos situamos después, cuando llega Authorization: Bearer ....
La separación será esta:
No se pudo mostrar el diagrama. Puedes consultar su definición.
flowchart TD
A[Petición a una API] --> B[Adapter Express o Guard NestJS]
B --> C[Librería instalada en esa API]
C --> D[Verificar firma y política del token]
D --> E[Principal verificado]
E --> F[Autorización de negocio en la API]
F --> G[Operación sobre el recurso]
La librería recibe una cadena y devuelve una identidad tipada o un error. No consulta usuarios en una base de datos, inicia sesiones, renueva credenciales ni decide quién puede editar un pedido. La validación del token proporciona información autenticada; la autorización decide qué operaciones permite esa información en un contexto concreto.
Antes de escribir código, fijemos las decisiones de confianza
En un JWT firmado, iss identifica al emisor y aud al destinatario. exp limita su vigencia; nbf indica desde cuándo puede aceptarse. Son claims definidos por JWT, pero la aplicación debe establecer qué valores espera y cuáles exige. RFC 7519, sección 4.1.
Para este paquete elegiremos una política acotada:
- Un tenant configurado por el despliegue, en la nube pública de Microsoft.
- Audiencias exactas y separadas por versión de token.
RS256como único algoritmo permitido inicialmente.- Firma, issuer, audiencia, fechas y tenant obligatorios.
- Metadatos y claves obtenidos únicamente desde ubicaciones permitidas.
Aceptar varias audiencias tiene sentido cuando la API representa deliberadamente varios identificadores del mismo recurso. No conviene añadir el identificador del frontend o de Graph para hacer desaparecer un 401: ampliaríamos quién puede presentar un token aceptable.
Usaremos jose, mantenida por su proyecto independiente; no es una biblioteca oficial de Microsoft. Nos proporciona la implementación criptográfica y la resolución de claves. Nuestra librería añade una política concreta de Entra. Este reparto sigue las recomendaciones de restringir algoritmos, validar destinatarios y evitar confusiones entre tipos de JWT de RFC 8725.
Versiones 1.0 y 2.0: dos políticas explícitas
La versión del access token la determina la configuración del recurso; no debemos deducirla únicamente del endpoint OAuth usado por el cliente. Un token ver: "1.0" requiere los metadatos v1; uno ver: "2.0", los v2. Validación de versiones en Microsoft Entra.
Esta es la política del laboratorio, donde {tenant} siempre procede de la configuración local:
| Decisión | Token v1.0 | Token v2.0 |
|---|---|---|
| Discovery | https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration | https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration |
| Issuer esperado | https://sts.windows.net/{tenant}/ | https://login.microsoftonline.com/{tenant}/v2.0 |
| Audience | Valor exacto del recurso: App ID URI o GUID, según su emisión | Application ID de la API, como GUID |
| Cliente que solicitó el token | appid | azp |
tid identifica el tenant en ambas versiones. scp expresa scopes delegados; roles puede contener roles asignados al usuario o permisos de aplicación, según el flujo y la configuración. No son intercambiables. La referencia oficial de claims describe estas diferencias.
Podemos leer ver antes de verificar, pero solo como selector no confiable entre políticas previamente habilitadas. Nunca lo usamos para construir una URL arbitraria ni para aceptar otro tenant. Después de verificar la firma comprobamos otra vez la versión y su consistencia con la política elegida.
Un workspace pequeño y una API pública sencilla
El código completo está en entra-validation-lab, un repositorio público independiente. Los fragmentos siguientes muestran las partes relevantes; no reemplazan los archivos completos del laboratorio.
entra-validation-lab/
├── packages/entra-token-validator/src/
│ ├── config.ts
│ ├── errors.ts
│ ├── index.ts
│ ├── metadata.ts
│ └── types.ts
├── apps/express-api/src/index.ts
├── apps/nestjs-api/src/index.ts
├── tests/
├── scripts/
├── pnpm-workspace.yaml
└── README.mdEl workspace permite que las dos aplicaciones consuman @uncodigo-lab/entra-token-validator mediante workspace:*. Ese protocolo exige resolver el paquete local, en lugar de buscar accidentalmente otro con el mismo nombre en un registry. Workspaces de pnpm.
La fábrica es asíncrona porque obtiene los metadatos al arrancar:
import { createEntraTokenValidator } from '@uncodigo-lab/entra-token-validator';
const validator = await createEntraTokenValidator({
tenantId: '11111111-1111-4111-8111-111111111111',
audiences: {
'1.0': ['api://22222222-2222-4222-8222-222222222222'],
'2.0': ['22222222-2222-4222-8222-222222222222'],
},
algorithms: ['RS256'],
clockToleranceSeconds: 5,
timeoutMs: 3000,
jwksCacheMaxAgeMs: 600000,
jwksCooldownMs: 30000,
});Los identificadores son ficticios. Este bloque muestra el contrato de configuración; fuera del laboratorio necesita un tenant autorizado y las audiencias reales de esa API. Si solo queremos v2, omitimos la entrada 1.0 y esa versión queda rechazada.
La configuración se valida antes de hacer solicitudes. No permite common como tenant, comodines, audiencias vacías, tolerancias ilimitadas ni desactivar la firma. requiredClaims permite añadir claims obligatorios, pero no quitar los básicos; exigir presencia tampoco comprueba automáticamente un valor de negocio.
Crearíamos una instancia al iniciar cada aplicación, nunca una por petición. El resultado expone un método:
interface EntraTokenValidator {
verify(token: string): Promise<VerifiedPrincipal>;
}El principal normalizado incluye tenant, issuer, audiencia, sujeto, cliente, expiración, scopes y roles. objectId conserva oid cuando está presente. No entrega el JWT ni todos sus claims indiscriminadamente. Los tipos expresan el contrato, mientras que las comprobaciones en runtime impiden devolver estructuras inesperadas.
De discovery a una clave pública confiable
El documento de OpenID Connect Discovery describe al proveedor y contiene jwks_uri. Ese campo apunta a un JWKS, un conjunto de claves públicas en formato JSON. Microsoft firma con una clave privada; nuestra API utiliza la pública correspondiente para verificar la firma. No necesita un client secret para hacerlo. Tokens y claves de Microsoft.
kid ayuda a seleccionar una clave del conjunto. No certifica su procedencia: un atacante también puede escribir un kid en un token. La confianza nace del proveedor y de la política que configuramos, no del identificador aislado.
En metadata.ts construimos discovery desde el tenant permitido, comprobamos su issuer exacto y admitimos únicamente las rutas JWKS previstas bajo https://login.microsoftonline.com. No seguimos redirecciones. Las URL contenidas en jku o x5u dentro del token se rechazan; no se utilizan para descargar claves.
La resolución remota se apoya en esta API de jose:
const keys = createRemoteJWKSet(new URL(metadata.jwks_uri), {
timeoutDuration: config.timeout,
cacheMaxAge: config.cacheMaxAge,
cooldownDuration: config.cooldown,
});Es un extracto: el archivo completo añade customFetch para sanear errores de transporte y filtrar el issuer asociado a las claves de Entra antes de que jose las almacene. No construimos otro caché. Opciones del resolvedor remoto de jose.
Rotación, caché y fallos de red
Microsoft puede rotar claves, incluso por una emergencia. Un conjunto puede contener varias claves válidas; no debemos fijar la primera de la lista como si fuera permanente. Rotación de claves de firma.
En esta implementación, jose conserva las claves durante un máximo configurable. Si encuentra un kid desconocido, puede descargar de nuevo el conjunto una vez terminado el cooldown, el intervalo que limita esas actualizaciones. Durante ese intervalo el token desconocido se rechaza. Así evitamos convertir cada valor inventado de kid en otra petición al proveedor.
El ejemplo usa diez minutos de caché, treinta segundos de cooldown y tres segundos de timeout. Son decisiones ajustables del laboratorio. Un caché más largo reduce tráfico, pero también retrasa que una clave retirada deje de aceptarse localmente; uno demasiado corto aumenta la dependencia de red.
Si discovery falla al iniciar, la fábrica falla y la API no debería declararse lista. Si falla la obtención de claves necesarias durante una petición, respondemos sin autorizarla. Las claves todavía frescas pueden seguir utilizándose durante una interrupción; una vez vencido el caché, no prolongamos su uso indefinidamente.
Los metadatos se mantienen durante la vida de la instancia. Un cambio de su configuración requiere recrearla. Esta es una simplificación documentada, distinta de la actualización automática del JWKS.
Qué significa que verify haya terminado correctamente
Primero limitamos el tamaño del JWT a 32 KiB y comprobamos que tenga tres segmentos de un JWS compacto. El ejemplo no admite tokens cifrados JWE. Después inspeccionamos el header y seleccionamos una política habilitada mediante ver.
La verificación principal, extraída de index.ts, es:
const { payload } = await jwtVerify(token, policy.keys, {
algorithms: config.algorithms,
issuer: policy.issuer,
audience: config.audiences.get(version),
clockTolerance: config.clockTolerance,
requiredClaims: [
'iss', 'aud', 'exp', 'nbf', 'iat', 'sub', 'tid', 'ver',
clientClaim,
...config.requiredClaims,
],
});clientClaim es appid o azp, según la versión. A continuación comprobamos tid, la versión, los tipos de claims y la coherencia temporal antes de construir el resultado. También rechazamos iat en el futuro más allá de la tolerancia. Los scopes se separan en valores completos; los roles deben ser un array de cadenas.
Esta combinación es deliberadamente más concreta que «JWT válido». No aceptamos cualquier formato permitido por RFC 7519: por ejemplo, exigimos una audiencia de tipo cadena y los claims de nuestro perfil de Entra. Si una integración necesita otra variante, debe cambiar el contrato y añadir pruebas, no relajar una condición silenciosamente.
La firma tampoco demuestra por sí sola que el token tenga el propósito correcto. Conviene separar el registro de la API del cliente que inicia sesión y exigir los permisos del recurso. No existe aquí un detector universal de ID tokens. El artículo JWT: decodificar no es validar desarrolla esa diferencia entre leer claims y establecer confianza.
Express recibe HTTP; la librería valida el token
El adapter de Express extrae un único Bearer Token del encabezado, llama a verify y guarda el principal en res.locals. Este extracto muestra el camino satisfactorio; el archivo del laboratorio también contiene todos los rechazos y el manejo de errores:
const match = /^Bearer ([A-Za-z0-9_.-]+)$/i.exec(
req.headers.authorization ?? '',
);
if (!match) {
res.set('WWW-Authenticate', 'Bearer')
.status(401)
.json({ error: 'unauthorized' });
return;
}
res.locals.principal = await validator.verify(match[1]);
next();res.locals mantiene información de esa respuesta; no usamos una variable global para el usuario actual. La adaptación sigue el modelo de middleware de Express.
En /orders aplicamos una política separada:
if (!principal.scopes.includes('Orders.Read')) {
res.status(403).json({ error: 'forbidden' });
return;
}Esta ruta admite acceso delegado. Orders.Read.All no satisface Orders.Read, y un rol llamado Orders.Read tampoco sustituye el scope. Para admitir acceso entre aplicaciones necesitamos diseñar expresamente otra política de permisos de aplicación y clientes autorizados. La ausencia de scp no basta para clasificar de forma segura al actor.
El scope tampoco garantiza acceso a todos los pedidos. Una API real debe comprobar el tenant y los permisos sobre el objeto solicitado, además de las reglas de negocio. El par tenant/identificador de objeto puede ayudar a situar una identidad; comprobar únicamente tid u oid no autoriza una operación. Validación de claims en Entra y RBAC frente a ABAC.
Los fallos tienen un tratamiento predecible:
| Situación | Respuesta del adapter |
|---|---|
| Falta el Bearer o el token se rechaza | 401, con WWW-Authenticate: Bearer |
| Principal verificado sin permiso para la operación | 403 |
| No se pueden obtener las claves necesarias | 503, sin autorizar |
| Configuración inválida | Fallo de arranque |
La librería proporciona códigos de error propios y mensajes fijos. No devolvemos el token, la excepción de jose, cabeceras ni información de diagnóstico interna al cliente. En producción registraríamos el código de error, un identificador de petición y métricas agregadas, con redacción de cabeceras sensibles en toda la cadena de logs.
La misma pieza dentro de NestJS
En NestJS registramos el validador como provider y lo inyectamos en un Guard. El módulo del laboratorio recibe una instancia ya inicializada:
providers: [
{ provide: ENTRA_VALIDATOR, useValue: validator },
EntraGuard,
OrdersReadGuard,
]El constructor deja explícita la dependencia:
@Injectable()
export class EntraGuard implements CanActivate {
constructor(
@Inject(ENTRA_VALIDATOR)
private readonly validator: EntraTokenValidator,
) {}
// canActivate extrae el Bearer, llama a verify
// y asocia el principal a la petición.
}El ejemplo completo usa EntraGuard para autenticar y OrdersReadGuard para exigir el scope. También podría construirse el validador con un provider asíncrono durante el arranque. La criptografía permanece en el paquete; Nest aporta el ciclo de vida y la adaptación HTTP. Providers personalizados y autenticación con Guards.
Fastify podría adaptar el mismo método a un hook. No añadimos esa tercera implementación al laboratorio porque no cambiaría la responsabilidad que estamos estudiando.
Ejecutar el laboratorio y comprobar los rechazos
Necesitas Node.js 22.12 o superior y pnpm 11.14.0, fijado en el workspace. Clona el repositorio del laboratorio:
git clone https://github.com/felipejoq/entra-validation-lab.git
cd entra-validation-lab
pnpm install --frozen-lockfile
pnpm test
pnpm demoLa demo genera claves RSA en memoria y levanta un emisor ficticio, Express y NestJS en puertos locales efímeros. El código de pruebas redirige el transporte de las URL preconfiguradas hacia ese emisor; la librería conserva todas sus verificaciones. No hay un modo de producción que acepte cualquier JWKS o desactive firmas.
Las pruebas unitarias simulan las respuestas HTTP sin salir a la red. La integración sí utiliza HTTP real, pero únicamente en 127.0.0.1. Al ejecutar la demo observamos este resultado en ambos frameworks:
Token v2 con Orders.Read -> 200
Token v1 con Orders.Read -> 200
Sin token -> 401
Sin permiso delegado -> 403
Token expirado -> 401El conjunto de pruebas también comprueba fechas futuras, firma incorrecta, algoritmo no admitido, issuer ajeno, audiencia equivocada, token destinado a Graph, tenant distinto, claims ausentes o mal formados, versiones deshabilitadas, metadatos inconsistentes y fallos de claves. Una prueba introduce una segunda clave para observar la actualización después del cooldown; otra confirma que un caché vencido no permite seguir aceptando tokens si el proveedor está caído.
Estas son pruebas locales, no validaciones de tokens reales de Microsoft. Queda pendiente contrastar la emisión de un tenant autorizado: registrar API y cliente por separado, exponer el scope, obtener un access token para esa API y repetir las comprobaciones para cada versión que vayamos a admitir. El README describe ese recorrido sin utilizar credenciales corporativas.
La demo cierra los servidores y descarta las claves al terminar. No guardamos tokens en archivos. Para limpiar el workspace puedes eliminar sus directorios generados dist, node_modules y artifacts, conservando fuentes y lockfile. Una captura de terminal no aportaría más información que las salidas reproducibles anteriores.
Empaquetar también forma parte de probar
La librería del laboratorio se distribuye como ESM y genera declaraciones .d.ts. No añadimos una compilación CommonJS sin un consumidor que la necesite. El manifiesto declara el punto de entrada:
{
"name": "@uncodigo-lab/entra-token-validator",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"dependencies": {
"jose": "6.2.17"
}
}exports delimita los puntos de entrada públicos y declaration genera la descripción de tipos para consumidores TypeScript. Paquetes de Node.js y declaraciones de TypeScript.
jose pertenece a dependencies porque la librería lo necesita al ejecutarse. TypeScript y las utilidades de pruebas son dependencias de desarrollo del workspace. No necesitamos un peerDependency de Express o NestJS: el paquete no los utiliza. Un peer tiene sentido cuando el contrato requiere colaborar con una dependencia proporcionada por el consumidor; no por el simple hecho de publicar una librería.
Podemos generar un tarball local y probarlo fuera del monorepo:
pnpm smoke:packageEl script compila, ejecuta pnpm pack, instala el archivo en un consumidor temporal y verifica tanto el import ESM como la resolución de tipos. Usa el store local poblado por la instalación inicial y elimina ese consumidor al terminar. Esta comprobación también se ejecutó para el artículo.
npm pack ofrece la misma finalidad de preparar un tarball, sin publicarlo en un registry. Documentación de npm pack. En un equipo podríamos distribuir una versión revisada mediante un registry privado, con credenciales fuera del repositorio y permisos de publicación separados. En este experimento los paquetes conservan private: true y no se publican.
El versionado semántico debe reflejar cambios de contrato. Quitar una versión admitida, exigir un claim adicional o modificar el significado de un error puede romper consumidores aunque el método siga llamándose verify. Las correcciones de seguridad necesitan una comunicación clara, pruebas de compatibilidad y un plan de actualización de las APIs.
Qué falta antes de convertirla en una dependencia compartida real
El soporte de este ejemplo es single-tenant. Añadir multi-tenant no consiste en reemplazar el GUID por common. Haría falta una lista explícita de tenants admitidos, relacionar tid con el issuer verificado y validar el ámbito del issuer asociado a cada clave, especialmente con metadatos independientes del tenant. Nuestra política v2 acepta el issuer de clave exacto o su plantilla {tenantid}; v1 admite claves sin esa extensión y comprueba las que sí la incluyen. Eso no equivale a implementar multi-tenant. Consideraciones de validación de Microsoft.
Tampoco cubrimos nubes soberanas, B2C/External ID, claves personalizadas por aplicación, revocación inmediata, Continuous Access Evaluation ni todos los perfiles de token posibles. Verificar un JWT localmente no consulta en tiempo real si una sesión fue revocada.
Antes de adoptarla conviene revisar esas limitaciones con el equipo de identidad, probar tokens del entorno autorizado y ajustar caché, rate limiting, límites HTTP y observabilidad. Mantener jose y revisar su cadena de dependencias sigue siendo responsabilidad del equipo. Las pruebas negativas deben acompañar cada cambio, no quedar como un ejercicio inicial.
Una librería compartida resulta útil cuando mantiene visible la decisión de confianza y hace más sencillo corregirla en todas las APIs. Si termina ocultando excepciones, aceptando perfiles que nadie ha probado o acumulando reglas de cada negocio, habrá dejado de resolver el problema con el que empezamos.
Referencias
- Microsoft: access tokens y validación.
- Microsoft: tokens de seguridad.
- Microsoft: referencia de claims de access tokens.
- Microsoft: validación de claims y autorización.
- Microsoft: rotación de claves de firma.
- jose: implementación y documentación oficial del proyecto.
- RFC 7519: JSON Web Token.
- RFC 8725: buenas prácticas de JWT.
