Autenticación Multi-Tenant en Go: Diagramas y un Authorization Server Neutral de Proveedor

Autenticación Multi-Tenant en Go: Diagramas y un Authorization Server Neutral de Proveedor

Complemento práctico a la arquitectura de identidad distribuida: cinco diagramas del flujo completo de autenticación, el ciclo de vida access/refresh token y la propagación de confianza entre servicios, más la implementación en Go de un Issuer, un Verifier y un interceptor gRPC bajo arquitectura hexagonal para no depender de un único proveedor cloud.

Por Omar Flores

En Identidad Distribuida: La Arquitectura de Confianza en Sistemas Multi-Tenant desmenuzamos los tres planos —identidad, autenticación, autorización—, por qué el par access/refresh token resuelve la revocación sin volverse un sistema con estado, y cómo aislar un Authorization Server de cualquier proveedor cloud específico.

Esta es la segunda mitad: los diagramas que hacen tangible ese flujo, y el código Go que lo implementa. Si tu problema real es “tengo RFC, usuario y contraseña, valido contra un catálogo y contra la BD del tenant, armo un JWT, y no sé cómo hacer que todos mis servicios confíen en ese token sin acoplarse entre sí” —esto es la solución de punta a punta.


Los cinco diagramas del sistema

1. Los tres planos y sus responsabilidades

Identidad, autenticación y autorización no son sinónimos ni etapas intercambiables. Cada una responde una pregunta distinta y vive en un lugar distinto del sistema.

graph TB
    ID["Plano 1 · Identidad<br/>¿Quién eres?<br/>RFC + usuario · se establece una vez"]
    AUTHN["Plano 2 · Autenticación<br/>¿Puedes demostrarlo?<br/>Authorization Server · evento puntual → token"]
    AUTHZ["Plano 3 · Autorización<br/>¿Qué puedes hacer?<br/>Cada Resource Server · evaluado en cada request"]

    ID --> AUTHN --> AUTHZ

2. Flujo completo de autenticación multi-tenant

El orden de validación importa: primero el tenant (evita fuerza bruta contra cuentas de tenants inactivos y corta temprano si hay impago), luego el usuario, y al final se acuña el token.

sequenceDiagram
    participant C as Cliente
    participant AS as Authorization Server
    participant ADM as Catálogo (admon)
    participant TDB as BD del tenant

    C->>AS: POST /token (rfc, usuario, password)
    AS->>ADM: 1. ¿RFC existe? ¿activo? ¿suscripción vigente?
    ADM-->>AS: tenant_status = active
    AS->>ADM: 2. Resolver connection string del tenant
    ADM-->>AS: DSN de la BD del RFC
    AS->>TDB: 3. Verificar usuario + password
    TDB-->>AS: usuario válido
    AS->>ADM: 4. Cargar entitlements (módulos, roles)
    ADM-->>AS: módulos contratados + roles
    AS->>AS: 5. Firmar access_token + refresh_token
    AS-->>C: access_token + refresh_token

3. Ciclo de vida del token: access vs. refresh

Aquí está la respuesta a “¿cómo sé si el token aún tiene valor?”. El access token expira solo (sin llamadas extra); el refresh token es el punto donde se re-evalúa el estado real del tenant.

sequenceDiagram
    participant C as Cliente
    participant RS as Resource Server
    participant AS as Authorization Server

    Note over C,RS: Uso normal (0–15 min)
    C->>RS: Request + access_token
    RS->>RS: Verifica firma con JWKS cacheado (offline)
    RS-->>C: 200 OK

    Note over C,AS: Expiración
    C->>RS: Request + access_token expirado
    RS-->>C: 401 Unauthorized
    C->>AS: POST /refresh (refresh_token)
    AS->>AS: Re-evalúa estado del tenant
    alt Tenant activo
        AS-->>C: nuevo access_token
    else Tenant suspendido (impago)
        AS-->>C: 403 — refresh denegado, sesión muere
    end

4. Topología de confianza entre servicios

La clave de la firma asimétrica: la clave privada vive en un único lugar; la pública se cachea en todos los servicios y nadie necesita llamar de vuelta al Authorization Server para verificar.

graph LR
    AS["Authorization Server<br/>(clave privada)"] -->|publica| JWKS["/.well-known/jwks.json<br/>(clave pública)"]
    JWKS -.->|cachea offline| SA["Servicio A"]
    JWKS -.->|cachea offline| SB["Servicio B"]
    JWKS -.->|cachea offline| SC["Servicio C"]

    SA -->|"Token forwarding<br/>(mismo aud)"| SB
    SA -->|"Token exchange RFC 8693<br/>(aud acotado)"| SC
    M["Job / Cron"] -->|"Client credentials<br/>(sin usuario)"| AS

5. Máquina de estados del tenant

La morosidad no es un booleano ni una emergencia de sub-segundo: es una transición de estado que se resuelve naturalmente en el siguiente refresh.

stateDiagram-v2
    [*] --> Trial
    Trial --> Active: pago confirmado
    Active --> PastDue: falla el cobro
    PastDue --> Active: pago regularizado
    PastDue --> Suspended: expira periodo de gracia
    Suspended --> Active: pago + reactivación
    Suspended --> Cancelled: baja definitiva
    Cancelled --> [*]

    note right of PastDue
        Access tokens activos siguen
        válidos hasta expirar (≤15 min)
    end note
    note right of Suspended
        Refresh tokens rechazados.
        No se emiten tokens nuevos.
    end note

La implementación en Go

El contrato tiene tres piezas que nunca se mezclan: quien acuña confianza (el Issuer, solo en el Authorization Server), quien la verifica (el Verifier, en cualquier Resource Server) y quien la hace cumplir (el interceptor, en el borde de cada servicio).

El Issuer — el único que firma

// AccessTokenRequest es el contrato de entrada para acuñar identidad.
// Deliberadamente "delgado": claims gruesos viajan aquí; los permisos
// finos se resuelven en el Resource Server.
type AccessTokenRequest struct {
	TenantID  string   // RFC. Ancla del aislamiento multi-tenant (claim "tid").
	Subject   string   // ID de usuario DENTRO del tenant (no global).
	Audiences []string // Servicios que aceptarán el token (claim "aud").
	Scopes    []string // Capacidades de grano medio: "facturacion:write".
	Modules   []string // Módulos contratados por el tenant.
	PlanTier  string   // El recurso aplica los LÍMITES de este plan.
}

// Issuer encapsula la única clave privada del sistema. Es el único punto
// del universo donde nace la confianza.
type Issuer struct {
	privateKey jwk.Key       // Incluye "kid" para rotación. NUNCA se publica.
	issuerURL  string        // "https://auth.miempresa.mx" → claim "iss".
	ttl        time.Duration // Vida corta: minimiza la ventana de revocación.
	clock      func() time.Time
}

func (i *Issuer) Mint(req AccessTokenRequest) (string, error) {
	now := i.clock()

	tok, err := jwt.NewBuilder().
		Issuer(i.issuerURL).
		Subject(req.Subject).
		Audience(req.Audiences).
		IssuedAt(now).
		NotBefore(now).
		Expiration(now.Add(i.ttl)).
		JwtID(newJTI()). // Ancla para deny-list.
		Claim("tid", req.TenantID). // Aislamiento de tenant.
		Claim("scope", strings.Join(req.Scopes, " ")). // Formato OAuth2 canónico.
		Claim("modules", req.Modules).
		Claim("plan", req.PlanTier).
		Build()
	if err != nil {
		return "", fmt.Errorf("auth: construyendo claims: %w", err)
	}

	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, i.privateKey))
	if err != nil {
		return "", fmt.Errorf("auth: firmando token: %w", err)
	}
	return string(signed), nil
}

El Verifier — cualquier Resource Server, solo verifica

// Verifier valida tokens SIN contactar al Auth Server en el camino feliz.
// Cachea y rota claves públicas automáticamente vía JWKS.
type Verifier struct {
	jwks     *jwk.Cache
	jwksURL  string // Apunta al /.well-known/jwks.json del Auth Server.
	issuer   string // Ancla de confianza: rechaza todo emisor que no sea el nuestro.
	audience string // ESTE servicio: rechaza tokens dirigidos a otros.
}

func NewVerifier(ctx context.Context, jwksURL, issuer, audience string) (*Verifier, error) {
	cache := jwk.NewCache(ctx)
	// Auto-refresh: si rotas claves en el Auth Server, los recursos las
	// recogen sin redeploy. Desacopla el ciclo de vida criptográfico.
	if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
		return nil, fmt.Errorf("auth: registrando JWKS: %w", err)
	}
	if _, err := cache.Refresh(ctx, jwksURL); err != nil {
		return nil, fmt.Errorf("auth: precarga JWKS: %w", err)
	}
	return &Verifier{cache, jwksURL, issuer, audience}, nil
}

func (v *Verifier) Verify(ctx context.Context, raw string) (Principal, error) {
	keySet, err := v.jwks.Get(ctx, v.jwksURL)
	if err != nil {
		return Principal{}, fmt.Errorf("auth: obteniendo JWKS: %w", err)
	}

	tok, err := jwt.Parse([]byte(raw),
		jwt.WithKeySet(keySet), // Firma vs. clave pública del "kid".
		jwt.WithValidate(true), // exp, nbf, iat.
		jwt.WithIssuer(v.issuer), // Solo NUESTRO emisor.
		jwt.WithAudience(v.audience), // El token venía dirigido a MÍ.
		jwt.WithAcceptableSkew(30*time.Second), // Tolerancia de reloj entre nodos.
	)
	if err != nil {
		return Principal{}, fmt.Errorf("auth: token inválido: %w", err)
	}
	return principalFrom(tok)
}

El interceptor — enforcement reutilizable en cualquier servicio gRPC

type principalCtxKey struct{}

// UnaryAuthInterceptor: una sola línea de wiring protege CUALQUIER servicio gRPC.
// Aquí es donde "todos los sistemas viven con el JWT".
func UnaryAuthInterceptor(v *Verifier, denylist Denylist) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler) (any, error) {

		raw, err := bearerFromMetadata(ctx) // lee "authorization: Bearer ..."
		if err != nil {
			return nil, status.Error(codes.Unauthenticated, "credenciales ausentes")
		}

		principal, err := v.Verify(ctx, raw)
		if err != nil {
			return nil, status.Error(codes.Unauthenticated, "token inválido o expirado")
		}

		// Punto de revocación inmediata (OPCIONAL). Lookup O(1) en Redis por:
		//  - jti  → logout / token comprometido
		//  - tid  → matar un tenant entero al instante (fraude, no morosidad)
		// La inmediatez de revocación se PAGA con este acoplamiento a Redis.
		if denylist.IsRevoked(ctx, principal.TokenID, principal.TenantID) {
			return nil, status.Error(codes.PermissionDenied, "sesión o tenant revocado")
		}

		// El tenant viaja en el contexto. El resolver de conexión a la BD
		// del tenant lo lee de aquí — JAMÁS de un parámetro del request.
		ctx = context.WithValue(ctx, principalCtxKey{}, principal)
		return handler(ctx, req)
	}
}

Regla de hierro del aislamiento multi-tenant: el tid para enrutar a la BD-por-cliente sale siempre del token verificado en el contexto, nunca de un parámetro que el cliente controle. Confiar en un tenant_id del request es la vulnerabilidad número uno de los sistemas multi-tenant.

El puerto que evita el vendor lock-in

La neutralidad de proveedor no es una promesa, es una interfaz. Tu dominio de autenticación depende de esto, nunca de un SDK concreto:

// Puerto: tu dominio solo conoce esto. Cero imports de Azure/AWS aquí.
type KeyProvider interface {
	SigningKey(ctx context.Context) (jwk.Key, error) // privada para firmar
	Rotate(ctx context.Context) error
}

// Adaptadores intercambiables — la decisión vive en el wiring, no en el dominio:
//   azureKeyVaultAdapter   → Azure Key Vault   (hoy)
//   awsKmsAdapter          → AWS KMS           (si migras)
//   hashiVaultAdapter      → HashiCorp Vault   (cloud-agnóstico real)
//   localFileAdapter       → desarrollo / tests

Migrar de Azure a otro proveedor es escribir un adaptador nuevo que satisface KeyProvider y cambiar una línea en el wiring. El dominio —el Issuer, el Verifier, la lógica de tenant— no se entera.


Con esto se cierra el círculo entre la teoría y el código: los tres planos definen qué construir, los cinco diagramas muestran cómo fluye, y estas tres piezas —Issuer, Verifier, interceptor— son el contrato mínimo que hace que cualquier servicio nuevo confíe en el mismo token sin acoplarse a los demás.

Artículos relacionados

Por relevancia
Identidad Distribuida: La Arquitectura de Confianza en Sistemas Multi-Tenant
Architecture 7 tags en común

Identidad Distribuida: La Arquitectura de Confianza en Sistemas Multi-Tenant

Cómo diseñar autenticación, autorización y tokens en sistemas multi-tenant distribuidos. JWT, firma asimétrica y aislamiento de datos.

· 19 min
API de Notas Production-Grade en Go: Postgres, JWT, Argon2id, Docker y Agnosticismo de Persistencia
Backend 5 tags en común

API de Notas Production-Grade en Go: Postgres, JWT, Argon2id, Docker y Agnosticismo de Persistencia

Tercera parte de la serie: llevamos la API de notas a producción con Go + Uber Fx + Gin + pgx + Postgres, autenticación JWT, hashing Argon2id, autorización por dueño, migraciones versionadas con golang-migrate y despliegue con Docker multi-stage. El dominio permanece intacto en cada evolución.

· 30 min
Gestor de Notas Seguro en Go 1.25: Arquitectura Hexagonal desde Cero
Backend 4 tags en común

Gestor de Notas Seguro en Go 1.25: Arquitectura Hexagonal desde Cero

La Guía Definitiva paso a paso para construir un gestor de notas empresarial con Go 1.25, Arquitectura Hexagonal pura, JWT, roles de usuario y permisos granulares. Desde la configuración de CachyOS hasta la inyección de dependencias. Diseñado para novatos y expertos.

· 55 min
Gestor de Notas Seguro con Arquitectura Hexagonal en Go 1.25: Guía Completa Paso a Paso
Backend 4 tags en común

Gestor de Notas Seguro con Arquitectura Hexagonal en Go 1.25: Guía Completa Paso a Paso

Una guía exhaustiva y profesional para construir un gestor de notas seguro desde cero usando Go 1.25: arquitectura hexagonal pura, autenticación JWT, roles de usuario, compartir notas con permisos, testing con httpie, Docker, MongoDB y PostgreSQL. Paso a paso real, código limpio, tips de Neovim y terminal.

· 102 min