Abe Estrada

AWS Cloudfront Edge Auth Cognito

Buscando información sobre como proteger una instancia de Cloudfront para permitir acceso solo a usuarios registrados y validados por Cognito, encontré un ejemplo de 2018 de AWS el cual permite esta opción:

Authorization@Edge – How to Use Lambda@Edge and JSON Web Tokens to Enhance Web Application Security

El ejemplo incluye un machote de CloudFormation para crear la solución con un solo click, pero mi intención era aregar dicha funcionalidad a un proyecto actualmente en producción, así que me dí a la tarea de revisar el código ya viejo, para poder implementarlo en 2022.

A grandes rasgos, el ejemplo muestra como una función Lambda es utilizada para validar un JSON Web Token (jwt) de Cognito que es enviado cada ve que hacemos la petición de un archivo por medio de Cloudfront.

👩‍💻 → Cloudfront → Lambda@Edge → S3


Para iniciar hay que crear una función. Es importante utilizar la arquitectura x86_64 ya que Lambda@Edge no permite ejecutar en arm64 (por el momento).

Luego de creada la función, hay que modificar el rol para que pueda ser ejecutada por Lambda@Edge.

Dentro de Trust relationships hay que agregar edgelambda.amazonaws.com.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": ["edgelambda.amazonaws.com", "lambda.amazonaws.com"]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Ahora localmente creamos el proyecto con las dependencias y archivos necesarios para la función.

npm init -y
npm install jsonwebtoken jwk-to-pem -S
touch index.js

El cual nos va a generar un archivo package.json que no es necesario pero funciona como referencia o para futuras actualizaciones de estas dependencias.

package.json
{
  "dependencies": {
    "jsonwebtoken": "^8.5.1",
    "jwk-to-pem": "^2.0.5"
  }
}

Y dentro del archivo index.js copiamos el siguiente código, que es mi versión actualizada del código de ejemplo de AWS. Solo debemos modificar tres constantes: REGION, USERPOOLID y JWKS con nuestra información donde tenemos la instancia de Cognito.

Los JWKS los podemos encontrar en un archivo json que debemos buscar en una url específica a la instancia de Cognito que estamos utilizando

https://cognito-idp.[AWSRegion].amazonaws.com/[UserPoolId]/.well-known/jwks.json

Y agregamos el contenido como una cadena de caracteres (string) en la constante JWKS.

index.js
const jwt = require("jsonwebtoken");
const jwkToPem = require("jwk-to-pem");

const REGION = "us-east-1";
const USERPOOLID = "us-east-1_XXXXXXXXX";
// https://cognito-idp.[AWSRegion].amazonaws.com/[UserPoolId]/.well-known/jwks.json
const JWKS = "";

const iss = `https://cognito-idp.${REGION}.amazonaws.com/${USERPOOLID}`;
const keys = JSON.parse(JWKS).keys;
const pems = keys.reduce((o, key) => {
  // Convert each key to PEM
  const key_id = key.kid;
  const modulus = key.n;
  const exponent = key.e;
  const key_type = key.kty;
  const jwk = { kty: key_type, n: modulus, e: exponent };
  const pem = jwkToPem(jwk);
  return { ...o, [key_id]: pem };
}, {});

// Cloudfront Response
const response401 = {
  status: 401,
  body: "Unauthorized",
};

exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers || {};

  // CORS Preflight
  if (`${request.method}`.toUpperCase() === "OPTIONS") {
    return request;
  }

  // Fail if no authorization header found
  if (!headers.authorization) {
    console.log("No authorization header");
    return response401;
  }

  // Strip out "Bearer " to extract JWT token only
  const jwtToken = headers.authorization[0].value.slice(7);
  console.log(`jwtToken=${jwtToken}`);

  // Fail if the token is not jwt
  const decodedJwt = jwt.decode(jwtToken, { complete: true });
  if (!decodedJwt) {
    console.log("Not a valid JWT token");
    return response401;
  }

  // Fail if token is not from your UserPool
  if (decodedJwt.payload.iss !== iss) {
    console.log("Invalid issuer");
    return response401;
  }

  // Reject the jwt if it's not an "Access Token"
  if (decodedJwt.payload.token_use !== "access") {
    console.log("Not an access token");
    return response401;
  }

  // Get the kid from the token and retrieve corresponding PEM
  const kid = decodedJwt.header.kid;
  const pem = pems[kid];
  if (!pem) {
    console.log("Invalid access token");
    return response401;
  }

  try {
    // Verify the signature of the JWT token to ensure
    // it's really coming from your User Pool
    jwt.verify(jwtToken, pem, { issuer: iss });
    // Valid token.
    console.log("Successful verification");
    // Remove authorization header
    delete request.headers.authorization;
    // CloudFront can proceed to fetch the content from origin
    return request;
  } catch (err) {
    // Invalid token.
    console.log("Token failed verification");
    return response401;
  }
};

Después hay que crear un archivo .zip para subir directamente a la función, ya que no se pueden agregar Layers a funciones que son ejecutadas en Edge. Para esto solo necesitamos el archivo index.js y el directorio node_modules/

zip -r lambda.zip index.js node_modules

Una vez creado el archivo .zip, lo subimos a la función.

Cuando tenemos el código listo con nuestra información y hacemos un “Deploy”, es tiempo de crear una “Version” para que pueda ser publicada en Edge y utilizada por Cloudfront.

Una vez creada la “Version” copiamos el ARN de la función.

Dentro del bucket de S3 hay que permitir el acceso con la siguiente configuración CORS:

[
  {
    "AllowedOrigins": ["*"],
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "ExposeHeaders": []
  }
]

En el caso de AllowedOrigins se puede ser tan específico se requiera, para solo permitir el acceso a los archivos a ciertos dominios.

En la distribución de Cloudfront, modificamos el “Behavior” y asociamos en “Viewer Request” una función de “Lambda@Edge” con el ARN de la versión de la función que creamos.

AWS Amplify tiene una opción dentro de la librería de autenticación Auth para obtener la sesión del usuario por medio de Auth.currentAuthenticatedUser() o solo la sesión activa Auth.currentSession(), en este caso vamos a obtener toda la información del usuario para el ejemplo.

En caso de React, es posible obtener la información del usuario de la siguiente manera:

const [user, setUser] = useState(null);
useEffect(() => {
  Auth.currentAuthenticatedUser()
    .then((user) => setUser(user))
    .catch((err) => console.log(err));
}, []);

Ahora, cada vez que necesitamos acceder a la información protegida, es necesario agregar Authorization dentro de los encabezados (headers) con el accessToken del usuario autenticado por Cognito, como por ejemplo, utilizando fetch:

const token = user?.signInUserSession?.accessToken?.jwtToken ?? "";
const res = await fetch("https://example.com/private/top-secret.json", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

En caso de utilizar hls.js la configuración es la siguiente:

const token = user?.signInUserSession?.accessToken?.jwtToken ?? "";
const config = {
  xhrSetup: (xhr, url) => {
    xhr.open("GET", url, true);
    xhr.setRequestHeader("Authorization", `Bearer ${token}`);
  },
};

Si el usuario intenta entrar directamente al archivo, lo único que va a ver es un error 401 que indica que no esta autorizado para acceder.