StackPractices
advanced Por Mathias Paulenko

Patrón Voucher

Valida claims y delega acceso usando vouchers firmados sin exponer datos sensibles. Un patrón de seguridad para autorización basada en tokens entre servicios.

Visión General

El patrón Voucher emite tokens firmados y de corta duración (vouchers) que prueban un claim sin revelar los datos sensibles subyacentes. Un servicio puede emitir un voucher afirmando “este usuario está autenticado” o “esta petición está autorizada” y pasarlo a servicios downstream. Los servicios downstream verifican la firma del voucher y confían en el claim — nunca necesitan acceso a las credenciales originales o al data store.

Cuándo Usar

Usar el patrón Voucher cuando:

  • Un servicio necesita probar un claim a otro servicio sin compartir datos sensibles
  • Quieres evitar pasar credenciales a través de múltiples hops de servicio
  • Los servicios downstream necesitan verificar autorización sin consultar una base de datos central
  • Necesitas tokens de delegación de corta duración y scoped entre microservicios
  • Quieres reducir el acoplamiento entre servicios y el identity store

Solución

Python

import hmac
import hashlib
import json
import time
import base64
from dataclasses import dataclass, asdict

SECRET_KEY = b"shared-secret-key-between-services"

@dataclass
class Voucher:
    issuer: str
    subject: str
    claims: dict
    issued_at: float
    expires_at: float

    def to_dict(self) -> dict:
        return asdict(self)

    def is_expired(self) -> bool:
        return time.time() > self.expires_at


def sign_voucher(voucher: Voucher) -> str:
    payload = json.dumps(voucher.to_dict(), sort_keys=True).encode()
    signature = hmac.new(SECRET_KEY, payload, hashlib.sha256).digest()
    token = base64.urlsafe_b64encode(payload) + b"." + base64.urlsafe_b64encode(signature)
    return token.decode()


def verify_voucher(token: str) -> Voucher | None:
    try:
        payload_b64, sig_b64 = token.rsplit(".", 1)
        payload = base64.urlsafe_b64decode(payload_b64)
        expected_sig = hmac.new(SECRET_KEY, payload, hashlib.sha256).digest()
        actual_sig = base64.urlsafe_b64decode(sig_b64)

        if not hmac.compare_digest(expected_sig, actual_sig):
            return None

        data = json.loads(payload)
        voucher = Voucher(**data)
        if voucher.is_expired():
            return None
        return voucher
    except (ValueError, json.JSONDecodeError, KeyError):
        return None


# Servicio A: Emitir un voucher después de autenticar un usuario
def issue_voucher(user_id: str, permissions: list[str]) -> str:
    voucher = Voucher(
        issuer="auth-service",
        subject=user_id,
        claims={"permissions": permissions, "tenant": "acme"},
        issued_at=time.time(),
        expires_at=time.time() + 300,  # 5 minutos
    )
    return sign_voucher(voucher)


# Servicio B: Verificar el voucher sin acceder a la base de datos de auth
def handle_request(auth_header: str):
    token = auth_header.replace("Bearer ", "")
    voucher = verify_voucher(token)
    if not voucher:
        raise PermissionError("Invalid or expired voucher")
    if "read:reports" not in voucher.claims.get("permissions", []):
        raise PermissionError("Insufficient permissions")
    return f"Report delivered to {voucher.subject}"


# Uso
token = issue_voucher("user-123", ["read:reports", "write:reports"])
result = handle_request(f"Bearer {token}")
print(result)

JavaScript

const crypto = require("crypto");

const SECRET_KEY = "shared-secret-key-between-services";

function signVoucher(voucher) {
    const payload = JSON.stringify(voucher);
    const signature = crypto
        .createHmac("sha256", SECRET_KEY)
        .update(payload)
        .digest("base64url");
    return Buffer.from(payload).toString("base64url") + "." + signature;
}

function verifyVoucher(token) {
    try {
        const [payloadB64, sig] = token.split(".");
        const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString());

        const expectedSig = crypto
            .createHmac("sha256", SECRET_KEY)
            .update(Buffer.from(payloadB64, "base64url"))
            .digest("base64url");

        if (!crypto.timingSafeEqual(
            Buffer.from(sig),
            Buffer.from(expectedSig)
        )) {
            return null;
        }

        if (Date.now() / 1000 > payload.expires_at) {
            return null;
        }

        return payload;
    } catch {
        return null;
    }
}

// Servicio A: Emitir voucher
function issueVoucher(userId, permissions) {
    const voucher = {
        issuer: "auth-service",
        subject: userId,
        claims: { permissions, tenant: "acme" },
        issued_at: Date.now() / 1000,
        expires_at: Date.now() / 1000 + 300,
    };
    return signVoucher(voucher);
}

// Servicio B: Verificar voucher
function handleRequest(authHeader) {
    const token = authHeader.replace("Bearer ", "");
    const voucher = verifyVoucher(token);
    if (!voucher) {
        throw new Error("Invalid or expired voucher");
    }
    if (!voucher.claims.permissions.includes("read:reports")) {
        throw new Error("Insufficient permissions");
    }
    return `Report delivered to ${voucher.subject}`;
}

// Uso
const token = issueVoucher("user-123", ["read:reports", "write:reports"]);
console.log(handleRequest(`Bearer ${token}`));

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

public class VoucherPattern {

    static final String SECRET = "shared-secret-key-between-services";

    record Voucher(String issuer, String subject, Map<String, Object> claims,
                   long issuedAt, long expiresAt) {}

    static String signVoucher(Voucher v) throws Exception {
        String payload = String.format(
            "{\"issuer\":\"%s\",\"subject\":\"%s\",\"claims\":%s,\"issuedAt\":%d,\"expiresAt\":%d}",
            v.issuer(), v.subject(), v.claims().toString(), v.issuedAt(), v.expiresAt()
        );
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET.getBytes(), "HmacSHA256"));
        byte[] sig = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
        String payloadB64 = Base64.getEncoder().encodeToString(payload.getBytes());
        String sigB64 = Base64.getEncoder().encodeToString(sig);
        return payloadB64 + "." + sigB64;
    }

    static boolean verifyVoucher(String token) throws Exception {
        String[] parts = token.split("\\.");
        if (parts.length != 2) return false;

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET.getBytes(), "HmacSHA256"));
        byte[] expectedSig = mac.doFinal(Base64.getDecoder().decode(parts[0]));
        byte[] actualSig = Base64.getDecoder().decode(parts[1]);

        return java.util.Arrays.equals(expectedSig, actualSig);
    }

    public static void main(String[] args) throws Exception {
        Voucher v = new Voucher(
            "auth-service", "user-123",
            Map.of("permissions", List.of("read:reports")),
            System.currentTimeMillis() / 1000,
            System.currentTimeMillis() / 1000 + 300
        );

        String token = signVoucher(v);
        System.out.println("Valid: " + verifyVoucher(token));
    }
}

Explicación

El patrón Voucher crea una cadena de confianza entre servicios sin compartir datos sensibles:

  • Issuer: El servicio que autentica al usuario o verifica el claim. Crea y firma el voucher.
  • Voucher: Un token firmado que contiene claims (user ID, permisos, tenant), issuer y expiración. Firmado con HMAC o clave asimétrica.
  • Verifier: El servicio downstream que recibe el voucher. Verifica la firma y chequea expiración. Confía en el claim sin consultar el identity store.
  • Short-lived: Los vouchers expiran rápido (minutos, no horas). Esto limita la ventana de misuse si un voucher es interceptado.
  • Scoped: Los vouchers contienen solo los claims necesarios para la operación downstream. Sin contraseñas, sin session tokens, sin datos innecesarios.

Variantes

VarianteMétodo de FirmaCaso de Uso
HMAC VoucherShared secret (HMAC-SHA256)Servicios que comparten una secret key
JWT VoucherAsimétrico (RS256, ES256)Verificación pública con firma privada
Capability TokenToken opaco + lookupCuando los claims son demasiado grandes para un token
Delegation TokenFirmado por servicio intermedioCadena de confianza across múltiples hops

Pautas

  • Mantener vouchers de corta duración — 5 minutos o menos. Limitar el daño de tokens interceptados.
  • Incluir solo claims necesarios — no embeber perfiles de usuario o datos sensibles en el voucher
  • Usar firma asimétrica para cross-org — RS256/ES256 permite a verificadores chequear solo con public key
  • Usar HMAC para servicios internos — más simple, más rápido, shared secret vía secret manager
  • Verificar firma y expiración en cada petición — nunca skip verification para callers “trusted”
  • Rotar signing keys regularmente — el compromiso de la key compromete todos los vouchers
  • Incluir audience claim — un voucher para el Servicio B no debería ser aceptado por el Servicio C
  • Loguear emisión de vouchers — trackear quién emitió qué voucher para audit trails

Errores Comunes

  • Hacer vouchers de larga duración — se convierten en session tokens reutilizables, derrotando el propósito
  • Embeber datos sensibles (contraseñas, PII) en claims del voucher — los vouchers pueden ser logueados
  • No verificar la firma — aceptar vouchers sin firmar o tampered
  • No chequear expiración — vouchers stale otorgan acceso después de deberían haber expirado
  • Usar la misma key para todos los servicios — un compromiso rompe toda la cadena
  • No incluir audience claim — vouchers destinados a un servicio son aceptados por otro
  • Pasar vouchers en URL query strings — las URLs son logueadas por proxies y load balancers
  • No rotar keys — keys de larga duración aumentan el blast radius de un compromiso

Troubleshooting

  • Login works for some users but not others: check identity provider configuration, user claims, and role mappings. Look for case sensitivity in identifiers.
  • Token expires too quickly: verify token lifetime, refresh logic, and clock skew. Short tokens with secure refresh are preferred.
  • Session is not shared across subdomains: set the cookie domain and SameSite policy correctly. Test in the target browser.
  • Brute force attempts increase: implement rate limiting, account lockout, and CAPTCHA. Monitor failed authentication patterns.
  • OIDC flow fails with invalid_state: ensure the state parameter is stored, transmitted, and validated in the same user session.

Errores Comunes en Producción

  • Aplicar el patrón donde no se necesita abstracción, agregando complejidad accidental.
  • Dejar que el patrón se filtre en módulos no relacionados y confundir los límites de responsabilidad.
  • Sobre-ingeniería en la primera implementación en lugar de comenzar simple y medir el dolor.
  • Saltar los tests de contrato, de modo que las refactorizaciones rompan consumidores en silencio.
  • Ignorar modos de fallo que el patrón no cubre.
  • Usar el patrón como opción por defecto en lugar de elegir la herramienta adecuada para la escala actual.
  • Olvidar documentar cuándo dejar de usar el patrón y qué lo reemplaza.
  • Carecer de observabilidad sobre rendimiento y propagación de errores del patrón.

Preguntas frecuentes

¿Es este patrón adecuado para proyectos pequeños?

Para proyectos pequeños con pocos componentes, este patrón puede añadir complejidad innecesaria. Empieza simple e introduce el patrón cuando sientas el problema que resuelve.

¿Cómo se compara este patrón con alternativas?

Cada patrón hace diferentes trade-offs. Revisa la tabla de variantes arriba y considera tus restricciones específicas: tamaño del equipo, requisitos de rendimiento y planes de escalado.

¿Puedo aplicar este patrón parcialmente?

Sí. Muchos equipos adoptan patrones incrementalmente. Empieza con la idea central y añade sofisticación según sea necesario. El patrón es una guía, no un blueprint estricto.