Autenticando rutas de API Gateway desde varios pools Cognito

Autenticando rutas de API Gateway desde varios pools Cognito

En nuestra experiencia de consultoría en sistemas de pago notamos que los proyectos más solicitados actualmente tienen que ver con modernización de arquitecturas de microservicios implementadas sobre una gran variedad de tecnologías y servicios que en su momento cumplieron su objetivo pero que en la actualidad fueron superados por soluciones más potentes y sencillas.

En una de estas experiencias nos encontramos con un cliente con esta necesidad de modernización donde las comunicaciones entre sus microservicios se hacían a través de un API Gateway basado en una tecnología propietaria y que se encargaba de autenticar todas las comunicaciones desde una base de datos centralizada. En esta arquitectura existía un componente central de autenticación conocido como "Auth Database" el cual hacía las operaciones de autenticar clientes así como proveer y validar tokens. La característica más importante de este esquema era la fuerte integración entre el "Auth Database" y el "API Gateway" que al ser productos del mismo proveedor permitía que una ruta pueda ser utilizada por clientes pertenecientes a N grupos dentro del "Auth Database" donde los "grupos" eran equivalentes a "pools" del servicio Cognito de AWS.

El flujo básico de autenticación/autorización puede verse en la siguiente imagen:

Aquí la secuencia de pasos es la siguiente:

  1. Los clientes hacen una llamada al API "/transferencia" con un token opaco.
  2. Este token era verificado contra 2 pools diferentes.
  3. Los microservicios se comunican con sus pares a través del mismo API Gateway y se autentican con sus pools individuales.
  4. Estos a su vez disparan llamadas a otros microservicios.

Al ser un API Gateway propietario, y con la necesidad de adaptar la arquitectura a su stack AWS, nos pusimos a diseñar una solución equivalente utilizando el API Gateway de AWS el cual decía tener integración con Cognito, durante este proceso nos dimos cuenta que la implementación de este esquema no sería tan fácil como inicialmente lo planeamos aunque finalmente llegamos a la solución.

En esta entrada explicamos las soluciones que se implementaron.

Tipos de API Gateway

AWS ofrece 3 tipos de APIs: REST, HTTP y WebSocket, de las cuales elegimos probar 2: HTTP y REST. La diferencia entre ambas está en las herramientas complementarias (monitoreo, integraciones, seguridad, etc.) siendo la funcionalidad base idéntica entre ambos, la cual es rutear una URI a un endpoint, esta diferencia de complementos se traduce a una diferencia de precios, siendo más cara la versión REST.

Veamos en detalle las diferencias en el área que nos interesa: la autorización.

API REST

Inicialmente no encontramos un mecanismo para autorizar desde 2 pools diferentes en la documentación oficial lo cual nos pareció extraño ya que debería tener mejor integración con Cognito según la lista de features de REST vs HTTP. Nos pusimos a investigar más a fondo y luego de revisar el formato de los templates de Cloudformation para API REST notamos que a la hora de crear un Authorizer la sección de "PrividerARNs" era una lista en vez de un string, por lo cual en teoría se podía configurar un authorizer para que use más de un pool.

Luego de esto armamos una prueba y confirmamos que la autorización de usuarios de pools diferentes funcionaba correctamente, el flujo de esta prueba puede verse a continuación:

Flujo con API REST:

  1. El cliente intenta acceder a la ruta adjuntando su access_token al request.
  2. El request termina en el Authorizer el cual valida el token contra sus dos pools configurados.
  3. Si el token es válido en alguno de los pools, se acepta el request y se lo envía al microservicio final.

API HTTP

Con este tipo de APIs encontramos un problema diferente, aquí la integración con Cognito no es tan completa y es necesario usar el mecanismo genérico de "JWT Authorizer" para poder autorizar tokens de Cognito, el problema con este mecanismo es que no permite validar contra más de un pool de usuarios.

Luego de investigar un poco más llegamos a la conclusión que la única opción restante era la de utilizar un Lambda Authorizer con el cual podríamos implementar una solución personalizada, finalmente la solución implementada fue la siguiente:

Flujo con API HTTP:

  1. El cliente intenta acceder a la ruta adjuntando sus credenciales (access_token o id_token) al request.
  2. El API Gateway envía los datos del request al Lambda Authorizer.
  3. El Lambda Authorizer verifica el token contra los pools de Cognito.
  4. El Lambda Authorizer retorna un policy document con el resultado de la autorización.
  5. El API Gateway cachea el policy para no volver a llamar a la función Lambda, esto es opcional.
  6. Dependiendo del contenido del policy, se permite el acceso a la ruta original.

A continuación se muestra un ejemplo de un Lambda Authorizer sencillo que valida contra dos pools de Cognito:

import json
import urllib.request
import jwt
import os
from jwt.algorithms import RSAAlgorithm

def lambda_handler(event, context):
    token = event['authorizationToken']
    methodArn = event['methodArn']
    
    # Cognito user pool IDs
    user_pool_ids = [
        "us-east-1_examplePool1",  # Replace with your first user pool ID
        "us-east-1_examplePool2"   # Replace with your second user pool ID
    ]
    
    # Validate token against each user pool
    for user_pool_id in user_pool_ids:
        try:
            keys_url = f'https://cognito-idp.{os.getenv("AWS_REGION")}.amazonaws.com/{user_pool_id}/.well-known/jwks.json'
            response = urllib.request.urlopen(keys_url)
            keys = json.loads(response.read())['keys']
            
            # Extract the public key
            header = jwt.get_unverified_header(token)
            key = next(k for k in keys if k['kid'] == header['kid'])
            public_key = RSAAlgorithm.from_jwk(json.dumps(key))
            
            # Decode and validate the token
            decoded_token = jwt.decode(token, public_key, algorithms=['RS256'], audience=user_pool_id)
            
            # If valid, return the policy
            return generate_policy(decoded_token['sub'], 'Allow', methodArn)
        
        except Exception as e:
            print(f"Error: {e}")
            continue  # Try the next user pool
    
    # If token is not valid for any user pool, return deny policy
    return generate_policy('user', 'Deny', methodArn)


def generate_policy(principal_id, effect, resource):
    policy_document = {
        'Version': '2012-10-17',
        'Statement': [{
            'Action': 'execute-api:Invoke',
            'Effect': effect,
            'Resource': resource
        }]
    }
    return {
        'principalId': principal_id,
        'policyDocument': policy_document
    }

Conclusión

Los mecanismos de autenticación desde múltiples pools de Cognito en un API Gateway de AWS resultaron interesantes, especialmente cuando se intenta replicar la funcionalidad de un API Gateway propietario. Sin embargo, a través de una investigación y pruebas detalladas, logramos diseñar un par de soluciones efectivas tanto para APIs REST como para APIs HTTP.

En el caso de las APIs REST, la capacidad de configurar múltiples pools en un Authorizer simplificó el proceso de autenticación. Para las APIs HTTP, aunque la integración inicial con Cognito fue limitada, el uso de un Lambda Authorizer proporcionó la flexibilidad necesaria para validar tokens desde múltiples pools. Estos enfoques demostraron la versatilidad del ecosistema AWS en la resolución de problemas complejos de integración.

Read more