Appearance
Google OAuth Architecture
Este documento describe el flujo OAuth 2.0 de Google en el módulo connections tras la consolidación a dos apps y la introducción del modelo de visibility.
Decisión 1 — Dos OAuth apps, con capability en la config
Adoptado: dos OAuth apps en Google Cloud — google_gmail (scopes restringidos de Gmail) y google_workspace (Sheets + Calendar). La capacidad específica (gmail | sheets | calendar) vive en GoogleOAuthConfig.capability y la factory de providers selecciona el cliente concreto a partir de ese campo.
Por qué dos apps y no una:
- Los scopes de Gmail son
restrictedpor Google y disparan un proceso de verificación que los scopes de Sheets/Calendar no necesitan. Mantenerlos en una app propia aísla el proceso. - La pantalla de consentimiento queda alineada con el uso real — al conectar Calendar el usuario no ve permisos de Gmail mezclados.
- Permite revocar/rotar credenciales de Gmail sin afectar Workspace y viceversa.
Por qué no tres apps (una por capacidad, como en el diseño anterior): Sheets y Calendar comparten el mismo perfil de riesgo/verificación, y consolidarlos reduce la superficie operativa (menos OAuth clients que mantener, menos secrets que rotar, una sola pantalla de consentimiento Workspace para el usuario).
Decisión 2 — State token en Redis con delete-on-read
- Token:
crypto.randomBytes(32).toString('base64url')— 256 bits de entropía, URL-safe. - Clave Redis:
connections:oauth:state:{token}conEX 600(10 minutos de TTL). - Consumo: pipeline
MULTI GET DELatómico enConnectionsGoogleOAuthStateService.consume. Un replay con el mismo token devuelvenull→OAUTH_STATE_INVALID.
Payload almacenado:
ts
interface IOAuthStatePayload {
provider: 'google_gmail' | 'google_workspace';
capability: 'gmail' | 'sheets' | 'calendar';
visibility: 'private' | 'restricted';
userId: string;
orgId: string;
agencyId: string;
name: string;
createdAt: number;
}El callback es una ruta pública — la autenticación del flujo viene del state token, no del JWT. Por eso el payload lleva todo el contexto necesario para construir la conexión (userId, orgId, agencyId, visibility, name).
Error collapse: OAUTH_STATE_INVALID cubre los tres casos (nunca existió, expiró, ya fue consumido). No se distinguen porque la acción del usuario es idéntica: reiniciar el flujo.
Decisión 3 — Sin lock en refresh concurrente (last-writer-wins)
GoogleCredentialService.getFreshCredentials no toma lock distribuido antes de refrescar. Dos requests concurrentes sobre la misma conexión dispararán refreshes paralelos; Google acepta cada grant de forma independiente, ambos access tokens son válidos hasta su expiresAt propio y el último save() en Postgres gana.
Google no rota el refreshToken por defecto, así que no hay invalidación cruzada. Si a futuro las métricas muestran una tasa anómala de refreshes, se puede introducir SET NX EX 15 en Redis con clave connections:refresh:lock:{connectionId}; por ahora se mantiene fuera.
Decisión 4 — Visibility enforcement en dos capas
- CASL (
@CheckPolicies) sobre el subject'connections.connection'pararead,create,update,delete— aplica reglas de rol a nivel de controlador. - Filtrado por visibility dentro de handlers:
GetConnectionsQueryaplica WHERE por visibility en el repositorio (lista sólo lo visible al usuario).GetConnectionByIdQuery,UpdateConnectionHandleryDeleteConnectionHandlercargan la entidad y llaman aconnection.isVisibleTo(userId). Si retornafalse, devuelvenConnectionErrors.ownershipViolation→ HTTP 403.
ts
isVisibleTo(userId: string): boolean {
if (this.visibility === 'restricted') return true;
return this.addedById === userId; // private
}El scope orgId + agencyId ya fue aplicado por el repo — isVisibleTo asume mismo scope.
Diagramas de secuencia
Inicio del flujo OAuth
sequenceDiagram
participant FE as Frontend
participant Ctl as ConnectionsOAuthController
participant Cmd as StartGoogleOAuthHandler
participant AppCfg as AppConfigService
participant State as ConnectionsGoogleOAuthStateService
participant Flow as GoogleOAuthFlowService
participant Redis
FE->>Ctl: POST /connections/oauth/:provider/start { name, capability, visibility? }
Ctl->>Cmd: execute({ provider, capability, name, visibility, userId, orgId, agencyId })
Cmd->>Cmd: validate capability ↔ provider coupling
alt mismatch
Cmd-->>Ctl: fail CONNECTION.PROVIDER_CAPABILITY_MISMATCH
Ctl-->>FE: 400
else OK
Cmd->>AppCfg: getGoogleAppByProvider(provider)
alt app no configurada
AppCfg-->>Cmd: null
Cmd-->>Ctl: fail PROVIDER_NOT_CONFIGURED
Ctl-->>FE: 400
else app configurada
AppCfg-->>Cmd: { appKey, clientId, clientSecret }
Cmd->>State: create({ provider, capability, visibility, userId, orgId, agencyId, name })
State->>Redis: SET connections:oauth:state:{token} {payload} EX 600
State-->>Cmd: stateToken
Cmd->>AppCfg: getGoogleRedirectUri(provider)
Cmd->>Flow: buildAuthUrl(app, state, scopesForCapability, redirectUri)
Flow-->>Cmd: authUrl (access_type=offline, prompt=consent)
Cmd-->>Ctl: { authUrl, state }
Ctl-->>FE: 200 { authUrl, state }
end
end
Callback y creación de la conexión
sequenceDiagram
participant Browser
participant Ctl as ConnectionsOAuthController
participant Cmd as CompleteGoogleOAuthHandler
participant State as ConnectionsGoogleOAuthStateService
participant Redis
participant Flow as GoogleOAuthFlowService
participant Google
participant Enc as EncryptionService
participant Repo as IConnectionRepository
Browser->>Ctl: GET /connections/oauth/:provider/callback?code&state
alt query.error (user_denied)
Ctl-->>Browser: 302 error URL (USER_DENIED)
else
Ctl->>Cmd: execute({ provider, code, state })
Cmd->>State: consume(state)
State->>Redis: MULTI GET DEL key
alt state inválido
State-->>Cmd: null
Cmd-->>Ctl: fail OAUTH_STATE_INVALID
Ctl-->>Browser: 302 error URL
else payload válido
State-->>Cmd: IOAuthStatePayload
Cmd->>Flow: exchangeCode(app, code, redirectUri)
Flow->>Google: POST /token
Google-->>Flow: { access_token, refresh_token, scope, expiry_date }
Cmd->>Cmd: validate granted scopes ⊇ required
Cmd->>Google: GET /oauth2/v3/userinfo
Google-->>Cmd: { email }
Cmd->>Enc: encrypt(access) + encrypt(refresh)
Cmd->>Repo: save(new Connection({ addedById: userId, visibility, capability, ... }))
Cmd-->>Ctl: { connectionId }
Ctl-->>Browser: 302 success URL?connectionId=...
end
end
Refresco automático de token
sequenceDiagram
participant Caller as AI tool / sync job
participant Cred as GoogleCredentialService
participant Repo as IConnectionRepository
participant Enc as EncryptionService
participant Flow as GoogleOAuthFlowService
participant Google
Caller->>Cred: getFreshCredentials(connectionId)
Cred->>Repo: findById(connectionId)
Cred->>Enc: decrypt(access, refresh)
Cred->>Cred: (expiresAt - now) > safetyWindow (default 5 min)
alt fresh
Cred-->>Caller: { accessToken, expiresAt, scope, googleAccountEmail }
else about to expire
Cred->>Flow: refreshAccessToken(app, refreshToken)
Flow->>Google: POST /token (grant_type=refresh_token)
alt invalid_grant
Google-->>Flow: 400
Cred->>Repo: save(conn.withStatus('error'))
Cred-->>Caller: throw CONNECTION_REAUTH_REQUIRED
else OK
Google-->>Flow: { access_token, expiry_date }
Cred->>Enc: encrypt(new access)
Cred->>Repo: save(conn.withConfig(...))
Cred-->>Caller: fresh credentials
end
end
Revocación al eliminar
DeleteConnectionHandler revoca el token en Google antes del delete, en modo fire-and-forget:
ts
void googleFlow.revokeToken(plainAccessToken).catch((err) => {
this.logger.warn('google_token_revoke_failed', { connectionId, error });
});
await this.repository.delete(command.id);El delete nunca bloquea por fallos de revocación — la limpieza en Google es best-effort.
Taxonomía de errores
| Error | HTTP | Código API | Cuándo |
|---|---|---|---|
ConnectionOAuthErrors.providerNotConfigured | 400 | PROVIDER_NOT_CONFIGURED | Faltan GOOGLE_*_CLIENT_ID/SECRET |
ConnectionOAuthErrors.providerCapabilityMismatch | 400 | CONNECTION.PROVIDER_CAPABILITY_MISMATCH | capability no coincide con provider |
ConnectionOAuthErrors.stateInvalid | 400 | OAUTH_STATE_INVALID | State expirado, replay o no encontrado |
ConnectionOAuthErrors.scopeMismatch | 400 | OAUTH_SCOPE_MISMATCH | Usuario desmarcó scopes en el consent |
ConnectionOAuthErrors.googleUpstream | 502 | GOOGLE_OAUTH_ERROR | Error upstream de Google (no invalid_grant) |
ConnectionErrors.reauthRequired | 401 | CONNECTION_REAUTH_REQUIRED | Refresh devolvió invalid_grant |
ConnectionErrors.ownershipViolation | 403 | CONNECTION.OWNERSHIP_VIOLATION | Usuario intenta operar sobre conexión no visible |
ConnectionErrors.notFound | 404 | CONNECTION.NOT_FOUND | findByIdAndOrgId no encontró la conexión |
En el callback (que responde con 302, no JSON) el controller mapea a ?error=<code>&provider=<provider> y redirige a CONNECTIONS_OAUTH_ERROR_URL. Si esa URL no está configurada, responde con JSON 400.
Variables de entorno y degradación graceful
Las OAuth apps se configuran con sólo CLIENT_ID + CLIENT_SECRET. La redirect URI ya no es un env var — se deriva en runtime como:
${APP_URL}/api/connections/oauth/${provider}/callback(ver AppConfigService.getGoogleRedirectUri). Esto elimina la clase de bugs donde APP_URL y GOOGLE_*_REDIRECT_URI divergen entre entornos.
Los scopes viven en código (domain/constants/google-scopes.ts) y no son configurables por entorno.
Accessors:
appConfigService.googleGmail→{ appKey: 'googleGmail', clientId, clientSecret } | nullappConfigService.googleWorkspace→{ appKey: 'googleWorkspace', clientId, clientSecret } | nullappConfigService.getGoogleAppByProvider(provider)→ selecciona porConnectionProvider
Cada getter retorna null si alguno de los dos valores (id o secret) falta. En ese caso start devuelve PROVIDER_NOT_CONFIGURED y los demás providers siguen operando. La validación es lazy — la app arranca aunque ninguna OAuth app esté configurada.
GOOGLE_TOKEN_REFRESH_SAFETY_WINDOW_MS (default 300000, 5 min) controla cuánto antes del expiresAt se dispara el refresh proactivo.
Seguridad
- State token: 256 bits de entropía, URL-safe, single-use atómico (
MULTI GET DEL). - Redirect URI derivada de
APP_URL: evita host header injection y divergencias de config. - Tokens cifrados en reposo:
accessTokenyrefreshTokense cifran con AES-256-GCM (iv:authTag:ciphertext) víaEncryptionService.encryptantes de persistir. toPublic()del VO nunca expone tokens — sólocapability,scope,googleAccountEmail,expiresAt.- Logs estructurados: campos permitidos
connectionId,provider,capability,visibility,status,googleAccountEmail,durationMs. Prohibido loguearaccessToken,refreshToken,authUrl,code,state. - Revocación al delete: el token se revoca en Google antes del delete para evitar tokens huérfanos activos.