Implementar OAuth 2.0 y JWT para APIs seguras

Implementar OAuth 2.0 y JWT para APIs seguras

En cualquier sistema distribuido, el contrato definido por una API es tan fuerte como su modelo de seguridad. Los puntos finales sin protección o con una seguridad inadecuada representan una vulnerabilidad crítica, exponiendo datos y lógica empresarial a accesos no autorizados. Para los directores técnicos y líderes de ingeniería, establecer una arquitectura de seguridad robusta, escalable y conforme a los estándares de la industria no es una opción, sino un requisito fundamental. La combinación de OAuth 2.0 para la autorización y JSON Web Tokens (JWT) para los credenciales de acceso ha surgido como la solución definitiva para este desafío.

Este artículo proporciona un esquema técnico detallado para implementar la seguridad de las API utilizando OAuth 2.0 y JWT. Nos adentraremos más allá de la teoría general para explorar el flujo arquitectónico, los pasos de validación críticos y proporcionar ejemplos de código prácticos para construir un servidor de recursos seguro.

Los Componentes Principales: Framework y Token

Es crucial entender que OAuth 2.0 y JWT tienen propósitos distintos pero complementarios. Confundir uno con el otro es un error arquitectónico común.

OAuth 2.0: El marco de autorización

OAuth 2.0 es unmarco de autorización, no un protocolo de autenticación. Su propósito principal es permitir que una aplicación cliente acceda a los recursos en nombre de un usuario (el propietario de los recursos) sin exponer las credenciales del usuario a la aplicación.

El marco define cuatro roles clave:

  • Propietario del recurso: El usuario que posee los datos y otorga permiso.
  • Cliente: La aplicación (por ejemplo, una interfaz web o una aplicación móvil) que solicita acceso a los datos del propietario del recurso.
  • Servidor de autorización (AS): El servidor que autentica al propietario del recurso y emite tokens de acceso después de que se haya otorgado el consentimiento. Este es el principal centro de seguridad.
  • Servidor de recursos (RS): El servidor de API que aloja los recursos protegidos y acepta/valida los tokens de acceso. Este es el servicio de backend que estás desarrollando.

Servicios de Ingeniería de Productos

Colabore con nuestros gestores de proyectos, ingenieros de software y probadores de calidad para desarrollar su nuevo producto de software personalizado o para apoyar su flujo de trabajo actual, siguiendo metodologías Agile, DevOps y Lean.

Build with 4Geeks

Para clientes públicos modernos (como SPAs o aplicaciones móviles), el flujo recomendado es el "Authorization Code Grant con Proof Key for Code Exchange (PKCE)Entrega de código de autorización con clave de prueba para el intercambio de códigos (PKCE)". Proporciona una forma segura de obtener tokens sin exponer ninguna clave del cliente en el navegador, lo que ayuda a prevenir ataques de interceptación de códigos de autorización.

Tokens Web JSON (JWT): El Credencial de Acceso

Un JWT es un medio compacto y seguro para URL, que permite representar los datos a ser transferidos entre dos partes. En nuestro contexto, es el formato del token de acceso emitido por el Servidor de Autorización. Su naturaleza sin estado es su característica más poderosa: el Servidor de Recursos puede validar un JWT y determinar la identidad y los permisos del usuario sin necesidad de realizar una llamada a la base de datos o contactar al Servidor de Autorización.

Un JWT consta de tres partes, separadas por puntos (.):

  1. Encabezado: Contiene metadatos sobre el token, incluyendo el algoritmo de firma (alg, por ejemplo, RS256) y el tipo de token (typ, por ejemplo, JWT).{"alg": "RS256", "typ": "JWT"}
  2. Carga útil: Contiene las afirmaciones, que son declaraciones sobre la entidad (típicamente el usuario) y metadatos adicionales. Las afirmaciones estándar incluyen:
    • iss (Emisor): El Servidor de Autorización que emitió el token.
    • sub (Asunto): El identificador único del propietario del recurso.
    • aud (Audiencia): El destinatario previsto del token (tu Servidor de Recursos/API).
    • exp (Fecha de expiración): La marca de tiempo después de la cual el token deja de ser válido.
    • iat (Emitido en): La marca de tiempo cuando se emitió el token.
    • scp (Alcance): Una lista separada por espacios de permisos otorgados por el usuario.
  3. Firma: Una firma criptográfica utilizada para verificar la integridad del token. Se crea firmando el encabezado y la carga útil codificados con una clave privada que posee el Servidor de Autorización. El Servidor de Recursos utiliza la clave pública correspondiente para validarla.HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

Planos Arquitectónicos: El Código de Autorización + Flujo PKCE

Analicemos el flujo completo, centrándonos en las interacciones entre el cliente, el Servidor de Autorización y tu Servidor de Recursos.

  1. El cliente inicia el flujo: La aplicación del cliente primero genera una cadena aleatoria criptográfica llamada el code_verifier. A continuación, crea un code_challenge mediante el hash SHA256 del verificador y el codificación Base64Url del resultado.
  2. Redirigir al servidor de autorización: El cliente redirige el navegador del usuario al punto final /authorize del servidor de autorización, incluyendo parámetros como client_id, redirect_uri, scope, response_type=code, y los generadoscode_challenge y code_challenge_method=S256.
  3. Autenticación y consentimiento del usuario: El usuario interactúa con el servidor de autorización, introduciendo sus credenciales (autenticación) y aprobando los permisos (scopes) solicitados por el cliente (consentimiento).
  4. Otorgamiento de código de autorización: Tras la autenticación y el consentimiento exitosos, el servidor de autorización redirige al usuario de nuevo al redirect_uri del cliente, con un código de autorización temporal y de uso único.Token de intercambio: El backend del cliente envía una solicitud POST al punto final /token del servidor de autorización. Esta solicitud se realiza de servidor a servidor (no a través del navegador) y incluye el código de autorización recibido junto con el verificador original. El servidor de autorización valida el verificador contra el desafío del código de la solicitud inicial. Si coinciden, esto demuestra que la solicitud de token proviene del mismo cliente que inició el flujo. A continuación, devuelve un token de acceso (JWT) y un token de actualización.Llamada a la API con JWT: El cliente almacena el token de acceso e incluye este en el encabezado de Autorización de todas las solicitudes a su API protegida (el servidor de recursos). Autorización: Bearer <your_jwt_here>
  5. El servidor de recursos valida el JWT:
  6. Este es el paso más crítico para su API. Tras recibir una solicitud, su servidor de recursos debe realizar una serie de validaciones sin estado
  7. antes de ejecutar cualquier lógica de negocio:Verificar la firma: Obtenga la clave pública del servidor de autorización (típicamente de un punto final
    • .well-known/jwks.json y utilícela para verificar la firma del JWT. Esto demuestra que el token fue emitido por el AS de confianza y no ha sido alterado.Validar las afirmaciones estándar:Verifique si la afirmación
    • iss
      • (emisor) coincide con el servidor de autorización esperado.Verifique si la afirmación aud
      • (audiencia) coincide con el identificador único de su API.Verifique si la hora actual es anterior al exp
      • (fecha de caducidad).Verifique los permisos de autorización: Después de la validación, inspeccione la afirmación
    • scp para asegurarse de que el token otorga los permisos necesarios para la operación solicitada. Por ejemplo, una solicitud para DELETE /api/users/123 podría requerir el scope users:delete.usuarios: eliminaralcance.

Solo si se cumplen todas estas comprobaciones, se debe procesar la solicitud.

Implementación práctica: Cómo asegurar una API de Python

Implementemos la lógica de validación de JWT (Paso 7) en una API de Python utilizando FastAPI. Este ejemplo asume que tiene un Servidor de Autenticación (como Auth0, Okta o Keycloak) que proporciona un punto final JWKS.

Dependencias

pip install "fastapi[all]" "python-jose[cryptography]" "requests"

Lógica de validación JWT

Crearemos una dependencia reutilizable que gestione la extracción y validación de tokens. Este código obtiene las claves públicas del URI de JWKS, las almacena en caché para evitar la latencia de red en cada solicitud, y realiza los pasos de validación.

# src/security.py

import os
import requests
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from functools import lru_cache

# --- Configuration ---
# In a real app, load these from environment variables or a config file.
AUTH_SERVER_URL = "https://your-auth-server.com/" 
API_AUDIENCE = "https://api.yourapp.com"
ALGORITHMS = ["RS256"]

oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{AUTH_SERVER_URL}oauth/token")

# --- JWKS Caching and Public Key Retrieval ---
@lru_cache()
def get_jwks():
    """
    Fetches the JSON Web Key Set (JWKS) from the authorization server.
    Uses lru_cache for in-memory caching to improve performance.
    """
    try:
        response = requests.get(f"{AUTH_SERVER_URL}.well-known/jwks.json")
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Could not connect to Authorization Server: {e}"
        )

def get_signing_key(token: str):
    """
    Finds the appropriate public key from the JWKS to verify the token's signature.
    """
    try:
        unverified_header = jwt.get_unverified_header(token)
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token header"
        )
    
    rsa_key = {}
    jwks = get_jwks()
    
    for key in jwks["keys"]:
        if key["kid"] == unverified_header["kid"]:
            rsa_key = {
                "kty": key["kty"],
                "kid": key["kid"],
                "use": key["use"],
                "n": key["n"],
                "e": key["e"],
            }
    if not rsa_key:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Signing key not found in JWKS"
        )
    return rsa_key


# --- Main Token Validation Dependency ---
async def validate_token(token: str = Depends(oauth2_scheme)) -> dict:
    """
    Validates a JWT token and returns its payload if valid.
    This function acts as a FastAPI dependency.
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    signing_key = get_signing_key(token)
    
    try:
        payload = jwt.decode(
            token,
            signing_key,
            algorithms=ALGORITHMS,
            audience=API_AUDIENCE,
            issuer=AUTH_SERVER_URL
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, 
            detail="Token has expired"
        )
    except jwt.JWTClaimsError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, 
            detail=f"Invalid claims: {e}"
        )
    except JWTError:
        raise credentials_exception

Servicios de Ingeniería de Productos

Trabaje con nuestros gestores de proyectos, ingenieros de software y probadores de calidad, para desarrollar su nuevo producto de software personalizado o para apoyar su flujo de trabajo actual, siguiendo metodologías Agile, DevOps y Lean.

Build with 4Geeks

Protegiendo un Endpoint

Con la dependencia de validación creada, asegurar un punto final es limpio y declarativo. También podemos añadir otra dependencia para verificar los permisos necesarios.

# src/main.py

from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List

from .security import validate_token

app = FastAPI()

class Item(BaseModel):
    id: int
    name: str

# --- Scope Checking Dependency ---
def require_scope(required_scopes: List[str]):
    """
    A dependency factory to check if the token has the required scopes.
    """
    async def scope_checker(payload: dict = Depends(validate_token)):
        token_scopes = payload.get("scope", "").split()
        for scope in required_scopes:
            if scope not in token_scopes:
                raise HTTPException(
                    status_code=status.HTTP_403_FORBIDDEN,
                    detail=f"Missing required scope: {scope}"
                )
        return payload
    return scope_checker

# --- Protected Endpoints ---

@app.get("/api/items", response_model=List[Item])
async def read_items(
    # This endpoint requires a valid token with the "items:read" scope
    payload: dict = Depends(require_scope(["items:read"]))
):
    # The `sub` claim from the token payload can be used to identify the user
    user_id = payload.get("sub")
    print(f"Fetching items for user: {user_id}")
    return [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]

@app.post("/api/items", response_model=Item)
async def create_item(
    item: Item,
    # This endpoint requires a valid token with the "items:write" scope
    payload: dict = Depends(require_scope(["items:write"]))
):
    print(f"User {payload.get('sub')} is creating an item.")
    # Business logic to create the item...
    return item

Esta implementación demuestra un patrón robusto y reutilizable. La lógica de seguridad está desacoplada de la lógica de negocio, y los endpoints especifican de forma declarativa sus requisitos de seguridad.

Consideraciones finales

  • Duración del token: Mantenga la duración de los tokens de acceso corta (por ejemplo, 5-15 minutos) para limitar el impacto de un token comprometido. Utilice tokens de actualización de larga duración, almacenados de forma segura en el cliente, para obtener nuevos tokens de acceso sin necesidad de que el usuario se autentique nuevamente.
  • Revocación de tokens: Si bien los JWT son sin estado, es posible que necesite un mecanismo para revocar el acceso antes de que caduque (por ejemplo, cuando un usuario cierra sesión o cambia su contraseña). Esto normalmente requiere introducir una capa con estado, como una lista de revocación verificada por la API, lo que implica renunciar a algunas de las ventajas del funcionamiento sin estado a cambio de un mayor control de seguridad.
  • No guarde datos sensibles en los JWT: El payload de JWT está codificado en Base64Url, no está encriptado. Es de lectura pública. Nunca guarde información sensible como contraseñas o datos personales directamente en el payload.

Al implementar el marco de trabajo OAuth 2.0 con JWTs, usted crea un modelo de seguridad de API que no solo es robusto y cumple con los estándares modernos, sino que también es altamente escalable y desacoplado, lo que permite que sus servicios crezcan de forma segura.

Preguntas frecuentes

¿Cuál es la diferencia entre OAuth 2.0 y JWT?

OAuth 2.0 es un marco de autorización, no un protocolo de autenticación. Su propósito principal es permitir que una aplicación cliente acceda a los recursos en nombre de un usuario sin exponer las credenciales de ese usuario. Define los roles y los flujos para delegar el acceso. Un Token Web JSON (JWT), por otro lado, es un formato de credenciales. Es un token compacto y seguro para URL que representa reclamaciones entre dos partes. En este contexto, el JWT es el token de acceso emitido por un servidor de autorización de OAuth 2.0, que la API (Servidor de Recursos) puede validar para confirmar la identidad y los permisos de un usuario.

¿Cómo valida una API un Token Web JWT (JSON)?

Una API (o Servidor de Recursos) debe realizar varios pasos de validación clave antes de confiar en un JWT y procesar una solicitud.

  • Verificar la Firma: El servidor recupera la clave pública del servidor de autorización (a menudo de un .well-known/jwks.json endpoint) y la utiliza para verificar criptográficamente que la firma del token es válida y fue emitida por la autoridad de confianza.
  • Validar las Afirmaciones Estándar: El servidor verifica las afirmaciones críticas en el contenido del token, como la iss (emisor) para asegurarse de que proviene de la fuente correcta, la aud (audiencia) para asegurarse de que el token está destinado a esta API específica, y la exp (expiración) para asegurarse de que el token no ha caducado.
  • Comprobar los Alcances: Después de la validación, el servidor inspecciona la scp (alcance) para asegurarse de que el token otorga permisos suficientes para la operación específica solicitada (por ejemplo, users:delete).

¿Qué es el flujo de Autorización de Código con PKCE?

El flujo de Autorización de Código con "Proof Key for Code Exchange" (PKCE) es el flujo OAuth 2.0 recomendado y más seguro para aplicaciones modernas como aplicaciones de una sola página (SPA) y aplicaciones móviles.

  1. La aplicación cliente genera una clave secreta y una versión transformada llamada un code_verifier.
  2. Redirige al usuario al servidor de autorización con la code_challenge.
  3. El usuario inicia sesión y otorga el consentimiento.
  4. El servidor envía un código de autorización temporal de vuelta a la aplicación cliente.
  5. La aplicación cliente envía este código de autorización y la clave original code_verifier al punto final del token. El servidor de autorización valida la code_verifier contra el code_challenge que tiene almacenada. Si coinciden, emite el token de acceso (JWT). Este proceso asegura que, incluso si el código de autorización se intercepta, es inútil sin la code_verifier, evitando así los ataques de interceptación.