Proteger una API en Node.js con Azure Active Directory

Hace un par de años escribí una serie de artículos donde explicaba cómo funcionaba OAuth 2.0 y los diferentes componentes que se necesitaban para los flujos que se pueden implementar. Sin embargo, es comprensible que a veces confundamos algunos de estos componentes y que las cosas no respondan como esperamos. En este artículo te muestro cómo proteger una API propia con Node.js y Azure Active Directory.

La API sin protección

Para este ejemplo vamos a utilizar el siguiente código para nuestra API en Node.js:

const express = require('express'),
    app = express();

app.get('/protected', (req, res) => {
    res.json({ message: "This message is protected" });
});

app.listen(1000, () => {
    console.log(`API running on port 1000!`);
});

Nuestro objetivo será proteger la llamada a /protected con Azure Active Directory. Para ello vamos a necesitar además una aplicación cliente que recupere un token válido y haga la llamada a esta API. Para este ejemplo vamos a suponer que nuestro cliente tiene un backend seguro, también en Node.js, por lo que voy a apoyarme en el artículo donde compartí el ejemplo de Authorization Code Flow. Utiliza este artículo para montar esta primera parte.

Modificando nuestra API para que esté protegida

Ahora ya tenemos una API desprotegida y una aplicación que es capaz de recuperar tokens y hacer llamadas, inicialmente a la API de Microsoft Graph. El siguiente reto sería modificar la API para que las peticiones a protected tuvieran en cuenta un token válido que autorizara la petición. En este artículo te voy a contar dos opciones.

Opción 1: Usando express-jwt

Una aproximación sería hacer uso de los módulos express-jwt, el cual nos hace de middleware cada vez que llega una petición para validar los JWT, y jwks-rsa que nos permite recuperar las claves públicas que nos ayudan a verificar si la firma del token es válida.

const express = require('express'),
    app = express(),
    // Modules to validate JWTs
    jwt = require('express-jwt'),
    jwks = require('jwks-rsa');

require('dotenv').config();

var jwtCheck = jwt({
    secret: jwks.expressJwtSecret({
        cache: true,
        jwksUri: `https://login.microsoftonline.com/${process.env.TENANT_ID}/discovery/v2.0/keys`
    }),
    algorithms: ['RS256']
})

app.use(jwtCheck);

app.get('/protected', (req, res) => {
    res.json({ message: "This message is protected" });
});

app.listen(1000, () => {
    console.log(`API running on port 1000!`);
});

Como ves, defino una configuración, con express-jwt, que dice cómo y dónde recuperar las claves que validarán el token, apoyándonos en jwks-rsa que sabe cómo recuperar la clave que necesita, en base un valor del token llamado kid, el algoritmo indicado y la URL que le estoy facilitando a través de la propiedad jwksUri. Utilizo app.use para inyectarle esta lógica y, a partir de ahora, cada vez que se haga una llamada a cualquier acción de mi API pasará por este middleware para comprobar la cabecera y el token que le llega.

Opción 2: uso de passport

Otra forma que tenemos en Node.js, bastante extendida, es con el módulo passport:

const express = require('express'),
    app = express();

require('dotenv').config();

//Modules to use passport
const passport = require('passport'),
    JwtStrategy = require('passport-jwt').Strategy,
    ExtractJwt = require('passport-jwt').ExtractJwt,
    jwks = require('jwks-rsa');

let jwtOptions = {
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint.
    secretOrKeyProvider: jwks.passportJwtSecret({
        jwksUri: `https://login.microsoftonline.com/${process.env.TENANT_ID}/discovery/v2.0/keys`,
    }),
    algorithms: ['RS256']
};

const verify = (jwt_payload, done) => {
    console.log(`Signature is valid for the JSON Web Token (JWT), let's check other things...`);
    console.log(jwt_payload);

    if (jwt_payload && jwt_payload.sub) {
        return done(null, jwt_payload);
    }

    return done(null, false);
};

passport.use(new JwtStrategy(jwtOptions, verify));


app.get("/protected", passport.authorize('jwt', { session: false }), function(req, res) {
    res.json({ message: "This message is protected" });
});

app.listen(1000, () => {
    console.log(`API running on port 1000!`);
});

En este caso verás que la configuración es similar a la anterior, solo que aquí utilizamos, en vez de express-jwt, el módulo passport y passport-jwt y debemos configurar lo que esta librería llama estrategia. Para este escenario en concreto necesitamos definir la llamada JwtStrategy que, como te puedes imaginar, es la utilizada para este tipo de autorización. Para ello le pasamos básicamente los mismos valores que en la opción 1, e incluso vuelvo a hacer uso de la librería jwks-rsa para recuperar la clave correcta.
Otra diferencia aquí es que en lugar de inyectar el middleware a través de app.use, estoy utilizando esta autorización en la propia definición de la acción /protected. Cuando se ejecute esta llamada utilizará la estrategia jwt, que pondrá a juicio el token, recibido en la cabecera Authorizacion con el formato Bearer {token}, utilizando la configuración especificada a través del objeto jwtOptions que me cree más arriba. Además de esto, la configuración de la estrategia viene acompañada de una función de callback que solo se ejecutará si la verificación previa es superada con éxito. Esta se suele utilizar para localizar usuarios dentro de una base de datos propia de la aplicación, ver qué roles o scopes tiene el token, etcétera.

Llegados a este punto puedes confiarte y pensar que ya tienes todo bien atado, pero lo cierto es que si intentas recuperar el token y haces uso del mismo contra tu nueva API esta no devolverá lo que esperas.

Invalid signature cuando el token llega a nuestra API

Un error común en este punto es intentar utilizar el token generado por nuestra aplicación cliente recien registrada en Azure Active Directory, lo cual nos dará el siguiente error:

Invalid signature

Nota mental: Se ve mucho mejor cuando utilizas la opción 1 como implementación. Así que si en algún momento no eres capaz de pasar del mensaje Unauthorized (que es lo único que te dice passport) prueba a utilizar la implementación sin passport, que te dará más información.

Lo que está ocurriendo es lo siguiente: Si recuerdas el flujo de OAuth 2.0 tenía los siguientes pasos:

Funcionamiento del flujo de OAuth 2.0 – Code Authorization
  1. Tu aplicación cliente, que es la que se encarga de solicitar el token, le pide al usuario que se autentique, redirigiendo a este al servidor de autorización.
  2. Este se autentica con su usuario y contraseña, su segundo factor de autenticación si hiciera falta, además del consentimiento si todavía no había tenido oportunidad de darlo.
  3. Una vez que el usuario se autentica correctamente, el servidor le pasa el código de autorización a la aplicación para que sea esta quien pida el token con el mismo.
  4. La aplicación es ahora la que pide el token al servidor de autorización utilizando nuestro código como señal de que el usuario al que representa se ha autenticado correctamente.
  5. Si está todo ok el servidor de autorización devuelve el token.
  6. La aplicación ahora llama al recurso protegido con el token expedido.
  7. El recurso protegido comprueba que el token es correcto y, en el caso de serlo responde, con los datos solicitados por la aplicación.

Si has descargado el código de ejemplo de mi artículo anterior la configuración que deberías de tener ahora mismo en el archivo .env es parecida a la siguiente:

TENANT_ID=YOUR_TENANT_ID
CLIENT_ID=YOUR_CLIENT_ID
CLIENT_SECRET=YOUR_CLIENT_SECRET
SCOPE="https://graph.microsoft.com/User.Read"

Si esto lo trasladamos al escenario en el que nos encontramos ahora mismo sería este:

Si te fijas, lo que está ocurriendo es que ahora mismo estamos intentando verificar la firma de un token que no es para nuestra API sino para la API de Microsoft Graph, y esto produce que esa comprobación de la que hablo en el paso 6 sea fallida, como puedes leer en los comentarios de este artículo del equipo de Azure AD.

Entonces, ¿cómo soluciono esto?

Registrar tu API en Azure Active Directory

El paso que te falta para cerrar el flujo es que tu API también es una aplicación que debe estar registrada en Azure Active Directory, por lo que deberías tener dos: la aplicación cliente y el recurso protegido:

Dos aplicaciones registradas en Azure AD: la aplicación cliente y la API

La primera de ellas, authZ-code-flow-example representa al componente Client Application de la imagen anterior que es la que le está pidiendo al usuario que se autentique para obtener un token. (Esta deberías de haberla generado si has seguido el artículo del ejemplo de Authorization Code Flow de OAuth 2.0).
La segunda es la que representa a nuestra API que es para la que ahora necesitamos el token. Esta la he registrado por ahora con los valores por defecto cuando haces clic en el botón New Registration. En esta debemos modificar dos cosas: en el apartado Expose an API debemos modificar la Application ID URI, que es el valor que utilizaré para referirme a esta aplicación, y añadir algunos scopes que nuestra aplicación quiera poder solicitar. Para este ejemplo he creado un scope llamado read:

El resultado de esta configuración debería de ser una aplicación expuesta a que otras puedan solicitar permiso utilizando estos scopes que se han dado de alta.

La idea es que estos sean reconocidos por tu API y en base a ellos se puedan permitir o denegar también ciertas peticiones.

Por otro lado, para que todo esto tenga sentido, lo que tenemos que hacer es permitir a nuestra aplicación cliente, authZ-code-flow-example que tenga permisos sobre esta otra, my-api. Para ello, debemos acudir a la primera y en el apartado API permissions hacemos clic en la opción Add a permission y en la nueva ventana que aparece en el lado derecho debemos seleccionar el apartado My APIs, donde ahora deberías de poder ver el registro de my-api y agregarlo, seleccionando el scope que tenemos disponible:

Una vez seleccionada deberías de ver todos los scopes disponibles de los cuales puedes permitir a tu aplicación solicitar (nunca des permisos de más).

Agregar scopes de la API my-api a authZ-code-flow-example

Una vez añadido el scope el resumen final sería como el que sigue:

Ahora tienes dos aplicaciones registradas correctamente, el cliente que consume la API y la API en sí, las cuales están asociadas entre sí a través del API permissions. Para terminar la configuración solo quedaría pedir el token para el scope correcto. Haz clic en grant admin consent for xxxxx para autorizar el consentimiento para el nuevo scope.

Modificar la petición del token para tu API en vez de Microsoft Graph

Para validar que todo esto funciona como debería, he reutilizado el código para el client application que es el encargado de solicitar el token con los parámetros correctos, solo que para esta vez vamos a modificar el para qué quiero el token. Para ello, hay que modificar el valor de SCOPE en tu archivo .env, que ahora será api://my-api/read

TENANT_ID=YOUR_TENANT_ID
CLIENT_ID=CLIENT_ID
CLIENT_SECRET=CLIENT_SECRET
SCOPE="api://my-api/read"

Con esto le estamos diciendo a nuestra aplicación que cuando pida el token lo quiere para los permisos que se indican aquí, que son para interactuar con nuestra API. Por otro lado, he modificado también el código fuente para que en lugar de llamar a Microsoft Graph llame a tu nueva API en http://localhost:1000:

//Step 4: Call the protected API
app.post('/call/protected/api', (req, res) => {

    let access_token = JSON.parse(req.body.token).access_token;

    // const Microsoft_Graph_Endpoint = 'https://graph.microsoft.com/beta';
    // const Acction_That_I_Have_Access_Because_Of_My_Scope = '/me';
    const Microsoft_Graph_Endpoint = 'http://localhost:1000';
    const Acction_That_I_Have_Access_Because_Of_My_Scope = '/protected';

    //Call Microsoft Graph with your access token
    fetch(`${Microsoft_Graph_Endpoint}${Acction_That_I_Have_Access_Because_Of_My_Scope}`, {
        headers: {
            'Authorization': `Bearer ${access_token}`
        }
    }).then(async response => {

        let json = await response.json();
        res.render('calling-ms-graph', { response: JSON.stringify(json, undefined, 2) });
    });
});

Si ahora intentas hacer el ciclo completo, arrancando tanto la aplicación cliente como la API, deberías poder acceder a http://localhost:8000, autenticarte con un usuario de tu Azure AD, obtener un token a través del flujo correspondiente y que al llamar a tu API con el token resultante esta pueda verificarlo y devolver el mensaje de prueba de manera satisfactoria:

El problema de las múltiples audiencias

Otro de los escenarios con el que me suelo encontrar es cuando se intenta recuperar un solo token para diferentes recursos (APIs). Esto es conocido como múltiple audiencia y no está soportador por ahora. Si intentas hacer esta mezcla de scopes de diferentes audiencias en una sola petición obtendrías un error como el siguiente:

{
  "error": "invalid_request",
  "error_description": "AADSTS28000: Provided value for the input parameter scope is not valid because it contains more than one resource. Scope api://my-api/read https://graph.microsoft.com/User.Read is not valid.\r\nTrace ID: 4ddfe527-70ea-4dce-bd62-c2e0857a7701\r\nCorrelation ID: 2843e1fa-43e8-4798-a7db-41858c14389f\r\nTimestamp: 2021-05-07 08:27:01Z",
  "error_codes": [
    28000
  ],
  "timestamp": "2021-05-07 08:27:01Z",
  "trace_id": "4ddfe527-70ea-4dce-bd62-c2e0857a7701",
  "correlation_id": "2843e1fa-43e8-4798-a7db-41858c14389f"
}

Lo que se debería hacer en este tipo de situaciones es generar más de un token, uno por cada audiencia. Lo que si podemos, y debemos hacer, es pedir más de un scope por audiencia si fuera necesario.

El código de ejemplo lo tienes en mi GitHub.

¡Saludos!