Zoho OAuth — Modo admin ?as=X para pre-autorización de onboarding
Resumen
El endpoint GET /api/oauth/zoho/start acepta un parámetro opcional ?as=Edu|Dani|Txell que permite a Edu autorizar la cuenta Zoho de otro miembro del staff desde su propia sesión CF Access, sin que ese miembro tenga que pasar por el flujo OAuth.
Motivación
Durante el onboarding nocturno de la sprint s68, Edu disponía de las credenciales Zoho de Dani y Txell pero ellos aún no habían pasado por el flujo OAuth desde su sesión. Las alternativas eran costosas:
- Sesiones incógnito con sus credenciales.
- Magic links que requerían que ellos estuvieran disponibles.
El modo ?as=X permite a Edu pre-autorizar sus cuentas de una sola vez desde su propia sesión autenticada.
Flujo técnico
Edu (sesión CF Access activa)
│
├─ GET /api/oauth/zoho/start?as=Dani
│ resolveActorEmail() → "edu@edomo.net"
│ resolveTeamMember() → "Edu"
│ asParam = "Dani" → isTeamMember("Dani") = true
│ targetMember = "Dani"
│ state = randomUUID()
│ stateValue = JSON.stringify({ email: "edu@edomo.net", target: "Dani" })
│ INSERT zoho_oauth_state (state, stateValue, now)
│ → redirect a Zoho authorize URL
│
├─ [Edu se autentica en Zoho como Dani]
│
└─ GET /api/oauth/zoho/callback?code=...&state=...
parseStateValue(stateRow.created_by)
→ { email: "edu@edomo.net", target: "Dani" }
isTeamMember("Dani") = true → teamMember = "Dani"
exchangeCodeForTokens() → tokens
upsertToken(env, "Dani", ..., "edu@edomo.net")
fetchPrimaryZohoEmail() → UPDATE zoho_email
Seguridad
| Control | Implementación |
|---|---|
Solo Edu puede usar ?as=X | if (actor !== 'Edu') → 403 en start.ts |
Valores válidos para ?as | isTeamMember(asParam) — solo Edu, Dani, Txell |
| Auditoría | El campo created_by guarda { email: "edu@...", target: "Dani" } — quién autorizó y para quién |
| State single-use | zoho_oauth_state se consume en callback y se limpian estados >1h |
| Callback público pero protegido | Validación del state UUID contra D1 antes de procesar |
Compatibilidad hacia atrás
El callback callback.ts incorpora parseStateValue() que maneja dos formatos del campo created_by:
- Nuevo (post-s68): JSON
{ email: string, target: string }— usatargetdirectamente comoteamMember. - Antiguo (pre-s68): email plano como string —
targetqueda vacío, se resuelveteamMemberdesdeemailviaresolveTeamMemberFromEmail().
Uso
# Edu re-autoriza su propia cuenta
GET /api/oauth/zoho/start
# Edu autoriza en nombre de Dani (onboarding)
GET /api/oauth/zoho/start?as=Dani
# Edu autoriza en nombre de Txell (onboarding)
GET /api/oauth/zoho/start?as=Txell
Nota: Esta feature requiere que Edu tenga sesión CF Access activa y haga login en Zoho con las credenciales del miembro destino durante el flujo OAuth.
Archivos afectados
| Archivo | Cambio |
|---|---|
functions/api/oauth/zoho/start.ts | Añade lógica ?as=X, serializa state como JSON |
functions/api/oauth/zoho/callback.ts | Añade parseStateValue(), resuelve target desde state JSON |
functions/_lib/staff.ts | Nueva resolveActorEmail(), nueva isTeamMember() |
Historial
Introducida en commit e65dde4 (fix #46, 2026-05-17). Co-autores: @Esquembri, Claude Opus 4.7.
Véase también
- [[entity—workers—function—resolve-actor-email]]
- [[decision—20260517—integracion-zoho-calendar-mail-pivot]]
- [[feature—zoho—oauth-per-user]]
- [[entity—workers—endpoint—oauth-zoho-start]]
- [[entity—workers—endpoint—oauth-zoho-callback]]