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.
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
tidpara 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 untenant_iddel 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
Cómo diseñar autenticación, autorización y tokens en sistemas multi-tenant distribuidos. JWT, firma asimétrica y aislamiento de datos.
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.
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.
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.