Skip to content

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:

  1. Los scopes de Gmail son restricted por 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.
  2. La pantalla de consentimiento queda alineada con el uso real — al conectar Calendar el usuario no ve permisos de Gmail mezclados.
  3. 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} con EX 600 (10 minutos de TTL).
  • Consumo: pipeline MULTI GET DEL atómico en ConnectionsGoogleOAuthStateService.consume. Un replay con el mismo token devuelve nullOAUTH_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

  1. CASL (@CheckPolicies) sobre el subject 'connections.connection' para read, create, update, delete — aplica reglas de rol a nivel de controlador.
  2. Filtrado por visibility dentro de handlers:
    • GetConnectionsQuery aplica WHERE por visibility en el repositorio (lista sólo lo visible al usuario).
    • GetConnectionByIdQuery, UpdateConnectionHandler y DeleteConnectionHandler cargan la entidad y llaman a connection.isVisibleTo(userId). Si retorna false, devuelven ConnectionErrors.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

ErrorHTTPCódigo APICuándo
ConnectionOAuthErrors.providerNotConfigured400PROVIDER_NOT_CONFIGUREDFaltan GOOGLE_*_CLIENT_ID/SECRET
ConnectionOAuthErrors.providerCapabilityMismatch400CONNECTION.PROVIDER_CAPABILITY_MISMATCHcapability no coincide con provider
ConnectionOAuthErrors.stateInvalid400OAUTH_STATE_INVALIDState expirado, replay o no encontrado
ConnectionOAuthErrors.scopeMismatch400OAUTH_SCOPE_MISMATCHUsuario desmarcó scopes en el consent
ConnectionOAuthErrors.googleUpstream502GOOGLE_OAUTH_ERRORError upstream de Google (no invalid_grant)
ConnectionErrors.reauthRequired401CONNECTION_REAUTH_REQUIREDRefresh devolvió invalid_grant
ConnectionErrors.ownershipViolation403CONNECTION.OWNERSHIP_VIOLATIONUsuario intenta operar sobre conexión no visible
ConnectionErrors.notFound404CONNECTION.NOT_FOUNDfindByIdAndOrgId 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 } | null
  • appConfigService.googleWorkspace{ appKey: 'googleWorkspace', clientId, clientSecret } | null
  • appConfigService.getGoogleAppByProvider(provider) → selecciona por ConnectionProvider

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: accessToken y refreshToken se cifran con AES-256-GCM (iv:authTag:ciphertext) vía EncryptionService.encrypt antes de persistir.
  • toPublic() del VO nunca expone tokens — sólo capability, scope, googleAccountEmail, expiresAt.
  • Logs estructurados: campos permitidos connectionId, provider, capability, visibility, status, googleAccountEmail, durationMs. Prohibido loguear accessToken, refreshToken, authUrl, code, state.
  • Revocación al delete: el token se revoca en Google antes del delete para evitar tokens huérfanos activos.