Volver a la wiki

Zoho OAuth per-user: autorización individual por miembro del staff (s68)

Zoho OAuth per-user: autorización individual por miembro del staff (s68)

Implementado en commit 7617371 (2026-05-17). Reemplaza el modelo de Service Account singleton introducido en s67.

Problema que resuelve

El modelo anterior almacenaba un único refresh_token (cuenta de Edu, id = 1) en D1. Esto impedía:

Arquitectura del flujo

[Miembro abre /settings/integrations/zoho]
        │
        ▼
GET /api/oauth/zoho/start
  → resolveTeamMember(request)   ← CF Access header
  → guarda state con created_by = cfAccessEmail
  → redirige a Zoho consent screen
        │
        ▼
[Miembro acepta en Zoho]
        │
        ▼
GET /api/oauth/zoho/callback  (PUBLIC_PATH — sin CF Access)
  → valida state (single-use, TTL 1h)
  → resolveTeamMemberFromEmail(state.created_by)
  → exchangeCodeForTokens(code)
  → upsertToken(env, teamMember, 'pending', ...)
  → fetchPrimaryZohoEmail(env, teamMember)
  → UPDATE zoho_oauth_tokens SET zoho_email = <real>
        │
        ▼
D1: zoho_oauth_tokens
  PK: team_member ('Edu' | 'Dani' | 'Txell')
  Columnas: zoho_email, refresh_token, access_token,
            access_token_expires_at, dc, granted_by_cf_email,
            granted_at, updated_at

Resolución de identidad

functions/_lib/staff.ts centraliza el mapeo CF Access email → TeamMember:

const STAFF_EMAIL_TO_NAME: Record<string, TeamMember> = {
  'edudomo2@gmail.com': 'Edu',
  'dfuentes@edomo.net': 'Dani',
  'tfuentes@edomo.net': 'Txell',
};

Tres funciones exportadas:

Renovación de tokens (auto-refresh)

getValidAccessToken(env, teamMember) compara access_token_expires_at con Date.now(). Si expirado, llama refreshAccessToken() que hace POST a https://accounts.zoho.{dc}/oauth/v2/token con grant_type=refresh_token y actualiza D1 en el mismo request.

Seguridad del callback público

El endpoint callback.ts está en PUBLIC_PATHS (sin CF Access) porque Zoho redirige desde sus servidores. La seguridad se sustenta en:

  1. State anti-CSRF — UUID single-use consumido al validar, TTL 1h limpiado automáticamente.
  2. Email en state — created_by almacenado en zoho_oauth_state.created_by al hacer start. El callback verifica que ese email mapea a un TeamMember válido (403 si no).
  3. DC del token — extraído de accounts-server param de Zoho o del parámetro location.

GET /api/oauth/zoho/status

Devuelve resumen de los 3 miembros + información del actor actual:

{
  "members": [
    { "team_member": "Dani", "zoho_email": "dfuentes@edomo.net", "dc": "eu", "granted_at": "..." },
    { "team_member": "Edu",  "zoho_email": "edudomo2@gmail.com", "dc": "eu", "granted_at": "..." },
    { "team_member": "Txell","zoho_email": "tfuentes@edomo.net", "dc": "eu", "granted_at": "..." }
  ],
  "actor": "Edu"
}

ZohoSettingsPanel

Tres cards independientes (Edu / Dani / Txell). Cada miembro solo puede gestionar su propia card:

Casos de error

SituaciónRespuesta
Actor no identificado (no CF Access)403 No autenticado
Email no mapea a TeamMember403 Usuario no autorizado
TeamMember sin token en D1401 Zoho no autorizado para {X}
/api/accounts falla en callbackzoho_email = 'pending' (soft fail)

Véase también

Subir