Integrar el flujo Authorization Code y PKCE para evitar ataques de interceptación del código

Si bien el flujo Authorization Code se considera el más seguro de todos, se ha demostrado que, en aplicaciones nativas, el código que otorga el servidor de autorización puede ser interceptado por aplicaciones maliciosas, haciéndose así con un token de acceso a nuestros recursos protegidos. Es por ello que el uso de la extensión PKCE, o Proof Key for Code Exchange, está recomendada, si usas este flujo en aplicaciones nativas. En este artículo vamos a ver cómo incorporarlo al ejemplo que ya teníamos, utilizando Azure Active Directory como servidor de autorización, el cual soporta esta validación extra.

Configuración del servidor de autorización

Para usar PKCE en este flujo no es necesario hacer nada distinto de lo que ya vimos en este artículo, por lo que puedes seguir los pasos del apartado con el mismo nombre que este.

El cliente

En el lado del cliente si que cambian algunas cosas:

//Modules
const express = require('express'),
    bunyan = require('bunyan'),
    bodyParser = require('body-parser'),
    session = require('express-session'),
    fetch = require("node-fetch"),
    crypto = require('crypto');


//Load values from .env file
require('dotenv').config();

const app = express();
const log = bunyan.createLogger({ name: 'Authorization Code Flow' });

app.use(express.static('public'));
app.use(session({ secret: 'ssshhhhh' }));

// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }));

app.set('view engine', 'ejs');

function base64URLEncode(str) {
    return str.toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

app.get('/', (req, res) => {

    //generate a code verifier
    req.session.code_verifier = base64URLEncode(crypto.randomBytes(43));
    //get code challenge
    req.session.code_challenge = base64URLEncode(crypto.createHash('sha256').update(req.session.code_verifier).digest());

    res.render('index', { code_verifier: req.session.code_verifier, code_challenge: req.session.code_challenge });
});

//Set 1: Ask the authorization code
app.get('/get/the/code', (req, res) => {

    log.info(req.session.code_challenge);

    const Authorization_Endpoint = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/authorize`;
    const Response_Type = 'code';
    const Client_Id = process.env.CLIENT_ID;
    const Redirect_Uri = 'http://localhost:8000/give/me/the/code';
    const Scope = 'https://graph.microsoft.com/User.Read';
    const State = 'ThisIsMyStateValue';
    const Code_Challenge = req.session.code_challenge;

    let url = `${Authorization_Endpoint}?response_type=${Response_Type}&client_id=${Client_Id}&code_challenge=${Code_Challenge}&code_challenge_method=S256&redirect_uri=${Redirect_Uri}&scope=${Scope}&state=${State}`;

    log.info(url);

    res.redirect(url);

});

//Step 2: Get the code from the URL
app.get('/give/me/the/code', (req, res) => {
    //before continue, you should check that req.query.state is the same that the state you sent
    res.render('exchange-code', { code: req.query.code, state: req.query.state });
});

//Step 3: Exchange the code for a token
app.post('/exchange/the/code/for/a/token', (req, res) => {

    const Token_Endpoint = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`;
    const Grant_Type = 'authorization_code';
    const Code = req.body.code;
    const Redirect_Uri = 'http://localhost:8000/give/me/the/code';
    const Client_Id = process.env.CLIENT_ID;
    const Client_Secret = process.env.CLIENT_SECRET;
    const Scope = 'https://graph.microsoft.com/User.Read';
    const Code_Verifier = req.session.code_verifier;

    log.info(Code_Verifier);

    let body = `grant_type=${Grant_Type}&code=${Code}&redirect_uri=${encodeURIComponent(Redirect_Uri)}&client_id=${Client_Id}&client_secret=${Client_Secret}&code_verifier=${Code_Verifier}&scope=${encodeURIComponent(Scope)}`;

    log.info(`Body: ${body}`);

    fetch(Token_Endpoint, {
        method: 'POST',
        body: body,
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    }).then(async response => {

        let json = await response.json();
        res.render('access-token', { token: JSON.stringify(json, undefined, 2) }); //you shouldn't share the access token with the client-side

    }).catch(error => {
        log.error(error.message);
    });
});

//Step 4: Call the protected API
app.post('/call/ms/graph', (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';

    //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) });
    });
});

app.listen(8000);

La primera de ellas es que, antes de llamar al endpoint de autorización, es necesario crear los valores para code_verifier y code_challenge. El code verifier no es más que una cadena de caracteres aleatorios con un mínimo de 43 y un máximo de 128. El code challenge se genera a partir del code verifier, preferiblemente utilizando SHA256 (también se puede usar en texto plano, pero no es recomendable si existe esta opción). En el código, antes de dirigirnos a la página index se crean ambos valores, utilizando el módulo crypo y una función auxiliar llamada base64URLEncode, que hace lo que indica su nombre:

function base64URLEncode(str) {
    return str.toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

app.get('/', (req, res) => {

    //generate a code verifier
    req.session.code_verifier = base64URLEncode(crypto.randomBytes(43));
    //get code challenge
    req.session.code_challenge = base64URLEncode(crypto.createHash('sha256').update(req.session.code_verifier).digest());

    res.render('index', { code_verifier: req.session.code_verifier, code_challenge: req.session.code_challenge });
});

Con fines educativos se mandan ambos valores a la página index, para que veas que pinta tienen, pero sólo con fines educativos.

OAuth 2.0 y PKCE – Code verifier y code challenge

Una vez que tienes ambos códigos, estos deben de usarse de la siguiente manera: Cuando hagas la petición al endpoint de autorización, para recuperar el código que demanda este flujo, se le pasa además el code challenge y el método utilizado para crearlo, en este caso SHA256. Puedes verlo en la variable url del paso 2, donde se concatenan estos valores junto con el resto:

let url = `${Authorization_Endpoint}?
response_type=${Response_Type}
&client_id=${Client_Id}
&code_challenge=${Code_Challenge}
&code_challenge_method=S256
&redirect_uri=${Redirect_Uri}
&scope=${Scope}&state=${State}`;

El código será devuelto sin problemas. Sin embargo, cuando intentamos cambiar este por un token, debemos demostrar que ese código que se nos ha devuelto es nuestro, enviando como parte del cuerpo el code verifier, el cual el servidor de autorización comparará que coincide con el code challenge enviado anteriormente, cuando se pidió el código, teniendo así esa verificación extra en nuestro flujo:

let body = `grant_type=${Grant_Type}
&code=${Code}
&redirect_uri=${encodeURIComponent(Redirect_Uri)}
&client_id=${Client_Id}
&client_secret=${Client_Secret}
&code_verifier=${Code_Verifier}
&scope=${encodeURIComponent(Scope)}`;

Como ves, una vez que tienes el flujo de Authorization Code implementado es súper sencillo añadir esta segunda capa de seguridad. En el caso de que no coincidan, el servidor de autorización devolverá un error como el siguiente:

{
  "error": "invalid_grant",
  "error_description": "AADSTS501481: The Code_Verifier does not match the code_challenge supplied in the authorization request.\r\nTrace ID: faf57c17-d403-496d-949a-364b6743b300\r\nCorrelation ID: 9b437643-28c5-44df-bf12-3ce28aba6500\r\nTimestamp: 2019-04-29 15:29:53Z",
  "error_codes": [
    501481
  ],
  "timestamp": "2019-04-29 15:29:53Z",
  "trace_id": "faf57c17-d403-496d-949a-364b6743b300",
  "correlation_id": "9b437643-28c5-44df-bf12-3ce28aba6500"
}

El código completo lo tienes en mi cuenta de GitHub.

¡Saludos!