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:
- Crear eventos de Calendar con el organizador correcto según quién actúa.
- Vincular emails desde la cuenta real del miembro que trabaja la task.
- Auditar qué acciones Zoho ejecutó cada persona.
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:
resolveTeamMember(request)— leecf-access-authenticated-user-emailheader.resolveTeamMemberFromEmail(email)— usado en callback (el header no está disponible al venir de Zoho).isTeamMember(value)— type guard.
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:
- State anti-CSRF — UUID single-use consumido al validar, TTL 1h limpiado automáticamente.
- Email en state —
created_byalmacenado enzoho_oauth_state.created_byal hacer start. El callback verifica que ese email mapea a un TeamMember válido (403 si no). - DC del token — extraído de
accounts-serverparam de Zoho o del parámetrolocation.
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:
- Card en estado no autorizado: botón “Conectar Zoho” → dispara start.
- Card autorizada: muestra
zoho_email,dc,granted_at. Botón “Desconectar” (llamadeleteToken). - Card con
zoho_email = 'pending': aviso de re-auth (el email no se pudo resolver en callback).
Casos de error
| Situación | Respuesta |
|---|---|
| Actor no identificado (no CF Access) | 403 No autenticado |
| Email no mapea a TeamMember | 403 Usuario no autorizado |
| TeamMember sin token en D1 | 401 Zoho no autorizado para {X} |
/api/accounts falla en callback | zoho_email = 'pending' (soft fail) |
Véase también
- [[decision—20260517—integracion-zoho-calendar-mail-pivot]]
- [[entity—workers—service—staff-lib]]
- [[entity—workers—service—zoho-lib]]
- [[entity—workers—endpoint—task-calendar-links]]
- [[entity—workers—endpoint—task-email-links]]