🔐Ciberseguridad

Cómo validar tokens de Entra ID con una librería reutilizable en Node.js

Felipe••16 min de lectura
Construye una librería TypeScript con jose para validar access tokens de Entra ID: issuer, audience, JWKS, versiones y pruebas con Express y NestJS.

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:

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.
  • RS256 como ú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ónToken v1.0Token v2.0
Discoveryhttps://login.microsoftonline.com/{tenant}/.well-known/openid-configurationhttps://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Issuer esperadohttps://sts.windows.net/{tenant}/https://login.microsoftonline.com/{tenant}/v2.0
AudienceValor exacto del recurso: App ID URI o GUID, según su emisiónApplication ID de la API, como GUID
Cliente que solicitó el tokenappidazp

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

El 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ónRespuesta del adapter
Falta el Bearer o el token se rechaza401, con WWW-Authenticate: Bearer
Principal verificado sin permiso para la operación403
No se pueden obtener las claves necesarias503, sin autorizar
Configuración inválidaFallo 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 demo

La 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           -> 401

El 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:package

El 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 Entra ID#validar access tokens#jose#TypeScript#JWKS#Express#NestJS

Artículos relacionados