Cómo validar tokens de acceso en tu servicio API o backend
Validar tokens de acceso es una parte fundamental para aplicar el control de acceso basado en roles (RBAC) en Logto. Esta guía te guía a través de la verificación de JWTs emitidos por Logto en tu backend / API, comprobando la firma, emisor, audiencia, expiración, permisos (alcances) y contexto de organización.
Antes de comenzar
- Esta guía asume que estás familiarizado con los conceptos de RBAC de Logto.
- Si estás protegiendo recursos de API, esta guía asume que ya has seguido la guía Proteger recursos de API globales.
- Si estás protegiendo funciones o flujos internos de la aplicación (permisos que no son de API), esta guía asume que ya has seguido la guía Proteger permisos de organización (no API).
- Si estás protegiendo recursos de API a nivel de organización, esta guía asume que ya has seguido la guía Proteger recursos de API a nivel de organización.
Paso 1: Inicializar constantes y utilidades
Define las constantes y utilidades necesarias en tu código para manejar la extracción y validación de tokens. Una solicitud válida debe incluir un encabezado Authorization
en la forma Bearer <token de acceso (access token)>
.
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
import { IncomingHttpHeaders } from 'http';
const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
const ISSUER = 'https://your-tenant.logto.app/oidc';
export class AuthInfo {
constructor(
public sub: string,
public clientId?: string,
public organizationId?: string,
public scopes: string[] = [],
public audience: string[] = []
) {}
}
export class AuthorizationError extends Error {
name = 'AuthorizationError';
constructor(
message: string,
public status = 403
) {
super(message);
}
}
export function extractBearerTokenFromHeaders({ authorization }: IncomingHttpHeaders): string {
const bearerPrefix = 'Bearer ';
if (!authorization) {
throw new AuthorizationError('Authorization header is missing', 401);
}
if (!authorization.startsWith(bearerPrefix)) {
throw new AuthorizationError(`Authorization header must start with "${bearerPrefix}"`, 401);
}
return authorization.slice(bearerPrefix.length);
}
JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks'
ISSUER = 'https://your-tenant.logto.app/oidc'
class AuthInfo:
def __init__(self, sub: str, client_id: str = None, organization_id: str = None,
scopes: list = None, audience: list = None):
self.sub = sub
self.client_id = client_id
self.organization_id = organization_id
self.scopes = scopes or []
self.audience = audience or []
def to_dict(self):
return {
'sub': self.sub,
'client_id': self.client_id,
'organization_id': self.organization_id,
'scopes': self.scopes,
'audience': self.audience
}
class AuthorizationError(Exception):
def __init__(self, message: str, status: int = 403):
self.message = message
self.status = status
super().__init__(self.message)
def extract_bearer_token_from_headers(headers: dict) -> str:
"""
Extrae el token bearer de los encabezados HTTP.
Nota: FastAPI y Django REST Framework tienen extracción de tokens incorporada,
por lo que esta función es principalmente para Flask y otros frameworks.
"""
authorization = headers.get('authorization') or headers.get('Authorization')
if not authorization:
raise AuthorizationError('El encabezado de autorización falta', 401)
if not authorization.startswith('Bearer '):
raise AuthorizationError('El encabezado de autorización debe comenzar con "Bearer "', 401)
return authorization[7:] # Elimina el prefijo 'Bearer '
package main
import (
"fmt"
"net/http"
"strings"
)
const (
JWKS_URI = "https://your-tenant.logto.app/oidc/jwks"
ISSUER = "https://your-tenant.logto.app/oidc"
)
type AuthorizationError struct {
Message string
Status int
}
func (e *AuthorizationError) Error() string {
return e.Message
}
func NewAuthorizationError(message string, status ...int) *AuthorizationError {
statusCode := http.StatusForbidden // Por defecto 403 Prohibido
if len(status) > 0 {
statusCode = status[0]
}
return &AuthorizationError{
Message: message,
Status: statusCode,
}
}
func extractBearerTokenFromHeaders(r *http.Request) (string, error) {
const bearerPrefix = "Bearer "
authorization := r.Header.Get("Authorization")
if authorization == "" {
return "", NewAuthorizationError("El encabezado Authorization (Authorization header) falta", http.StatusUnauthorized)
}
if !strings.HasPrefix(authorization, bearerPrefix) {
return "", NewAuthorizationError(fmt.Sprintf("El encabezado Authorization (Authorization header) debe comenzar con \"%s\"", bearerPrefix), http.StatusUnauthorized)
}
return strings.TrimPrefix(authorization, bearerPrefix), nil
}
public class AuthorizationException extends RuntimeException {
private final int statusCode;
public AuthorizationException(String message) {
this(message, 403); // Por defecto 403 Prohibido
}
public AuthorizationException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public int getStatusCode() {
return statusCode;
}
}
namespace YourApiNamespace
{
public static class AuthConstants
{
public const string Issuer = "https://your-tenant.logto.app/oidc";
}
}
namespace YourApiNamespace.Exceptions
{
public class AuthorizationException : Exception
{
public int StatusCode { get; }
public AuthorizationException(string message, int statusCode = 403) : base(message)
{
StatusCode = statusCode;
}
}
}
<?php
class AuthConstants
{
public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
<?php
class AuthInfo
{
public function __construct(
public readonly string $sub,
public readonly ?string $clientId = null,
public readonly ?string $organizationId = null,
public readonly array $scopes = [],
public readonly array $audience = []
) {}
public function toArray(): array
{
return [
'sub' => $this->sub,
'client_id' => $this->clientId,
'organization_id' => $this->organizationId,
'scopes' => $this->scopes,
'audience' => $this->audience,
];
}
}
<?php
class AuthorizationException extends Exception
{
public function __construct(
string $message,
public readonly int $statusCode = 403
) {
parent::__construct($message);
}
}
<?php
trait AuthHelpers
{
protected function extractBearerToken(array $headers): string
{
$authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;
if (!$authorization) {
throw new AuthorizationException('El encabezado de autorización (Authorization header) falta', 401);
}
if (!str_starts_with($authorization, 'Bearer ')) {
throw new AuthorizationException('El encabezado de autorización (Authorization header) debe comenzar con "Bearer "', 401);
}
return substr($authorization, 7); // Elimina el prefijo 'Bearer '
}
}
module AuthConstants
JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks'
ISSUER = 'https://your-tenant.logto.app/oidc'
end
class AuthInfo
attr_accessor :sub, :client_id, :organization_id, :scopes, :audience
def initialize(sub, client_id = nil, organization_id = nil, scopes = [], audience = [])
@sub = sub
@client_id = client_id
@organization_id = organization_id
@scopes = scopes
@audience = audience
end
def to_h
{
sub: @sub,
client_id: @client_id,
organization_id: @organization_id,
scopes: @scopes,
audience: @audience
}
end
end
class AuthorizationError < StandardError
attr_reader :status
def initialize(message, status = 403)
super(message)
@status = status
end
end
module AuthHelpers
def extract_bearer_token(request)
authorization = request.headers['Authorization']
raise AuthorizationError.new('Authorization header is missing', 401) unless authorization
raise AuthorizationError.new('Authorization header must start with "Bearer "', 401) unless authorization.start_with?('Bearer ')
authorization[7..-1] # Remove 'Bearer ' prefix
end
end
use serde::{Deserialize, Serialize};
use std::fmt;
pub const JWKS_URI: &str = "https://your-tenant.logto.app/oidc/jwks";
pub const ISSUER: &str = "https://your-tenant.logto.app/oidc";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthInfo {
pub sub: String,
pub client_id: Option<String>,
pub organization_id: Option<String>,
pub scopes: Vec<String>,
pub audience: Vec<String>,
}
impl AuthInfo {
pub fn new(
sub: String,
client_id: Option<String>,
organization_id: Option<String>,
scopes: Vec<String>,
audience: Vec<String>,
) -> Self {
Self {
sub,
client_id,
organization_id,
scopes,
audience,
}
}
}
#[derive(Debug)]
pub struct AuthorizationError {
pub message: String,
pub status_code: u16,
}
impl AuthorizationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status_code: 403,
}
}
pub fn with_status(message: impl Into<String>, status_code: u16) -> Self {
Self {
message: message.into(),
status_code,
}
}
}
impl fmt::Display for AuthorizationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AuthorizationError {}
pub fn extract_bearer_token(authorization: Option<&str>) -> Result<&str, AuthorizationError> {
let auth_header = authorization.ok_or_else(|| {
AuthorizationError::with_status("Falta el encabezado de autorización (Authorization header is missing)", 401)
})?;
if !auth_header.starts_with("Bearer ") {
return Err(AuthorizationError::with_status(
"El encabezado de autorización debe comenzar con \"Bearer \" (Authorization header must start with \"Bearer \")",
401,
));
}
Ok(&auth_header[7..]) // Elimina el prefijo 'Bearer ' (Remove 'Bearer ' prefix)
}
Paso 2: Recuperar información sobre tu tenant de Logto
Necesitarás los siguientes valores para validar los tokens emitidos por Logto:
- URI de JSON Web Key Set (JWKS): La URL a las claves públicas de Logto, utilizada para verificar las firmas de JWT.
- Emisor (Issuer): El valor esperado del emisor (la URL OIDC de Logto).
Primero, encuentra el endpoint de tu tenant de Logto. Puedes encontrarlo en varios lugares:
- En la Consola de Logto, en Configuración → Dominios.
- En cualquier configuración de aplicación que hayas configurado en Logto, Configuración → Endpoints y Credenciales.
Obtener desde el endpoint de descubrimiento de OpenID Connect
Estos valores pueden obtenerse desde el endpoint de descubrimiento de OpenID Connect de Logto:
https://<your-logto-endpoint>/oidc/.well-known/openid-configuration
Aquí tienes un ejemplo de respuesta (otros campos omitidos por brevedad):
{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}
Codificar directamente en tu código (no recomendado)
Dado que Logto no permite personalizar la URI de JWKS ni el emisor, puedes codificar estos valores directamente en tu código. Sin embargo, esto no se recomienda para aplicaciones en producción, ya que puede aumentar la carga de mantenimiento si alguna configuración cambia en el futuro.
- URI de JWKS:
https://<your-logto-endpoint>/oidc/jwks
- Emisor:
https://<your-logto-endpoint>/oidc
Paso 3: Validar el token y los permisos
Después de extraer el token y obtener la configuración OIDC, valida lo siguiente:
- Firma: El JWT debe ser válido y estar firmado por Logto (a través de JWKS).
- Emisor (Issuer): Debe coincidir con el emisor de tu tenant de Logto.
- Audiencia (Audience): Debe coincidir con el indicador de recurso de la API registrado en Logto, o el contexto de la organización si corresponde.
- Expiración: El token no debe estar expirado.
- Permisos (Alcances / scopes): El token debe incluir los alcances requeridos para tu API / acción. Los alcances son cadenas separadas por espacios en el reclamo
scope
. - Contexto de organización: Si proteges recursos de API a nivel de organización, valida el reclamo
organization_id
.
Consulta JSON Web Token para aprender más sobre la estructura y los reclamos de JWT.
Qué verificar para cada modelo de permisos
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
- Reclamo de audiencia (
aud
): Indicador de recurso de API - Reclamo de organización (
organization_id
): No presente - Alcances (permisos) a verificar (
scope
): Permisos de recurso de API
- Reclamo de audiencia (
aud
):urn:logto:organization:<id>
(el contexto de organización está en el reclamoaud
) - Reclamo de organización (
organization_id
): No presente - Alcances (permisos) a verificar (
scope
): Permisos de organización
- Reclamo de audiencia (
aud
): Indicador de recurso de API - Reclamo de organización (
organization_id
): ID de la organización (debe coincidir con la solicitud) - Alcances (permisos) a verificar (
scope
): Permisos de recurso de API
Para los permisos de organización que no son de API, el contexto de la organización está
representado por el reclamo aud
(por ejemplo, urn:logto:organization:abc123
). El reclamo
organization_id
solo está presente para tokens de recursos de API a nivel de organización.
Valida siempre tanto los permisos (alcances) como el contexto (audiencia, organización) para APIs multi-tenant seguras.
Añadir la lógica de validación
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
Usamos jose en este ejemplo para validar el JWT. Instálalo si aún no lo has hecho:
npm install jose
O utiliza tu gestor de paquetes preferido (por ejemplo, pnpm
o yarn
).
Primero, añade estas utilidades compartidas para manejar la validación de JWT:
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import { AuthInfo, AuthorizationError } from './auth-middleware.js';
const jwks = createRemoteJWKSet(new URL(JWKS_URI));
export async function validateJwt(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
});
verifyPayload(payload);
return payload;
}
export function createAuthInfo(payload: JWTPayload): AuthInfo {
const scopes = (payload.scope as string)?.split(' ') ?? [];
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
return new AuthInfo(
payload.sub!,
payload.client_id as string,
payload.organization_id as string,
scopes,
audience
);
}
function verifyPayload(payload: JWTPayload): void {
// Implementa aquí tu lógica de verificación basada en el modelo de permisos
// Esto se mostrará en la sección de modelos de permisos a continuación
}
Luego, implementa el middleware para verificar el token de acceso:
- Express.js
- Fastify
- Hapi.js
- Koa.js
- NestJS
import { Request, Response, NextFunction } from 'express';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
// Extiende la interfaz Request de Express para incluir auth
declare global {
namespace Express {
interface Request {
auth?: AuthInfo;
}
}
}
export async function verifyAccessToken(req: Request, res: Response, next: NextFunction) {
try {
const token = extractBearerTokenFromHeaders(req.headers);
const payload = await validateJwt(token);
// Almacena la información de autenticación en la solicitud para uso genérico
req.auth = createAuthInfo(payload);
next();
} catch (err: any) {
return res.status(err.status ?? 401).json({ error: err.message });
}
}
import { FastifyRequest, FastifyReply } from 'fastify';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
// Extiende la interfaz Fastify Request para incluir auth
declare module 'fastify' {
interface FastifyRequest {
auth?: AuthInfo;
}
}
export async function fastifyVerifyAccessToken(request: FastifyRequest, reply: FastifyReply) {
try {
const token = extractBearerTokenFromHeaders(request.headers);
const payload = await validateJwt(token);
// Almacena la información de autenticación en la solicitud para uso genérico
request.auth = createAuthInfo(payload);
} catch (err: any) {
reply.code(err.status ?? 401).send({ error: err.message });
}
}
import { Request, ResponseToolkit } from '@hapi/hapi';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
export async function hapiVerifyAccessToken(request: Request, h: ResponseToolkit) {
try {
const token = extractBearerTokenFromHeaders(request.headers);
const payload = await validateJwt(token);
// Almacena la información de autenticación en request.app para uso genérico
request.app.auth = createAuthInfo(payload);
return h.continue;
} catch (err: any) {
return h
.response({ error: err.message })
.code(err.status ?? 401)
.takeover();
}
}
import { Context, Next } from 'koa';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
export async function koaVerifyAccessToken(ctx: Context, next: Next) {
try {
const token = extractBearerTokenFromHeaders(ctx.request.headers);
const payload = await validateJwt(token);
// Almacenar la información de autenticación en el estado para uso genérico
ctx.state.auth = createAuthInfo(payload);
await next();
} catch (err: any) {
ctx.status = err.status ?? 401;
ctx.body = { error: err.message };
}
}
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
@Injectable()
export class AccessTokenGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
try {
const token = extractBearerTokenFromHeaders(req.headers);
const payload = await validateJwt(token);
// Almacenar la información de autenticación en la solicitud para uso genérico
req.auth = createAuthInfo(payload);
return true;
} catch (err: any) {
if (err.status === 401) throw new UnauthorizedException(err.message);
throw new ForbiddenException(err.message);
}
}
}
De acuerdo con tu modelo de permisos, implementa la lógica de verificación adecuada en jwt-validator.ts
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
function verifyPayload(payload: JWTPayload): void {
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.includes('https://your-api-resource-indicator')) {
throw new AuthorizationError('Audiencia no válida');
}
// Verifica los alcances requeridos para recursos de API globales
const requiredScopes = ['api:read', 'api:write']; // Reemplaza con tus alcances requeridos reales
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Alcance insuficiente');
}
}
function verifyPayload(payload: JWTPayload): void {
// Verifica que el reclamo de audiencia coincida con el formato de organización
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
const hasOrgAudience = audiences.some((aud) => aud.startsWith('urn:logto:organization:'));
if (!hasOrgAudience) {
throw new AuthorizationError('Audiencia no válida para permisos de organización');
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
const expectedOrgId = 'your-organization-id'; // Extrae del contexto de la solicitud
const expectedAud = `urn:logto:organization:${expectedOrgId}`;
if (!audiences.includes(expectedAud)) {
throw new AuthorizationError('ID de organización no coincide');
}
// Verifica los alcances requeridos de la organización
const requiredScopes = ['invite:users', 'manage:settings']; // Reemplaza con tus alcances requeridos reales
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Alcance de organización insuficiente');
}
}
function verifyPayload(payload: JWTPayload): void {
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.includes('https://your-api-resource-indicator')) {
throw new AuthorizationError(
'Audiencia no válida para recursos de API a nivel de organización'
);
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
const expectedOrgId = 'your-organization-id'; // Extrae del contexto de la solicitud
const orgId = payload.organization_id as string;
if (expectedOrgId !== orgId) {
throw new AuthorizationError('ID de organización no coincide');
}
// Verifica los alcances requeridos para recursos de API a nivel de organización
const requiredScopes = ['api:read', 'api:write']; // Reemplaza con tus alcances requeridos reales
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Alcances de API a nivel de organización insuficientes');
}
}
Usamos PyJWT para validar JWTs. Instálalo si aún no lo has hecho:
pip install pyjwt[crypto]
Primero, añade estas utilidades compartidas para manejar la validación de JWT:
import jwt
from jwt import PyJWKClient
from typing import Dict, Any
from auth_middleware import AuthInfo, AuthorizationError, JWKS_URI, ISSUER
jwks_client = PyJWKClient(JWKS_URI)
def validate_jwt(token: str) -> Dict[str, Any]:
"""Validar JWT y devolver el payload"""
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
issuer=ISSUER,
options={'verify_aud': False} # Verificaremos la audiencia manualmente
)
verify_payload(payload)
return payload
except jwt.InvalidTokenError as e:
raise AuthorizationError(f'Token inválido: {str(e)}', 401)
except Exception as e:
raise AuthorizationError(f'La validación del token falló: {str(e)}', 401)
def create_auth_info(payload: Dict[str, Any]) -> AuthInfo:
"""Crear AuthInfo desde el payload del JWT"""
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
audience = payload.get('aud', [])
if isinstance(audience, str):
audience = [audience]
return AuthInfo(
sub=payload.get('sub'),
client_id=payload.get('client_id'),
organization_id=payload.get('organization_id'),
scopes=scopes,
audience=audience
)
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verificar el payload según el modelo de permisos"""
# Implementa aquí tu lógica de verificación basada en el modelo de permisos
# Esto se mostrará en la sección de modelos de permisos a continuación
pass
Luego, implementa el middleware para verificar el token de acceso:
- FastAPI
- Flask
- Django
- Django REST Framework
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jwt_validator import validate_jwt, create_auth_info
security = HTTPBearer()
async def verify_access_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> AuthInfo:
try:
token = credentials.credentials
payload = validate_jwt(token)
return create_auth_info(payload)
except AuthorizationError as e:
raise HTTPException(status_code=e.status, detail=str(e))
from functools import wraps
from flask import request, jsonify, g
from jwt_validator import validate_jwt, create_auth_info
def verify_access_token(f):
@wraps(f)
def decorated_function(*args, **kwargs):
try:
token = extract_bearer_token_from_headers(dict(request.headers))
payload = validate_jwt(token)
# Almacena la información de autenticación en el objeto g de Flask para uso general
g.auth = create_auth_info(payload)
return f(*args, **kwargs)
except AuthorizationError as e:
return jsonify({'error': str(e)}), e.status
return decorated_function
from django.http import JsonResponse
from jwt_validator import validate_jwt, create_auth_info
def require_access_token(view_func):
def wrapper(request, *args, **kwargs):
try:
headers = {key.replace('HTTP_', '').replace('_', '-').lower(): value
for key, value in request.META.items() if key.startswith('HTTP_')}
token = extract_bearer_token_from_headers(headers)
payload = validate_jwt(token)
# Adjunta la información de autenticación al request para uso genérico
request.auth = create_auth_info(payload)
return view_func(request, *args, **kwargs)
except AuthorizationError as e:
return JsonResponse({'error': str(e)}, status=e.status)
return wrapper
from rest_framework.authentication import TokenAuthentication
from rest_framework import exceptions
from jwt_validator import validate_jwt, create_auth_info
class AccessTokenAuthentication(TokenAuthentication):
keyword = 'Bearer' # Usa 'Bearer' en lugar de 'Token'
def authenticate_credentials(self, key):
"""
Autentica el token validándolo como un JWT.
"""
try:
payload = validate_jwt(key)
auth_info = create_auth_info(payload)
# Crea un objeto similar a usuario que contiene la información de autenticación para uso genérico
user = type('User', (), {
'auth': auth_info,
'is_authenticated': True,
'is_anonymous': False,
'is_active': True,
})()
return (user, key)
except AuthorizationError as e:
if e.status == 401:
raise exceptions.AuthenticationFailed(str(e))
else: # 403
raise exceptions.PermissionDenied(str(e))
De acuerdo con tu modelo de permisos, implementa la lógica de verificación apropiada en jwt_validator.py
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verificar el payload para recursos de API globales"""
# Verifica que el claim de audiencia coincida con tu indicador de recurso de API
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'https://your-api-resource-indicator' not in audiences:
raise AuthorizationError('Audiencia inválida')
# Verifica los alcances requeridos para recursos de API globales
required_scopes = ['api:read', 'api:write'] # Reemplaza con tus alcances requeridos reales
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Alcance insuficiente')
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verificar el payload para permisos de organización"""
# Verifica que el claim de audiencia coincida con el formato de organización
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
has_org_audience = any(aud.startswith('urn:logto:organization:') for aud in audiences)
if not has_org_audience:
raise AuthorizationError('Audiencia inválida para permisos de organización')
# Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expected_org_id = 'your-organization-id' # Extraer del contexto de la solicitud
expected_aud = f'urn:logto:organization:{expected_org_id}'
if expected_aud not in audiences:
raise AuthorizationError('ID de organización no coincide')
# Verifica los alcances requeridos de la organización
required_scopes = ['invite:users', 'manage:settings'] # Reemplaza con tus alcances requeridos reales
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Alcance de organización insuficiente')
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verificar el payload para recursos de API a nivel de organización"""
# Verifica que el claim de audiencia coincida con tu indicador de recurso de API
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'https://your-api-resource-indicator' not in audiences:
raise AuthorizationError('Audiencia inválida para recursos de API a nivel de organización')
# Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expected_org_id = 'your-organization-id' # Extraer del contexto de la solicitud
org_id = payload.get('organization_id')
if expected_org_id != org_id:
raise AuthorizationError('ID de organización no coincide')
# Verifica los alcances requeridos para recursos de API a nivel de organización
required_scopes = ['api:read', 'api:write'] # Reemplaza con tus alcances requeridos reales
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Alcances insuficientes para recursos de API a nivel de organización')
Usamos github.com/lestrrat-go/jwx para validar JWTs. Instálalo si aún no lo has hecho:
go mod init your-project
go get github.com/lestrrat-go/jwx/v3
Primero, añade estos componentes compartidos a tu archivo auth_middleware.go
:
import (
"context"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
)
var jwkSet jwk.Set
func init() {
// Inicializar la caché de JWKS
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
jwkSet, err = jwk.Fetch(ctx, JWKS_URI)
if err != nil {
panic("No se pudo obtener JWKS: " + err.Error())
}
}
// validateJWT valida el JWT y devuelve el token analizado
func validateJWT(tokenString string) (jwt.Token, error) {
token, err := jwt.Parse([]byte(tokenString), jwt.WithKeySet(jwkSet))
if err != nil {
return nil, NewAuthorizationError("Token inválido: "+err.Error(), http.StatusUnauthorized)
}
// Verificar emisor
if token.Issuer() != ISSUER {
return nil, NewAuthorizationError("Emisor inválido", http.StatusUnauthorized)
}
if err := verifyPayload(token); err != nil {
return nil, err
}
return token, nil
}
// Funciones auxiliares para extraer datos del token
func getStringClaim(token jwt.Token, key string) string {
if val, ok := token.Get(key); ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
func getScopesFromToken(token jwt.Token) []string {
if val, ok := token.Get("scope"); ok {
if scope, ok := val.(string); ok && scope != "" {
return strings.Split(scope, " ")
}
}
return []string{}
}
func getAudienceFromToken(token jwt.Token) []string {
return token.Audience()
}
Luego, implementa el middleware para verificar el token de acceso (access token):
- Gin
- Fiber
- Echo
- Chi
import "github.com/gin-gonic/gin"
func VerifyAccessToken() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := extractBearerTokenFromHeaders(c.Request)
if err != nil {
authErr := err.(*AuthorizationError)
c.JSON(authErr.Status, gin.H{"error": authErr.Message})
c.Abort()
return
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
c.JSON(authErr.Status, gin.H{"error": authErr.Message})
c.Abort()
return
}
// Almacena el token en el contexto para uso genérico
c.Set("auth", token)
c.Next()
}
}
import (
"net/http"
"github.com/gofiber/fiber/v2"
)
func VerifyAccessToken(c *fiber.Ctx) error {
// Convertir la solicitud de fiber a http.Request para compatibilidad
req := &http.Request{
Header: make(http.Header),
}
req.Header.Set("Authorization", c.Get("Authorization"))
tokenString, err := extractBearerTokenFromHeaders(req)
if err != nil {
authErr := err.(*AuthorizationError)
return c.Status(authErr.Status).JSON(fiber.Map{"error": authErr.Message})
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
return c.Status(authErr.Status).JSON(fiber.Map{"error": authErr.Message})
}
// Almacenar el token en locals para uso genérico
c.Locals("auth", token)
return c.Next()
}
import "github.com/labstack/echo/v4"
func VerifyAccessToken(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
tokenString, err := extractBearerTokenFromHeaders(c.Request())
if err != nil {
authErr := err.(*AuthorizationError)
return c.JSON(authErr.Status, echo.Map{"error": authErr.Message})
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
return c.JSON(authErr.Status, echo.Map{"error": authErr.Message})
}
// Almacenar el token en el contexto para uso genérico
c.Set("auth", token)
return next(c)
}
}
import (
"context"
"encoding/json"
"net/http"
)
type contextKey string
const AuthContextKey contextKey = "auth"
func VerifyAccessToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString, err := extractBearerTokenFromHeaders(r)
if err != nil {
authErr := err.(*AuthorizationError)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
json.NewEncoder(w).Encode(map[string]string{"error": authErr.Message})
return
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
json.NewEncoder(w).Encode(map[string]string{"error": authErr.Message})
return
}
// Almacenar el token en el contexto para uso genérico
ctx := context.WithValue(r.Context(), AuthContextKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Según tu modelo de permisos, puede que necesites adoptar una lógica diferente para verifyPayload
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
func verifyPayload(token jwt.Token) error {
// Verificar que el claim de audiencia coincida con tu indicador de recurso de API
if !hasAudience(token, "https://your-api-resource-indicator") {
return NewAuthorizationError("Audiencia inválida")
}
// Verificar los alcances requeridos para recursos de API globales
requiredScopes := []string{"api:read", "api:write"} // Reemplaza con tus alcances requeridos
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Alcance insuficiente")
}
return nil
}
func verifyPayload(token jwt.Token) error {
// Verificar que el claim de audiencia coincida con el formato de organización
if !hasOrganizationAudience(token) {
return NewAuthorizationError("Audiencia inválida para permisos de organización")
}
// Verificar que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expectedOrgID := "your-organization-id" // Extraer del contexto de la solicitud
if !hasMatchingOrganization(token, expectedOrgID) {
return NewAuthorizationError("ID de organización no coincide")
}
// Verificar los alcances requeridos de la organización
requiredScopes := []string{"invite:users", "manage:settings"} // Reemplaza con tus alcances requeridos
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Alcance de organización insuficiente")
}
return nil
}
func verifyPayload(token jwt.Token) error {
// Verificar que el claim de audiencia coincida con tu indicador de recurso de API
if !hasAudience(token, "https://your-api-resource-indicator") {
return NewAuthorizationError("Audiencia inválida para recursos de API a nivel de organización")
}
// Verificar que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expectedOrgID := "your-organization-id" // Extraer del contexto de la solicitud
if !hasMatchingOrganizationID(token, expectedOrgID) {
return NewAuthorizationError("ID de organización no coincide")
}
// Verificar los alcances requeridos para recursos de API a nivel de organización
requiredScopes := []string{"api:read", "api:write"} // Reemplaza con tus alcances requeridos
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Alcances insuficientes para recursos de API a nivel de organización")
}
return nil
}
Agrega estas funciones auxiliares para la verificación del payload:
// hasAudience verifica si el token tiene la audiencia especificada
func hasAudience(token jwt.Token, expectedAud string) bool {
audiences := token.Audience()
for _, aud := range audiences {
if aud == expectedAud {
return true
}
}
return false
}
// hasOrganizationAudience verifica si el token tiene formato de audiencia de organización
func hasOrganizationAudience(token jwt.Token) bool {
audiences := token.Audience()
for _, aud := range audiences {
if strings.HasPrefix(aud, "urn:logto:organization:") {
return true
}
}
return false
}
// hasRequiredScopes verifica si el token tiene todos los alcances requeridos
func hasRequiredScopes(token jwt.Token, requiredScopes []string) bool {
scopes := getScopesFromToken(token)
for _, required := range requiredScopes {
found := false
for _, scope := range scopes {
if scope == required {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// hasMatchingOrganization verifica si la audiencia del token coincide con la organización esperada
func hasMatchingOrganization(token jwt.Token, expectedOrgID string) bool {
expectedAud := fmt.Sprintf("urn:logto:organization:%s", expectedOrgID)
return hasAudience(token, expectedAud)
}
// hasMatchingOrganizationID verifica si el organization_id del token coincide con el esperado
func hasMatchingOrganizationID(token jwt.Token, expectedOrgID string) bool {
orgID := getStringClaim(token, "organization_id")
return orgID == expectedOrgID
}
Usamos diferentes bibliotecas JWT dependiendo del framework. Instala las dependencias requeridas:
- Spring Boot
- Quarkus
- Micronaut
- Vert.x Web
Agrega a tu pom.xml
:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/protected/**").authenticated()
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
// Recuerda establecer estas variables de entorno en tu despliegue
String jwksUri = System.getenv("JWKS_URI");
String issuer = System.getenv("JWT_ISSUER");
return NimbusJwtDecoder.withJwkSetUri(jwksUri)
.issuer(issuer)
.build();
}
}
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class JwtValidator {
public void verifyPayload(Jwt jwt) {
// La validación del emisor (Issuer) es manejada automáticamente por el decodificador JWT de Spring Security
// Implementa aquí tu lógica de verificación adicional basada en el modelo de permisos
// Usa los métodos auxiliares de abajo para la extracción de reclamos (Claims)
// Ejemplo: throw new AuthorizationException("Permisos insuficientes");
// El código de estado será manejado por el manejo de excepciones de Spring Security
}
// Métodos auxiliares para JWT en Spring Boot
private List<String> extractAudiences(Jwt jwt) {
return jwt.getAudience();
}
private String extractScopes(Jwt jwt) {
return jwt.getClaimAsString("scope");
}
private String extractOrganizationId(Jwt jwt) {
return jwt.getClaimAsString("organization_id");
}
}
Agrega a tu pom.xml
:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
# Configuración de JWT
mp.jwt.verify.publickey.location=${JWKS_URI:https://your-tenant.logto.app/oidc/jwks}
mp.jwt.verify.issuer=${JWT_ISSUER:https://your-tenant.logto.app/oidc}
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import java.util.ArrayList;
import java.util.List;
@Provider
@ApplicationScoped
public class JwtVerificationFilter implements ContainerRequestFilter {
@Inject
JsonWebToken jwt;
@Override
public void filter(ContainerRequestContext requestContext) {
if (requestContext.getUriInfo().getPath().startsWith("/api/protected")) {
try {
verifyPayload(jwt);
requestContext.setProperty("auth", jwt);
} catch (AuthorizationException e) {
requestContext.abortWith(
Response.status(e.getStatusCode())
.entity("{\"error\": \"" + e.getMessage() + "\"}")
.build()
);
} catch (Exception e) {
requestContext.abortWith(
Response.status(401)
.entity("{\"error\": \"Token inválido\"}")
.build()
);
}
}
}
private void verifyPayload(JsonWebToken jwt) {
// La validación del emisor (Issuer) se maneja automáticamente por la extensión JWT de Quarkus
// Implementa aquí tu lógica de verificación adicional basada en el modelo de permisos
// Utiliza los métodos auxiliares de abajo para la extracción de reclamos (claims)
}
// Métodos auxiliares para Quarkus JWT
private List<String> extractAudiences(JsonWebToken jwt) {
return new ArrayList<>(jwt.getAudience());
}
private String extractScopes(JsonWebToken jwt) {
return jwt.getClaim("scope");
}
private String extractOrganizationId(JsonWebToken jwt) {
return jwt.getClaim("organization_id");
}
}
Agrega a tu pom.xml
:
<dependency>
<groupId>io.micronaut.security</groupId>
<artifactId>micronaut-security-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-server-netty</artifactId>
</dependency>
micronaut:
security:
authentication: bearer
token:
jwt:
signatures:
jwks:
logto:
url: ${JWKS_URI:https://your-tenant.logto.app/oidc/jwks}
claims-validators:
issuer: ${JWT_ISSUER:https://your-tenant.logto.app/oidc}
import io.micronaut.security.token.Claims;
import io.micronaut.security.token.validator.TokenValidator;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
@Singleton
public class JwtClaimsValidator implements TokenValidator {
@Override
public Publisher<Boolean> validateToken(String token, Claims claims) {
try {
verifyPayload(claims);
return Mono.just(true);
} catch (AuthorizationException e) {
// Micronaut manejará el código de estado apropiadamente
return Mono.just(false);
}
}
private void verifyPayload(Claims claims) {
// La validación del emisor (Issuer) se maneja automáticamente por la configuración JWT de Micronaut
// Implementa aquí tu lógica de verificación adicional basada en el modelo de permisos
// Utiliza los métodos auxiliares de abajo para la extracción de reclamos (claims)
// Ejemplo: throw new AuthorizationException("Permisos insuficientes");
}
// Métodos auxiliares para JWT en Micronaut
@SuppressWarnings("unchecked")
private List<String> extractAudiences(Claims claims) {
Object aud = claims.get("aud");
if (aud instanceof List) {
return (List<String>) aud;
} else if (aud instanceof String) {
return Arrays.asList((String) aud);
}
return List.of();
}
private String extractScopes(Claims claims) {
return (String) claims.get("scope");
}
private String extractOrganizationId(Claims claims) {
return (String) claims.get("organization_id");
}
}
Agrega a tu pom.xml
:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
</dependency>
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClient;
import java.util.List;
import java.util.ArrayList;
public class JwtAuthHandler implements Handler<RoutingContext> {
private final JWTAuth jwtAuth;
private final WebClient webClient;
private final String expectedIssuer;
private final String jwksUri;
public JwtAuthHandler(Vertx vertx) {
this.webClient = WebClient.create(vertx);
this.jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions());
// Recuerda establecer estas variables de entorno en tu despliegue
this.expectedIssuer = System.getenv("JWT_ISSUER");
this.jwksUri = System.getenv("JWKS_URI");
// Obtener JWKS y configurar la autenticación JWT
fetchJWKS().onSuccess(jwks -> {
// Configurar JWKS (simplificado - puede que necesites un parser JWKS adecuado)
});
}
@Override
public void handle(RoutingContext context) {
String authHeader = context.request().getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Authorization header missing or invalid\"}");
return;
}
String token = authHeader.substring(7);
jwtAuth.authenticate(new JsonObject().put("jwt", token))
.onSuccess(user -> {
try {
JsonObject principal = user.principal();
verifyPayload(principal);
context.put("auth", principal);
context.next();
} catch (AuthorizationException e) {
context.response()
.setStatusCode(e.getStatusCode()) // Usa el código de estado de la excepción
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"" + e.getMessage() + "\"}");
} catch (Exception e) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token\"}");
}
})
.onFailure(err -> {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token: " + err.getMessage() + "\"}");
});
}
private Future<JsonObject> fetchJWKS() {
return webClient.getAbs(this.jwksUri)
.send()
.map(response -> response.bodyAsJsonObject());
}
private void verifyPayload(JsonObject principal) {
// Verifica el emisor manualmente para Vert.x
String issuer = principal.getString("iss");
if (issuer == null || !expectedIssuer.equals(issuer)) {
throw new AuthorizationException("Invalid issuer: " + issuer);
}
// Implementa aquí tu lógica de verificación adicional basada en el modelo de permisos
// Usa los métodos auxiliares de abajo para la extracción de reclamos
}
// Métodos auxiliares para JWT en Vert.x
private List<String> extractAudiences(JsonObject principal) {
JsonArray audiences = principal.getJsonArray("aud");
if (audiences != null) {
List<String> result = new ArrayList<>();
for (Object aud : audiences) {
result.add(aud.toString());
}
return result;
}
return List.of();
}
private String extractScopes(JsonObject principal) {
return principal.getString("scope");
}
private String extractOrganizationId(JsonObject principal) {
return principal.getString("organization_id");
}
}
De acuerdo con tu modelo de permisos, implementa la lógica de verificación adecuada:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
List<String> audiences = extractAudiences(token); // Extracción específica del framework
if (!audiences.contains("https://your-api-resource-indicator")) {
throw new AuthorizationException("Audiencia no válida");
}
// Verifica los alcances requeridos para recursos de API globales
List<String> requiredScopes = Arrays.asList("api:read", "api:write"); // Reemplaza con tus alcances requeridos reales
String scopes = extractScopes(token); // Extracción específica del framework
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Alcance insuficiente");
}
// Verifica que el reclamo de audiencia coincida con el formato de organización
List<String> audiences = extractAudiences(token); // Extracción específica del framework
boolean hasOrgAudience = audiences.stream()
.anyMatch(aud -> aud.startsWith("urn:logto:organization:"));
if (!hasOrgAudience) {
throw new AuthorizationException("Audiencia no válida para permisos de organización");
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
String expectedOrgId = "your-organization-id"; // Extrae del contexto de la solicitud
String expectedAud = "urn:logto:organization:" + expectedOrgId;
if (!audiences.contains(expectedAud)) {
throw new AuthorizationException("ID de organización no coincide");
}
// Verifica los alcances requeridos de la organización
List<String> requiredScopes = Arrays.asList("invite:users", "manage:settings"); // Reemplaza con tus alcances requeridos reales
String scopes = extractScopes(token); // Extracción específica del framework
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Alcance de organización insuficiente");
}
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
List<String> audiences = extractAudiences(token); // Extracción específica del framework
if (!audiences.contains("https://your-api-resource-indicator")) {
throw new AuthorizationException("Audiencia no válida para recursos de API a nivel de organización");
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
String expectedOrgId = "your-organization-id"; // Extrae del contexto de la solicitud
String orgId = extractOrganizationId(token); // Extracción específica del framework
if (!expectedOrgId.equals(orgId)) {
throw new AuthorizationException("ID de organización no coincide");
}
// Verifica los alcances requeridos para recursos de API a nivel de organización
List<String> requiredScopes = Arrays.asList("api:read", "api:write"); // Reemplaza con tus alcances requeridos reales
String scopes = extractScopes(token); // Extracción específica del framework
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Alcances de API a nivel de organización insuficientes");
}
Los métodos auxiliares para extraer reclamos son específicos del framework. Consulta los detalles de implementación en los archivos de validación específicos del framework mencionados arriba.
Agrega el paquete NuGet requerido para la autenticación JWT:
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
Crea un servicio de validación para manejar la validación de tokens:
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using YourApiNamespace.Exceptions;
namespace YourApiNamespace.Services
{
public interface IJwtValidationService
{
Task ValidateTokenAsync(TokenValidatedContext context);
}
public class JwtValidationService : IJwtValidationService
{
public async Task ValidateTokenAsync(TokenValidatedContext context)
{
var principal = context.Principal!;
try
{
// Agrega aquí tu lógica de validación basada en el modelo de permisos
ValidatePayload(principal);
}
catch (AuthorizationException)
{
throw; // Re-lanzar excepciones de autorización
}
catch (Exception ex)
{
throw new AuthorizationException($"La validación del token falló: {ex.Message}", 401);
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Implementa aquí tu lógica de verificación basada en el modelo de permisos
// Esto se mostrará en la sección de modelos de permisos más abajo
}
}
}
Configura la autenticación JWT en tu Program.cs
:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using YourApiNamespace.Services;
using YourApiNamespace.Exceptions;
var builder = WebApplication.CreateBuilder(args);
// Agrega servicios al contenedor
builder.Services.AddControllers();
builder.Services.AddScoped<IJwtValidationService, JwtValidationService>();
// Configura la autenticación JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = AuthConstants.Issuer;
options.MetadataAddress = $"{AuthConstants.Issuer}/.well-known/openid_configuration";
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = AuthConstants.Issuer,
ValidateAudience = false, // Validaremos la audiencia manualmente según el modelo de permisos
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var validationService = context.HttpContext.RequestServices
.GetRequiredService<IJwtValidationService>();
await validationService.ValidateTokenAsync(context);
},
OnAuthenticationFailed = context =>
{
// Manejar errores de la librería JWT como 401
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
context.Response.WriteAsync($"{{\"error\": \"Token inválido\"}}");
context.HandleResponse();
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Manejo global de errores para fallos de autenticación / autorización
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (AuthorizationException ex)
{
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($"{{\"error\": \"{ex.Message}\"}}");
}
});
// Configura el pipeline de solicitudes HTTP
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
De acuerdo con tu modelo de permisos, implementa la lógica de validación apropiada en JwtValidationService
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
private void ValidatePayload(ClaimsPrincipal principal)
{
// Verifica que el claim de audiencia coincida con tu indicador de recurso de API
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
if (!audiences.Contains("https://your-api-resource-indicator"))
{
throw new AuthorizationException("Audiencia inválida");
}
// Verifica los alcances requeridos para recursos de API globales
var requiredScopes = new[] { "api:read", "api:write" }; // Reemplaza con tus alcances requeridos
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Alcance insuficiente");
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Verifica que el claim de audiencia coincida con el formato de organización
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
var hasOrgAudience = audiences.Any(aud => aud.StartsWith("urn:logto:organization:"));
if (!hasOrgAudience)
{
throw new AuthorizationException("Audiencia inválida para permisos de organización");
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
var expectedOrgId = "your-organization-id"; // Extrae del contexto de la solicitud
var expectedAud = $"urn:logto:organization:{expectedOrgId}";
if (!audiences.Contains(expectedAud))
{
throw new AuthorizationException("ID de organización no coincide");
}
// Verifica los alcances requeridos de la organización
var requiredScopes = new[] { "invite:users", "manage:settings" }; // Reemplaza con tus alcances requeridos
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Alcance de organización insuficiente");
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Verifica que el claim de audiencia coincida con tu indicador de recurso de API
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
if (!audiences.Contains("https://your-api-resource-indicator"))
{
throw new AuthorizationException("Audiencia inválida para recursos de API a nivel de organización");
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
var expectedOrgId = "your-organization-id"; // Extrae del contexto de la solicitud
var orgId = principal.FindFirst("organization_id")?.Value;
if (!expectedOrgId.Equals(orgId))
{
throw new AuthorizationException("ID de organización no coincide");
}
// Verifica los alcances requeridos para recursos de API a nivel de organización
var requiredScopes = new[] { "api:read", "api:write" }; // Reemplaza con tus alcances requeridos
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Alcances insuficientes para recursos de API a nivel de organización");
}
}
Usamos firebase/php-jwt para validar JWTs. Instálalo usando Composer:
composer require firebase/php-jwt
Primero, añade estas utilidades compartidas para manejar la validación de JWT:
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
class JwtValidator
{
use AuthHelpers;
private static ?array $jwks = null;
public static function fetchJwks(): array
{
if (self::$jwks === null) {
$jwksData = file_get_contents(AuthConstants::JWKS_URI);
if ($jwksData === false) {
throw new AuthorizationException('Failed to fetch JWKS', 401);
}
self::$jwks = json_decode($jwksData, true);
}
return self::$jwks;
}
public static function validateJwt(string $token): array
{
try {
$jwks = self::fetchJwks();
$keys = JWK::parseKeySet($jwks);
$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;
// Verificar emisor
if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
throw new AuthorizationException('Invalid issuer', 401);
}
self::verifyPayload($payload);
return $payload;
} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
}
}
public static function createAuthInfo(array $payload): AuthInfo
{
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
$audience = $payload['aud'] ?? [];
if (is_string($audience)) {
$audience = [$audience];
}
return new AuthInfo(
sub: $payload['sub'],
clientId: $payload['client_id'] ?? null,
organizationId: $payload['organization_id'] ?? null,
scopes: $scopes,
audience: $audience
);
}
private static function verifyPayload(array $payload): void
{
// Implementa aquí tu lógica de verificación basada en el modelo de permisos
// Esto se mostrará en la sección de modelos de permisos a continuación
}
}
Luego, implementa el middleware para verificar el token de acceso:
- Laravel
- Symfony
- Slim
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class VerifyAccessToken
{
use AuthHelpers;
public function handle(Request $request, Closure $next): Response
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
// Almacenar la información de autenticación en los atributos de la solicitud para uso genérico
$request->attributes->set('auth', JwtValidator::createAuthInfo($payload));
return $next($request);
} catch (AuthorizationException $e) {
return response()->json(['error' => $e->getMessage()], $e->statusCode);
}
}
}
Registra el middleware en app/Http/Kernel.php
:
protected $middlewareAliases = [
// ... otro middleware
'auth.token' => \App\Http\Middleware\VerifyAccessToken::class,
];
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class JwtAuthenticator extends AbstractAuthenticator
{
use AuthHelpers;
public function supports(Request $request): ?bool
{
return $request->headers->has('authorization');
}
public function authenticate(Request $request): Passport
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
$authInfo = JwtValidator::createAuthInfo($payload);
// Almacenar la información de autenticación en los atributos de la solicitud para uso genérico
$request->attributes->set('auth', $authInfo);
return new SelfValidatingPassport(new UserBadge($payload['sub']));
} catch (AuthorizationException $e) {
throw new AuthenticationException($e->getMessage());
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null; // Continuar al controlador
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
}
}
Configura la seguridad en config/packages/security.yaml
:
security:
firewalls:
api:
pattern: ^/api/protected
stateless: true
custom_authenticators:
- App\Security\JwtAuthenticator
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class JwtMiddleware implements MiddlewareInterface
{
use AuthHelpers;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$headers = $request->getHeaders();
$token = $this->extractBearerToken($headers);
$payload = JwtValidator::validateJwt($token);
// Almacenar la información de autenticación en los atributos de la solicitud para uso genérico
$request = $request->withAttribute('auth', JwtValidator::createAuthInfo($payload));
return $handler->handle($request);
} catch (AuthorizationException $e) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($e->statusCode);
}
}
}
De acuerdo con tu modelo de permisos, implementa la lógica de verificación apropiada en JwtValidator
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
private static function verifyPayload(array $payload): void
{
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience');
}
// Verifica los alcances requeridos para recursos de API globales
$requiredScopes = ['api:read', 'api:write']; // Reemplaza con tus alcances requeridos
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// Verifica que el reclamo de audiencia coincida con el formato de organización
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
$hasOrgAudience = false;
foreach ($audiences as $aud) {
if (str_starts_with($aud, 'urn:logto:organization:')) {
$hasOrgAudience = true;
break;
}
}
if (!$hasOrgAudience) {
throw new AuthorizationException('Invalid audience for organization permissions');
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
$expectedOrgId = 'your-organization-id'; // Extrae del contexto de la solicitud
$expectedAud = "urn:logto:organization:{$expectedOrgId}";
if (!in_array($expectedAud, $audiences)) {
throw new AuthorizationException('Organization ID mismatch');
}
// Verifica los alcances requeridos de la organización
$requiredScopes = ['invite:users', 'manage:settings']; // Reemplaza con tus alcances requeridos
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience for organization-level API resources');
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
$expectedOrgId = 'your-organization-id'; // Extrae del contexto de la solicitud
$orgId = $payload['organization_id'] ?? null;
if ($expectedOrgId !== $orgId) {
throw new AuthorizationException('Organization ID mismatch');
}
// Verifica los alcances requeridos para recursos de API a nivel de organización
$requiredScopes = ['api:read', 'api:write']; // Reemplaza con tus alcances requeridos
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization-level API scopes');
}
}
}
Usamos la gema jwt para validar JWTs. Agrégala a tu Gemfile:
gem 'jwt'
# net-http es parte de la biblioteca estándar de Ruby desde Ruby 2.7, no es necesario agregarla explícitamente
Luego ejecuta:
bundle install
Primero, añade estas utilidades compartidas para manejar JWKS y la validación de tokens:
require 'jwt'
require 'net/http'
require 'json'
class JwtValidator
include AuthHelpers
def self.fetch_jwks
@jwks ||= begin
uri = URI(AuthConstants::JWKS_URI)
response = Net::HTTP.get_response(uri)
raise AuthorizationError.new('Failed to fetch JWKS', 401) unless response.is_a?(Net::HTTPSuccess)
jwks_data = JSON.parse(response.body)
JWT::JWK::Set.new(jwks_data)
end
end
def self.validate_jwt(token)
jwks = fetch_jwks
# Deja que la biblioteca JWT maneje la detección del algoritmo desde JWKS
decoded_token = JWT.decode(token, nil, true, {
iss: AuthConstants::ISSUER,
verify_iss: true,
verify_aud: false, # Verificaremos la audiencia manualmente según el modelo de permisos
jwks: jwks
})[0]
verify_payload(decoded_token)
decoded_token
end
def self.create_auth_info(payload)
scopes = payload['scope']&.split(' ') || []
audience = payload['aud'] || []
AuthInfo.new(
payload['sub'],
payload['client_id'],
payload['organization_id'],
scopes,
audience
)
end
def self.verify_payload(payload)
# Implementa aquí tu lógica de verificación según el modelo de permisos
# Esto se mostrará en la sección de modelos de permisos a continuación
end
end
Luego, implementa el middleware para verificar el token de acceso:
- Ruby on Rails
- Sinatra
- Grape
module JwtAuthentication
extend ActiveSupport::Concern
include AuthHelpers
included do
before_action :verify_access_token, only: [:protected_action] # Añade acciones específicas
end
private
def verify_access_token
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Almacena la información de autenticación para uso genérico
@auth = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
render json: { error: e.message }, status: e.status
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
render json: { error: 'Token inválido' }, status: 401
end
end
end
class AuthMiddleware
include AuthHelpers
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
# Solo proteger rutas específicas
if request.path.start_with?('/api/protected')
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Almacenar la información de autenticación en env para uso genérico
env['auth'] = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
return [e.status, { 'Content-Type' => 'application/json' }, [{ error: e.message }.to_json]]
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
return [401, { 'Content-Type' => 'application/json' }, [{ error: 'Token inválido' }.to_json]]
end
end
@app.call(env)
end
end
module GrapeAuthHelpers
include AuthHelpers
def authenticate_user!
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Almacenar la información de autenticación para uso genérico
@auth = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
error!({ error: e.message }, e.status)
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
error!({ error: 'Token inválido' }, 401)
end
end
def auth
@auth
end
end
Según tu modelo de permisos, implementa la lógica de verificación adecuada en JwtValidator
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
def self.verify_payload(payload)
# Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
audiences = payload['aud'] || []
unless audiences.include?('https://your-api-resource-indicator')
raise AuthorizationError.new('Invalid audience')
end
# Verifica los alcances requeridos para recursos de API globales
required_scopes = ['api:read', 'api:write'] # Reemplaza con tus alcances requeridos
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient scope')
end
end
def self.verify_payload(payload)
# Verifica que el reclamo de audiencia coincida con el formato de organización
audiences = payload['aud'] || []
has_org_audience = audiences.any? { |aud| aud.start_with?('urn:logto:organization:') }
unless has_org_audience
raise AuthorizationError.new('Invalid audience for organization permissions')
end
# Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expected_org_id = 'your-organization-id' # Extrae del contexto de la solicitud
expected_aud = "urn:logto:organization:#{expected_org_id}"
unless audiences.include?(expected_aud)
raise AuthorizationError.new('Organization ID mismatch')
end
# Verifica los alcances requeridos de la organización
required_scopes = ['invite:users', 'manage:settings'] # Reemplaza con tus alcances requeridos
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient organization scope')
end
end
def self.verify_payload(payload)
# Verifica que el reclamo de audiencia coincida con tu indicador de recurso de API
audiences = payload['aud'] || []
unless audiences.include?('https://your-api-resource-indicator')
raise AuthorizationError.new('Invalid audience for organization-level API resources')
end
# Verifica que el ID de la organización coincida con el contexto (puede que necesites extraerlo del contexto de la solicitud)
expected_org_id = 'your-organization-id' # Extrae del contexto de la solicitud
org_id = payload['organization_id']
unless expected_org_id == org_id
raise AuthorizationError.new('Organization ID mismatch')
end
# Verifica los alcances requeridos para recursos de API a nivel de organización
required_scopes = ['api:read', 'api:write'] # Reemplaza con tus alcances requeridos
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient organization-level API scopes')
end
end
Usamos jsonwebtoken para validar JWTs. Añade las dependencias necesarias a tu Cargo.toml
:
[dependencies]
jsonwebtoken = "9.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
Primero, añade estas utilidades compartidas para manejar la validación de JWT:
use crate::{AuthInfo, AuthorizationError, ISSUER, JWKS_URI};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde_json::Value;
use std::collections::HashMap;
pub struct JwtValidator {
jwks: HashMap<String, DecodingKey>,
}
impl JwtValidator {
pub async fn new() -> Result<Self, AuthorizationError> {
let jwks = Self::fetch_jwks().await?;
Ok(Self { jwks })
}
async fn fetch_jwks() -> Result<HashMap<String, DecodingKey>, AuthorizationError> {
let response = reqwest::get(JWKS_URI).await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to fetch JWKS: {}", e), 401)
})?;
let jwks: Value = response.json().await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to parse JWKS: {}", e), 401)
})?;
let mut keys = HashMap::new();
if let Some(keys_array) = jwks["keys"].as_array() {
for key in keys_array {
if let (Some(kid), Some(kty), Some(n), Some(e)) = (
key["kid"].as_str(),
key["kty"].as_str(),
key["n"].as_str(),
key["e"].as_str(),
) {
if kty == "RSA" {
if let Ok(decoding_key) = DecodingKey::from_rsa_components(n, e) {
keys.insert(kid.to_string(), decoding_key);
}
}
}
}
}
if keys.is_empty() {
return Err(AuthorizationError::with_status("No valid keys found in JWKS", 401));
}
Ok(keys)
}
pub fn validate_jwt(&self, token: &str) -> Result<AuthInfo, AuthorizationError> {
let header = decode_header(token).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token header: {}", e), 401)
})?;
let kid = header.kid.ok_or_else(|| {
AuthorizationError::with_status("Token missing kid claim", 401)
})?;
let key = self.jwks.get(&kid).ok_or_else(|| {
AuthorizationError::with_status("Unknown key ID", 401)
})?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[ISSUER]);
validation.validate_aud = false; // Verificaremos la audiencia manualmente
let token_data = decode::<Value>(token, key, &validation).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token: {}", e), 401)
})?;
let claims = token_data.claims;
self.verify_payload(&claims)?;
Ok(self.create_auth_info(claims))
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Implementa aquí tu lógica de verificación basada en el modelo de permisos
// Esto se mostrará en la sección de modelos de permisos más abajo
Ok(())
}
fn create_auth_info(&self, claims: Value) -> AuthInfo {
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
.unwrap_or_default();
let audience = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
Value::String(s) => vec![s.clone()],
_ => vec![],
};
AuthInfo::new(
claims["sub"].as_str().unwrap_or_default().to_string(),
claims["client_id"].as_str().map(|s| s.to_string()),
claims["organization_id"].as_str().map(|s| s.to_string()),
scopes,
audience,
)
}
}
Luego, implementa el middleware para verificar el token de acceso:
- Axum
- Actix Web
- Rocket
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Extension, Json,
};
use serde_json::json;
use std::sync::Arc;
pub async fn jwt_middleware(
Extension(validator): Extension<Arc<JwtValidator>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthorizationError> {
let authorization = headers
.get("authorization")
.and_then(|h| h.to_str().ok());
let token = extract_bearer_token(authorization)?;
let auth_info = validator.validate_jwt(token)?;
// Almacena la información de autenticación en las extensiones de la solicitud para uso genérico
request.extensions_mut().insert(auth_info);
Ok(next.run(request).await)
}
impl IntoResponse for AuthorizationError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::FORBIDDEN);
(status, Json(json!({ "error": self.message }))).into_response()
}
}
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
web, Error, HttpMessage, HttpResponse,
};
use futures::future::{ok, Ready};
use std::sync::Arc;
pub struct JwtMiddleware {
validator: Arc<JwtValidator>,
}
impl JwtMiddleware {
pub fn new(validator: Arc<JwtValidator>) -> Self {
Self { validator }
}
}
impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = JwtMiddlewareService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(JwtMiddlewareService {
service,
validator: self.validator.clone(),
})
}
}
pub struct JwtMiddlewareService<S> {
service: S,
validator: Arc<JwtValidator>,
}
impl<S, B> Service<ServiceRequest> for JwtMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = futures::future::LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let validator = self.validator.clone();
Box::pin(async move {
let authorization = req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok());
match extract_bearer_token(authorization)
.and_then(|token| validator.validate_jwt(token))
{
Ok(auth_info) => {
// Almacena la información de autenticación en las extensiones de la solicitud para uso genérico
req.extensions_mut().insert(auth_info);
let fut = self.service.call(req);
fut.await
}
Err(e) => {
let response = HttpResponse::build(
actix_web::http::StatusCode::from_u16(e.status_code)
.unwrap_or(actix_web::http::StatusCode::FORBIDDEN),
)
.json(serde_json::json!({ "error": e.message }));
Ok(req.into_response(response))
}
}
})
}
}
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use rocket::{
http::Status,
outcome::Outcome,
request::{self, FromRequest, Request},
State,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AuthInfo {
type Error = AuthorizationError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let validator = match req.guard::<&State<JwtValidator>>().await {
Outcome::Success(validator) => validator,
Outcome::Failure((status, _)) => {
return Outcome::Failure((
status,
AuthorizationError::with_status("JWT validator not found", 500),
))
}
Outcome::Forward(()) => {
return Outcome::Forward(())
}
};
let authorization = req.headers().get_one("authorization");
match extract_bearer_token(authorization)
.and_then(|token| validator.validate_jwt(token))
{
Ok(auth_info) => Outcome::Success(auth_info),
Err(e) => {
let status = Status::from_code(e.status_code).unwrap_or(Status::Forbidden);
Outcome::Failure((status, e))
}
}
}
}
De acuerdo con tu modelo de permisos, implementa la lógica de verificación apropiada en JwtValidator
:
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Verifica que el claim de audiencia coincida con tu indicador de recurso de API
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience"));
}
// Verifica los alcances requeridos para recursos de API globales
let required_scopes = vec!["api:read", "api:write"]; // Sustituye por tus alcances requeridos reales
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient scope"));
}
}
Ok(())
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Verifica que el claim de audiencia coincida con el formato de organización
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
let has_org_audience = audiences.iter().any(|aud| aud.starts_with("urn:logto:organization:"));
if !has_org_audience {
return Err(AuthorizationError::new("Invalid audience for organization permissions"));
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraer esto del contexto de la solicitud)
let expected_org_id = "your-organization-id"; // Extrae del contexto de la solicitud
let expected_aud = format!("urn:logto:organization:{}", expected_org_id);
if !audiences.contains(&expected_aud.as_str()) {
return Err(AuthorizationError::new("Organization ID mismatch"));
}
// Verifica los alcances requeridos de la organización
let required_scopes = vec!["invite:users", "manage:settings"]; // Sustituye por tus alcances requeridos reales
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient organization scope"));
}
}
Ok(())
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Verifica que el claim de audiencia coincida con tu indicador de recurso de API
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience for organization-level API resources"));
}
// Verifica que el ID de la organización coincida con el contexto (puede que necesites extraer esto del contexto de la solicitud)
let expected_org_id = "your-organization-id"; // Extrae del contexto de la solicitud
let org_id = claims["organization_id"].as_str().unwrap_or_default();
if expected_org_id != org_id {
return Err(AuthorizationError::new("Organization ID mismatch"));
}
// Verifica los alcances requeridos para recursos de API a nivel de organización
let required_scopes = vec!["api:read", "api:write"]; // Sustituye por tus alcances requeridos reales
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient organization-level API scopes"));
}
}
Ok(())
}
Paso 4: Aplica el middleware a tu API
Aplica el middleware a tus rutas de API protegidas.
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
- Express.js
- Koa.js
- Fastify
- Hapi.js
- NestJS
import express from 'express';
import { verifyAccessToken } from './auth-middleware.js';
const app = express();
app.get('/api/protected', verifyAccessToken, (req, res) => {
// Accede a la información de autenticación directamente desde req.auth
res.json({ auth: req.auth });
});
app.get('/api/protected/detailed', verifyAccessToken, (req, res) => {
// Tu lógica para el endpoint protegido
res.json({
auth: req.auth,
message: 'Datos protegidos accedidos correctamente',
});
});
app.listen(3000);
import Koa from 'koa';
import Router from '@koa/router';
import { koaVerifyAccessToken } from './auth-middleware.js';
const app = new Koa();
const router = new Router();
router.get('/api/protected', koaVerifyAccessToken, (ctx) => {
// Accede a la información de autenticación directamente desde ctx.state.auth
ctx.body = { auth: ctx.state.auth };
});
router.get('/api/protected/detailed', koaVerifyAccessToken, (ctx) => {
// Tu lógica para el endpoint protegido
ctx.body = {
auth: ctx.state.auth,
message: 'Datos protegidos accedidos correctamente',
};
});
app.use(router.routes());
app.listen(3000);
import Fastify from 'fastify';
import { fastifyVerifyAccessToken } from './auth-middleware.js';
const fastify = Fastify();
server.get('/api/protected', { preHandler: fastifyVerifyAccessToken }, (request, reply) => {
// Accede a la información de autenticación directamente desde request.auth
reply.send({ auth: request.auth });
});
server.get(
'/api/protected/detailed',
{ preHandler: fastifyVerifyAccessToken },
(request, reply) => {
// Tu lógica para el endpoint protegido
reply.send({
auth: request.auth,
message: 'Datos protegidos accedidos correctamente',
});
}
);
fastify.listen({ port: 3000 });
import Hapi from '@hapi/hapi';
import { hapiVerifyAccessToken } from './auth-middleware.js';
const server = Hapi.server({ port: 3000 });
server.route({
method: 'GET',
path: '/api/protected',
options: {
pre: [{ method: hapiVerifyAccessToken }],
handler: (request, h) => {
// Accede a la información de autenticación desde request.app.auth
return { auth: request.app.auth };
},
},
});
server.route({
method: 'GET',
path: '/api/protected/detailed',
options: {
pre: [{ method: hapiVerifyAccessToken }],
handler: (request, h) => {
// Tu lógica para el endpoint protegido
return {
auth: request.app.auth,
message: 'Datos protegidos accedidos correctamente',
};
},
},
});
await server.start();
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AccessTokenGuard } from './access-token.guard.js';
@Controller('api')
export class ProtectedController {
@Get('protected')
@UseGuards(AccessTokenGuard)
getProtected(@Req() req: any) {
// Accede a la información de autenticación desde req.auth
return { auth: req.auth };
}
@Get('protected/detailed')
@UseGuards(AccessTokenGuard)
getDetailedProtected(@Req() req: any) {
// Tu lógica para el endpoint protegido
return {
auth: req.auth,
message: 'Datos protegidos accedidos correctamente',
};
}
}
- FastAPI
- Flask
- Django
- Django REST Framework
from fastapi import FastAPI, Depends
from auth_middleware import verify_access_token, AuthInfo
app = FastAPI()
@app.get("/api/protegido")
async def protected_endpoint(auth: AuthInfo = Depends(verify_access_token)):
# Accede a la información de autenticación directamente desde el parámetro auth
return {"auth": auth.to_dict()}
from flask import Flask, g, jsonify
from auth_middleware import verify_access_token
app = Flask(__name__)
@app.route('/api/protected', methods=['GET'])
@verify_access_token
def protected_endpoint():
# Accede a la información de autenticación desde g.auth
return jsonify({"auth": g.auth.to_dict()})
from django.http import JsonResponse
from auth_middleware import require_access_token
@require_access_token
def protected_view(request):
# Accede a la información de autenticación desde request.auth
return JsonResponse({"auth": request.auth.to_dict()})
from django.urls import path
from . import views
urlpatterns = [
path('api/protected/', views.protected_view, name='protected'),
]
from rest_framework.decorators import api_view, authentication_classes
from rest_framework.response import Response
from auth_middleware import AccessTokenAuthentication
@api_view(['GET'])
@authentication_classes([AccessTokenAuthentication])
def protected_view(request):
# Accede a la información de autenticación desde request.user.auth
return Response({"auth": request.user.auth.to_dict()})
O usando vistas basadas en clases:
from rest_framework.views import APIView
from rest_framework.response import Response
from auth_middleware import AccessTokenAuthentication
class ProtectedView(APIView):
authentication_classes = [AccessTokenAuthentication]
def get(self, request):
# Accede a la información de autenticación desde request.user.auth
return Response({"auth": request.user.auth.to_dict()})
from django.urls import path
from . import views
urlpatterns = [
path('api/protected/', views.protected_view, name='protected'),
# O para vistas basadas en clases:
# path('api/protected/', views.ProtectedView.as_view(), name='protected'),
]
- Gin
- Echo
- Fiber
- Chi
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := gin.Default()
// Aplica el middleware a las rutas protegidas
r.GET("/api/protected", VerifyAccessToken(), func(c *gin.Context) {
// Información del token de acceso directamente desde el contexto
tokenInterface, exists := c.Get("auth")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token no encontrado"})
return
}
token := tokenInterface.(jwt.Token)
c.JSON(http.StatusOK, gin.H{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
r.Run(":8080")
}
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
e := echo.New()
// Aplica el middleware a las rutas protegidas
e.GET("/api/protected", func(c echo.Context) error {
// Información del token de acceso directamente desde el contexto
tokenInterface := c.Get("auth")
if tokenInterface == nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "Token no encontrado"})
}
token := tokenInterface.(jwt.Token)
return c.JSON(http.StatusOK, echo.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
}, VerifyAccessToken)
e.Start(":8080")
}
O usando grupos de rutas:
package main
import (
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
e := echo.New()
// Crea un grupo de rutas protegidas
api := e.Group("/api", VerifyAccessToken)
api.GET("/protected", func(c echo.Context) error {
// Información del token de acceso directamente desde el contexto
token := c.Get("auth").(jwt.Token)
return c.JSON(200, echo.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Datos protegidos accedidos correctamente",
})
})
e.Start(":8080")
}
package main
import (
"github.com/gofiber/fiber/v2"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
app := fiber.New()
// Aplica el middleware a las rutas protegidas
app.Get("/api/protected", VerifyAccessToken, func(c *fiber.Ctx) error {
// Información del token de acceso directamente desde locals
tokenInterface := c.Locals("auth")
if tokenInterface == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token not found"})
}
token := tokenInterface.(jwt.Token)
return c.JSON(fiber.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
app.Listen(":8080")
}
O usando grupos de rutas:
package main
import (
"github.com/gofiber/fiber/v2"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
app := fiber.New()
// Crea un grupo de rutas protegidas
api := app.Group("/api", VerifyAccessToken)
api.Get("/protected", func(c *fiber.Ctx) error {
// Información del token de acceso directamente desde locals
token := c.Locals("auth").(jwt.Token)
return c.JSON(fiber.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Datos protegidos accedidos correctamente",
})
})
app.Listen(":8080")
}
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := chi.NewRouter()
// Aplica el middleware a las rutas protegidas
r.With(VerifyAccessToken).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
// Información del token de acceso directamente desde el contexto
tokenInterface := r.Context().Value(AuthContextKey)
if tokenInterface == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Token no encontrado"})
return
}
token := tokenInterface.(jwt.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
http.ListenAndServe(":8080", r)
}
O usando grupos de rutas:
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := chi.NewRouter()
// Crea un grupo de rutas protegidas
r.Route("/api", func(r chi.Router) {
r.Use(VerifyAccessToken)
r.Get("/protected", func(w http.ResponseWriter, r *http.Request) {
// Información del token de acceso directamente desde el contexto
token := r.Context().Value(AuthContextKey).(jwt.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Datos protegidos accedidos correctamente",
})
})
})
http.ListenAndServe(":8080", r)
}
- Spring Boot
- Quarkus
- Micronaut
- Vert.x Web
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@RestController
public class ProtectedController {
@GetMapping("/api/protected")
public Map<String, Object> protectedEndpoint(@AuthenticationPrincipal Jwt jwt) {
// Información del token de acceso directamente desde el JWT
String scopes = jwt.getClaimAsString("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", jwt.getSubject(),
"client_id", jwt.getClaimAsString("client_id"),
"organization_id", jwt.getClaimAsString("organization_id"),
"scopes", scopeList,
"audience", jwt.getAudience()
);
}
}
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.container.ContainerRequestContext;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Path("/api")
public class ProtectedResource {
@Inject
JsonWebToken jwt;
@GET
@Path("/protected")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> protectedEndpoint(@Context ContainerRequestContext requestContext) {
// Accede al JWT directamente desde la inyección o el contexto
JsonWebToken token = (JsonWebToken) requestContext.getProperty("auth");
if (token == null) {
token = jwt; // Alternativa al JWT inyectado
}
String scopes = token.getClaim("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", token.getSubject(),
"client_id", token.<String>getClaim("client_id"),
"organization_id", token.<String>getClaim("organization_id"),
"scopes", scopeList,
"audience", token.getAudience()
);
}
}
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Controller("/api")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class ProtectedController {
@Get("/protected")
public Map<String, Object> protectedEndpoint(Authentication authentication) {
// Accede a la información del token de acceso directamente desde Authentication
String scopes = (String) authentication.getAttributes().get("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", authentication.getName(),
"client_id", authentication.getAttributes().get("client_id"),
"organization_id", authentication.getAttributes().get("organization_id"),
"scopes", scopeList,
"audience", authentication.getAttributes().get("aud")
);
}
}
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
public class MainVerticle extends AbstractVerticle {
@Override
public void start(Promise<Void> startPromise) throws Exception {
Router router = Router.router(vertx);
// Aplica middleware a las rutas protegidas
router.route("/api/protected*").handler(new JwtAuthHandler(vertx));
router.get("/api/protected").handler(this::protectedEndpoint);
vertx.createHttpServer()
.requestHandler(router)
.listen(8080, result -> {
if (result.succeeded()) {
startPromise.complete();
} else {
startPromise.fail(result.cause());
}
});
}
private void protectedEndpoint(RoutingContext context) {
// Accede al principal JWT directamente desde el contexto
JsonObject principal = context.get("auth");
if (principal == null) {
context.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Principal JWT no encontrado\"}");
return;
}
String scopes = principal.getString("scope");
JsonObject response = new JsonObject()
.put("sub", principal.getString("sub"))
.put("client_id", principal.getString("client_id"))
.put("organization_id", principal.getString("organization_id"))
.put("scopes", scopes != null ? scopes.split(" ") : new String[0])
.put("audience", principal.getJsonArray("aud"));
context.response()
.putHeader("Content-Type", "application/json")
.end(response.encode());
}
}
Ya hemos configurado el middleware de autenticación (Authentication) y autorización (Authorization) en las secciones anteriores. Ahora podemos crear un controlador protegido que valide los tokens de acceso (Access tokens) y extraiga los reclamos (Claims) de las solicitudes autenticadas.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace YourApiNamespace.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize] // Requiere autenticación para todas las acciones en este controlador
public class ProtectedController : ControllerBase
{
[HttpGet]
public IActionResult GetProtectedData()
{
// Información del token de acceso directamente desde los reclamos del usuario
var sub = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value;
var clientId = User.FindFirst("client_id")?.Value;
var organizationId = User.FindFirst("organization_id")?.Value;
var scopes = User.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
var audience = User.FindAll("aud").Select(c => c.Value).ToArray();
return Ok(new {
sub,
client_id = clientId,
organization_id = organizationId,
scopes,
audience
});
}
[HttpGet("claims")]
public IActionResult GetAllClaims()
{
// Devuelve todos los reclamos para depuración / inspección
var claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList();
return Ok(new { claims });
}
}
}
- Laravel
- Symfony
- Slim
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth.token')->group(function () {
Route::get('/api/protected', function (Request $request) {
// Accede a la información de autenticación desde los atributos de la solicitud
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
});
});
O usando controladores:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ProtectedController extends Controller
{
public function __construct()
{
$this->middleware('auth.token');
}
public function index(Request $request)
{
// Accede a la información de autenticación desde los atributos de la solicitud
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
}
public function show(Request $request)
{
// Tu lógica del endpoint protegido
$auth = $request->attributes->get('auth');
return [
'auth' => $auth->toArray(),
'message' => 'Datos protegidos accedidos correctamente'
];
}
}
<?php
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/protected')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ProtectedController extends AbstractController
{
#[Route('', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
// Accede a la información de autenticación desde los atributos de la solicitud
$auth = $request->attributes->get('auth');
return $this->json(['auth' => $auth->toArray()]);
}
}
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class ProtectedController
{
public function index(Request $request, Response $response): Response
{
// Accede a la información de autenticación desde los atributos de la solicitud
$auth = $request->getAttribute('auth');
$response->getBody()->write(json_encode(['auth' => $auth->toArray()]));
return $response->withHeader('Content-Type', 'application/json');
}
public function detailed(Request $request, Response $response): Response
{
// Tu lógica para el endpoint protegido
$auth = $request->getAttribute('auth');
$data = [
'auth' => $auth->toArray(),
'message' => 'Datos protegidos accedidos correctamente'
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
}
- Ruby on Rails
- Sinatra
- Grape
class ApplicationController < ActionController::API # Para aplicaciones solo API
# class ApplicationController < ActionController::Base # Para aplicaciones Rails completas
include JwtAuthentication
end
class Api::ProtectedController < ApplicationController
before_action :verify_access_token
def index
# Accede a la información de autenticación desde @auth
render json: { auth: @auth.to_h }
end
end
Rails.application.routes.draw do
namespace :api do
resources :protected, only: [:index]
end
end
require 'sinatra'
require 'json'
require_relative 'auth_middleware'
require_relative 'auth_constants'
require_relative 'auth_info'
require_relative 'authorization_error'
require_relative 'auth_helpers'
require_relative 'jwt_validator'
# Aplicar middleware
use AuthMiddleware
get '/api/protected' do
content_type :json
# Acceder a la información de autenticación desde env
auth = env['auth']
{ auth: auth.to_h }.to_json
end
# Endpoint público (no protegido por el middleware)
get '/' do
content_type :json
{ message: "Endpoint público" }.to_json
end
require 'grape'
require_relative 'auth_helpers'
require_relative 'auth_constants'
require_relative 'auth_info'
require_relative 'authorization_error'
require_relative 'jwt_validator'
class API < Grape::API
format :json
helpers GrapeAuthHelpers
namespace :api do
namespace :protected do
before do
authenticate_user!
end
get do
# Accede a la información de autenticación desde el helper de autenticación (Access auth information from auth helper)
{ auth: auth.to_h }
end
end
end
# Endpoint público (no protegido) (Public endpoint (not protected))
get :public do
{ message: "Punto final público (Public endpoint)" }
end
end
require_relative 'api'
run API
- Axum
- Actix Web
- Rocket
use axum::{
extract::Extension,
http::StatusCode,
middleware,
response::Json,
routing::get,
Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
#[tokio::main]
async fn main() {
let validator = Arc::new(JwtValidator::new().await.expect("No se pudo inicializar el validador de JWT (JWT validator)"));
let app = Router::new()
.route("/api/protected", get(protected_handler))
.layer(middleware::from_fn(jwt_middleware::jwt_middleware))
.layer(Extension(validator))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn protected_handler(Extension(auth): Extension<AuthInfo>) -> Json<Value> {
// Accede a la información de autenticación (auth) directamente desde Extension
Json(json!({ "auth": auth }))
}
use actix_web::{middleware::Logger, web, App, HttpRequest, HttpServer, Result};
use serde_json::{json, Value};
use std::sync::Arc;
mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
use jwt_middleware::JwtMiddleware;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let validator = Arc::new(JwtValidator::new().await.expect("No se pudo inicializar el validador de JWT (Failed to initialize JWT validator)"));
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(validator.clone()))
.wrap(Logger::default())
.service(
web::scope("/api/protected")
.wrap(JwtMiddleware::new(validator.clone()))
.route("", web::get().to(protected_handler))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
async fn protected_handler(req: HttpRequest) -> Result<web::Json<Value>> {
// Accede a la información de autenticación desde las extensiones de la solicitud (Access auth information from request extensions)
let auth = req.extensions().get::<AuthInfo>().unwrap();
Ok(web::Json(json!({ "auth": auth })))
}
use rocket::{get, launch, routes, serde::json::Json};
use serde_json::{json, Value};
mod lib;
mod jwt_validator;
mod guards;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
#[get("/api/protected")]
fn protected_handler(auth: AuthInfo) -> Json<Value> {
// Accede a la información de autenticación directamente desde el guardia de la solicitud
Json(json!({ "auth": auth }))
}
#[launch]
async fn rocket() -> _ {
let validator = JwtValidator::new().await.expect("No se pudo inicializar el validador de JWT");
rocket::build()
.manage(validator)
.mount("/", routes![protected_handler])
}
Paso 5: Prueba tu implementación
Obtener tokens de acceso (Access tokens)
Desde tu aplicación cliente: Si has configurado una integración de cliente, tu aplicación puede obtener tokens automáticamente. Extrae el token de acceso y úsalo en las solicitudes a la API.
Para pruebas con curl / Postman:
-
Tokens de usuario: Usa las herramientas de desarrollador de tu aplicación cliente para copiar el token de acceso desde localStorage o la pestaña de red.
-
Tokens máquina a máquina: Utiliza el flujo de credenciales de cliente. Aquí tienes un ejemplo no normativo usando curl:
curl -X POST https://your-tenant.logto.app/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=your-m2m-client-id" \
-d "client_secret=your-m2m-client-secret" \
-d "resource=https://your-api-resource-indicator" \
-d "scope=api:read api:write"Puede que necesites ajustar los parámetros
resource
yscope
según tu recurso de API y permisos; también puede ser necesario un parámetroorganization_id
si tu API está asociada a una organización.
¿Necesitas inspeccionar el contenido del token? Usa nuestro decodificador de JWT para decodificar y verificar tus JWTs.
Probar endpoints protegidos
Solicitud con token válido
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected
Respuesta esperada:
{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
Token ausente
curl http://localhost:3000/api/protected
Respuesta esperada (401):
{
"error": "Authorization header is missing"
}
Token inválido
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected
Respuesta esperada (401):
{
"error": "Invalid token"
}
Pruebas específicas del modelo de permisos
- Recursos de API globales
- Permisos de organización (no API)
- Recursos de API a nivel de organización
Escenarios de prueba para APIs protegidas con alcances globales:
- Alcances válidos: Prueba con tokens que incluyan los alcances de API requeridos (por ejemplo,
api:read
,api:write
) - Alcances ausentes: Espera 403 Prohibido cuando el token no tenga los alcances requeridos
- Audiencia incorrecta: Espera 403 Prohibido cuando la audiencia no coincida con el recurso de API
# Token sin los alcances requeridos - espera 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected
Escenarios de prueba para control de acceso específico de organización:
- Token de organización válido: Prueba con tokens que incluyan el contexto correcto de organización (ID de organización y alcances)
- Alcances ausentes: Espera 403 Prohibido cuando el usuario no tenga permisos para la acción solicitada
- Organización incorrecta: Espera 403 Prohibido cuando la audiencia no coincida con el contexto de la organización (
urn:logto:organization:<organization_id>
)
# Token para organización incorrecta - espera 403
curl -H "Authorization: Bearer token-for-different-organization" \
http://localhost:3000/api/protected
Escenarios de prueba combinando validación de recursos de API con contexto de organización:
- Organización válida + alcances de API: Prueba con tokens que tengan tanto el contexto de organización como los alcances de API requeridos
- Alcances de API ausentes: Espera 403 Prohibido cuando el token de organización no tenga los permisos de API requeridos
- Organización incorrecta: Espera 403 Prohibido al acceder a la API con un token de otra organización
- Audiencia incorrecta: Espera 403 Prohibido cuando la audiencia no coincida con el recurso de API a nivel de organización
# Token de organización sin alcances de API - espera 403
curl -H "Authorization: Bearer organization-token-without-api-scopes" \
http://localhost:3000/api/protected
Recursos relacionados
Personalización de reclamos de token JSON Web Token (JWT)OpenID Connect Discovery
RFC 8707: Indicadores de recurso