Proveedores OAuth como extensión
Escribir un proveedor OAuth, dependencias, modos de autenticación, STRIDE y cumplimiento.
En esta página
- Modelo
- Escribir un proveedor OAuth como extensión
- Dependencias entre extensiones
- Rutas y páginas de extensiones: modos de autenticación
- Seguridad: modelo de amenazas STRIDE
- Requisito: la clave de dominio de la instancia
- Concesiones de ejecución (sin clave global del backend)
- Reglas de seguridad adicionales
- Cumplimiento (verificado por código y pruebas)
- Proveedores incluidos: MicrosoftLib, ZoomLib y SlackLib
- Funciones v3 (oauth.provider.v3): credencial de bot y rutas con dispatch
- Funciones v2 (oauth.provider.v2)
- Desvincular y revocar, por proveedor
- Plantilla y generador
- Probar con credenciales reales
- Migrar una instancia
Un proveedor OAuth (Google, Microsoft, Zoom o Slack, o el tuyo) es una extensión: declara oauthProviders en su manifest y el núcleo de la instancia ejecuta el flujo. Las demás extensiones lo usan por dependencia y nunca ven tokens.
Modelo#
- Núcleo (
/api/oauth/[provider]/start|callback|reconnect|unlink): PKCE S256,stateopaco de un solo uso, verificación delid_tokenOIDC por JWKS, comprobación deiss(RFC 9207),returnTosaneado, refresh con bloqueo y rotación, revocación RFC 7009 al desvincular. Los alias de Google (/api/auth/google,/api/auth/callback/google) usan el mismo flujo. - Registro: los proveedores salen de los manifests instalados. Si no hay extensión, Google sigue funcionando con las variables
GOOGLE_*solo cuando todos los endpoints son hosts oficiales de Google. - Credenciales: el client secret y las credenciales compartidas se guardan cifradas (AES-256-GCM v3) en la tabla de la instancia
OAuthProviderConfig, ancladas a los hosts aprobados. Si cambia un host, el proveedor pasa aneeds_reapproval. - Broker: el backend llama a la instancia (
/api/internal/host/oauth) presentando laexecutionGrantque la propia instancia firmó (ver "Concesiones de ejecución") y exponeservices.oauthyctx.libs.<proveedor>.<acción>. Los grupos de scopes y el acceso compartido los deriva la INSTANCIA de la concesión. Permisos:OAUTH_ACCOUNT:<proveedor>:<grupo>yOAUTH_SHARED:<proveedor>. - Principales:
user(cuenta del usuario),organizer(cuenta compartida) yservice(cuenta de servicio; el JWT lo firma el núcleo).
Escribir un proveedor OAuth como extensión#
- Declara
oauthProviderscon endpoints https, puerto 443, DNS público yallowedHosts(regla anti-SSRF). - Cataloga los
scopescongroup, texto es/en yrisk. - Declara
actions(método, ruta con parámetros, scopes requeridos,fixedQuery,bodyFrom,upload,write). Nada que no esté declarado se puede llamar. - Pon
requires.capabilitiesconoauth.provider.v1yrequires.clientApi5 (con funciones v2:oauth.provider.v2y clientApi 9). - Publica; el administrador introduce el client id y el secreto en la consola (
/admin/extensions, ooauth secret seten la CLI de administración) y aprueba los hosts del proveedor. - Atajo:
npm run new:oauth-provider -- <id>genera el esqueleto, las pruebas y la prueba de flujo (ver "Plantilla y generador").
{
"id": "core-googlelib",
"version": "1.0.0",
"requires": { "clientApi": 5, "capabilities": ["oauth.provider.v1", "oauth.broker.v1"] },
"oauthProviders": [{
"id": "google",
"displayName": "Google",
"authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth",
"tokenUrl": "https://oauth2.googleapis.com/token",
"apiBase": "https://www.googleapis.com",
"allowedHosts": ["accounts.google.com", "oauth2.googleapis.com", "www.googleapis.com"],
"pkce": true,
"clientIdSetting": "GOOGLE_CLIENT_ID",
"clientSecretCredential": "GOOGLE_CLIENT_SECRET",
"redirectPath": "/api/auth/callback/google",
"scopes": [{ "id": "https://www.googleapis.com/auth/calendar.events", "group": "calendar", "es": "Eventos", "en": "Events", "risk": "medium" }],
"actions": [{ "id": "calendar.events.list", "group": "calendar", "method": "GET", "path": "/calendar/v3/calendars/{calendarId}/events", "requiresScopes": ["https://www.googleapis.com/auth/calendar.events"] }]
}]
}Dependencias entre extensiones#
requires.extensions acepta rangos semver (^, ~, comparadores y *). Instalar una extensión instala o propone sus dependencias; si una dependencia se pausa o desinstala, las dependientes se pausan (no se borran) y se reanudan al volver. Los ciclos se rechazan al publicar. Capacidad: ext.dependencies.v1 (clientApi 3).
{
"id": "core-calendar",
"requires": { "extensions": { "core-googlelib": "^1.0.0" } },
"permissions": ["OAUTH_ACCOUNT:google:calendar", "OAUTH_SHARED:google"]
}
// server.js
const events = await ctx.libs.google.calendar.events.list({ calendarId: 'primary' });Rutas y páginas de extensiones: modos de autenticación#
Las rutas backendRoutes se sirven en /api/ext/<id>/... y las páginas públicas en /p/**. Cada ruta declara auth:
| Modo | Quién puede llamar | Detalle |
|---|---|---|
session | Usuario con sesión | Predeterminado. Respeta minLevel. |
admin | Administrador | minLevel y stepUp (MFA reciente) opcionales. Las páginas lo comprueban en /api/expansions/page-access. |
signature | Backend compartido | Solo llamadas directas firmadas Ed25519 a la instancia; el borde nunca firma signature. |
hmac | Sistema externo | HMAC con secreto de dominio (nunca global), comparación en tiempo constante y anti-replay. |
none | Cualquiera | Requiere el permiso PUBLIC_ROUTE aprobado explícitamente por el administrador (approvedPermissions). Las páginas auth: none solo bajo /p/** con CSP estricta. |
"backendRoutes": [
{ "path": "/hook/:id", "method": "POST", "handler": "onHook", "auth": "hmac", "hmac": { "header": "X-Signature", "secretSetting": "HOOK_SECRET" } },
{ "path": "/status", "method": "GET", "handler": "status", "auth": "admin", "minLevel": 2, "stepUp": true }
]Capacidades: ext.routes.v1, ext.routes.auth.v1, ext.pages.auth.v1 (clientApi 4). El borde de la instancia firma dentro de la URL el origen, nivel, usuario e IP; el backend los verifica.
Seguridad: modelo de amenazas STRIDE#
| Amenaza | Riesgo | Mitigación |
|---|---|---|
| Suplantación (S) | Callback forjado, llamada falsa al broker o a una ruta; backend suplantado | state opaco de un solo uso, PKCE S256, iss e id_token verificados; el broker solo acepta la executionGrant firmada con la clave de ESTA instancia (jti, caducidad, dominio, extensión, usuario); HMAC por ruta con secreto de dominio; registro de proveedores por https a URL fija y esquema estricto, y aprobación del admin para los no integrados. |
| Manipulación (T) | Cambiar endpoints, scopes o parámetros | Endpoints anclados a hosts aprobados (needs_reapproval); acciones validadas contra el manifest; grantedGroups firmado por el backend. |
| Repudio (R) | Negar una vinculación o un cambio de credencial | Auditoría oauth.* con proveedor y nombre de credencial, nunca valores. |
| Divulgación (I) | Fuga de tokens o secretos | Tokens cifrados en reposo; los tokens nunca llegan a las extensiones (el ejecutor retira context.auth); respuestas no-store; el backend compartido no guarda secretos del proveedor. |
| Denegación (D) | Agotar refresh, SSRF o respuestas enormes | Bloqueo de refresh (advisory lock + dedupe), providerFetch sin redirecciones, DNS público, tope de tamaño y de tiempo. |
| Elevación (E) | Extensión que accede a cuentas ajenas | Permisos por grupo; compartido solo con OAUTH_SHARED; PUBLIC_ROUTE requiere aprobación; rutas admin con nivel y MFA reciente. |
Requisito: la clave de dominio de la instancia#
GoogleLib, la IA por puente, los servicios del host y las rutas propias de extensiones (ext.grants.v1, oauth.*, ext.routes*) solo funcionan si la instancia firma sus peticiones con su BLOOMX_DOMAIN_PRIVATE_KEY. Una instancia sin clave sigue en modo legado y recibe exactamente las versiones de extensiones de siempre (google-sync, meet, calendar y drive 1.x, con tokens por context.auth): anuncia al backend clientApi 3 y solo las capacidades que funcionan sin firmar. Al registrar la clave pasa sola a las versiones nuevas, sin tocar nada más.
- Genera el par en tu equipo:
node scripts/gen-domain-keypair.mjs(la clave privada no se genera ni viaja por el navegador). - Guarda la privada como variable de entorno secreta
BLOOMX_DOMAIN_PRIVATE_KEYde la instancia y vuelve a desplegar. - Registra la clave pública en el backend:
bloomx-admin security keys register(sesión de gestor, nivel 4 con reautenticación), la pantalla/admin/registeroPOST /api/manager/domain-key. - Comprueba con
bloomx-admin security signing(signed: true): el aviso del panel de Extensiones, IA y Cuentas vinculadas desaparece y las extensiones se pueden actualizar.
| Capacidad | Sin clave | Motivo |
|---|---|---|
ext.grants.v1 | No se anuncia | La instancia emite la executionGrant con su clave. |
oauth.provider.v1, oauth.broker.v1 | No se anuncian | El intermediario lo llama el backend con la concesión; GoogleLib exige ambas. |
ext.routes.v1, ext.routes.auth.v1 | No se anuncian | El borde firma _bx_src, nivel e IP en la URL. |
oauth.provider.v2 | No se anuncia | MicrosoftLib, ZoomLib y SlackLib: mismo intermediario firmado más las funciones del flujo que solo conoce este núcleo. |
oauth.provider.v3 | No se anuncia | DiscordLib 1.1.0: credencial de bot y rutas con dispatch, sobre el mismo intermediario firmado. |
ext.dependencies.v1 | Se anuncia | Lógica del backend independiente de la firma. |
ai.*, services.host.v1, lifecycle.events.v1, settings.schema.v1, UI | Se anuncian | Ya las anunciaba la versión desplegada; sin clave degradan (camino heredado) en lugar de romper. Cambiarlas alteraría las versiones que hoy reciben. |
Concesiones de ejecución (sin clave global del backend)#
El backend compartido no tiene ninguna clave propia ni firma nada. Cuando una instancia le pide ejecutar una extensión, hook o ruta, adjunta una executionGrant: un mensaje Ed25519 firmado con la clave de dominio de la instancia (BLOOMX_DOMAIN_PRIVATE_KEY, la misma que ya firma sus peticiones). Lleva dominio (iss/aud), extensión y versión, usuario (sub, null en rutas públicas), la lista cerrada de permisos de esa ejecución, jti, iat/exp (120 s por defecto, BLOOMX_GRANT_TTL_SECONDS), nonce y tope de usos (BLOOMX_GRANT_MAX_USES).
- El backend solo la reenvía: comprueba que corresponde a la ejecución (dominio autenticado de la petición, extensión, versión, usuario, no caducada) y la presenta tal cual a
/api/internal/host/*. El sandbox nunca la ve. - La instancia la verifica sola, con la clave pública derivada de su privada y sin red: firma,
exp,aud, extensión y usuario del cuerpo, servicio dentro de los permisos y tope de usos porjti(la misma ejecución hace N llamadas hastaexp). Una extensión no puede ampliar permisos ni cambiar de usuario o dominio porque rompería la firma. - Versionado: capacidad
ext.grants.v1(clientApi 6). Si la instancia la anuncia y no hay concesión válida, el backend NO ofrece servicios del host (nunca cae a una clave global); una concesión mal formada o de otro dominio, extensión, versión o usuario se rechaza conINVALID_GRANT. - Legado deprecado: instancias y backends antiguos que usan la firma del backend (
BACKEND_SIGNING_PRIVATE_KEY,BLOOMX_BACKEND_PUBLIC_KEY,/.well-known/bloomx-backend-key.json) siguen funcionando durante la transición; la instancia lo avisa en el log y en/admin. Para apagarlo:BLOOMX_ACCEPT_BACKEND_SIGNATURE=false. Se retirará en una versión futura. - Migración de credenciales: sin entrega automática.
migrate-googlelibsolo informa de los nombres de las credenciales; guárdalas en cada instancia conoauth secret set.
Reglas de seguridad adicionales#
- Tokens como permiso:
context.authygetToken()solo llegan a extensiones cuyo manifest declaraOAUTH_READo a una lista cerrada de versiones antiguas (google-meet, calendar, google-drive, google-sync, zoom y hubspot, hasta su última versión legada). Cualquier otra extensión no los recibe. Está deprecado: usaOAUTH_ACCOUNT:*. - Ids reservados:
google,microsoft,zoom,slack,githuby similares solo los registra su extensión oficial (core-googlelib,core-microsoftlib,core-zoomlib,core-slacklib...); ni siquiera una oficial puede registrar el id de otra. Se valida al publicar y de nuevo en la instancia. - Rutas de acciones: un parámetro de ruta nunca puede ser
.ni..y la ruta final tras normalizar la URL debe ser exactamente la construida; un id con%2e%2e,/o?no sale del servidor. - Límites por acción: cuota por minuto y usuario/extensión/proveedor (120 lecturas, 30 escrituras) y, si la acción lo declara,
quotaPerHour(tope duro por hora fijado por el manifest, no por la extensión).allowedFromSettinglimita un parámetro a una lista que fija el admin (p. ej. canales de Slack). - Cuenta atada a su proveedor: cada cuenta guarda el hash de los endpoints del proveedor que la emitió; si otro proveedor usara el mismo id, sus tokens no se envían ni se refrescan.
- Aprobación de proveedores no integrados: quedan en
pending_approvalhasta que un administrador (nivel 3 + step-up) aprueba sus hosts (POST /api/admin/oauth/providers/<id>/approveooauth approve). El inicio de sesión solo funciona con el Google oficial y exigeemail_verifiedexplícito. - Aprobaciones al instalar/actualizar: cada ruta pública (
PUBLIC_ROUTE:<MÉTODO> <ruta>oPUBLIC_ROUTE:PAGE <ruta>),OAUTH_SHARED:*yOAUTH_ACCOUNT:*de grupos de riesgo alto (gmail, drive...) se aprueban una a una; una versión nueva que añade alguna queda pendiente. - Cuentas compartidas: el organizador y la cuenta de servicio solo conceden los scopes de una lista fija (Google: calendario y Meet).
- Transporte: la conexión al proveedor fija la IP validada (sin ventana de DNS rebinding) e incluye 6to4, NAT64 e IPv6 compatible como direcciones privadas.
NEXT_PUBLIC_BACKEND_URLdebe ser https en producción y es FIJA por instancia: el registro de proveedores no sigue redirecciones, se valida por esquema estricto y los proveedores no integrados exigen la aprobación del administrador. No existe ninguna clave global del backend. - Patrones: los
patternde acciones y rutas se validan contra ReDoS al publicar y se ejecutan con control de errores. - HMAC:
timestampHeaderes obligatorio salvoallowReplay: true; sin timestamp el detector de repeticiones es por proceso y no es una defensa fuerte en serverless. - GET con efectos: una ruta
session/adminconsideEffects: truerechaza peticionesSec-Fetch-Site: cross-site. - Páginas `admin` en el servidor: sin el nivel, el navegador no recibe el árbol de componentes de esas páginas y las acciones que invocan (y su handler) responden 403. El
stateinicial solo se oculta si la extensión no conserva otros mounts con componente: no pongas datos sensibles enstate. - Sin tabla `OAuthFlow` en producción el flujo falla cerrado en lugar de degradar a memoria.
Cumplimiento (verificado por código y pruebas)#
Mapeo hecho leyendo el código y ejecutando las pruebas; no es una certificación. Las pruebas usan un servidor OAuth falso local; no se ha probado contra Google real (requiere credenciales).
| Referencia | Control | Estado y verificación |
|---|---|---|
| RFC 6749 | Flujo de código, state, refresh | CUMPLE: pruebas de flujo, state de un solo uso y rotación. |
| RFC 7636 | PKCE | CUMPLE: solo S256; prueba de verificador. |
| RFC 9700 (BCP) | Sin implícito, redirect exacto, rotación de refresh | CUMPLE: redirect fijo por proveedor, invalid_grant marca reconectar. |
| RFC 9207 | Parámetro iss | CUMPLE: se comprueba si el proveedor lo envía. |
| RFC 8252 | Apps nativas | N/A: flujo web en navegador; sin esquemas personalizados. |
| RFC 7009 | Revocación | CUMPLE: al desvincular, si el proveedor la ofrece. |
| OIDC Core | id_token, nonce, JWKS | CUMPLE: firma, iss, aud, exp y caché JWKS con gracia. |
| OWASP ASVS V3/V4/V8/V13 | Sesión, control de acceso, datos, API | PARCIAL: pruebas por punto de seguridad; sin auditoría externa. |
| OWASP API Top 10 | BOLA, auth rota, SSRF, recursos | CUMPLE en las rutas nuevas: validación de entrada, límites y SSRF probados. |
| NIST SP 800-53 / 800-63B | IA-5, SC-8, SC-12, SC-28 | PARCIAL: cifrado AES-256-GCM y TLS; sin KMS (ver Mapeo CIS / NIST / ISO). |
| ISO/IEC 27001 A.8.5, A.8.24 | Autenticación segura y criptografía | PARCIAL: igual que arriba. |
Proveedores incluidos: MicrosoftLib, ZoomLib y SlackLib#
Los tres siguen el patrón de GoogleLib: un manifest con oauthProviders, un server.js solo de estado (sin red ni secretos) y extensiones dependientes con OAUTH_ACCOUNT:<proveedor>:<grupo>. Requieren oauth.provider.v2 (clientApi 9) y clave de dominio en la instancia. Los tokens nunca llegan a una extensión.
| Proveedor (extensión) | Flujo y particularidades | Scopes | Acciones y consumidoras |
|---|---|---|---|
microsoft (core-microsoftlib) | OAuth2/OIDC Microsoft identity platform v2, PKCE S256. Tenant common, organizations, consumers o GUID en el ajuste MICROSOFT_TENANT (variable {tenant}). El iss del id_token es https://login.microsoftonline.com/{tid}/v2.0: se valida la firma con el JWKS y se compara con el tid del propio token (con un tenant GUID fijado, solo ese tenant). Hosts: login.microsoftonline.com y graph.microsoft.com. | 17 en 7 grupos (userinfo, calendar, meetings, contacts, mail, files, directory). Riesgo alto: Calendars.ReadWrite, Contacts.ReadWrite, Mail.*, Files.ReadWrite. Aprobación del admin: grupos mail, files y directory. Por defecto: openid, profile, email, offline_access, User.Read, Calendars.ReadWrite, OnlineMeetings.ReadWrite. | 30 acciones sobre Graph v1.0: perfil, calendarios y eventos (list/create/update/delete, vista, disponibilidad), reunión de Teams (meetings.onlineMeetings.*), contactos, correo (lectura y envío), archivos y directorio. Consumidora: microsoft-teams (selector de ubicación del evento). |
zoom (core-zoomlib) | OAuth por usuario con PKCE y client_secret_basic (cabecera Basic), revocación por access token y modo Server-to-Server (account_credentials) con la app S2S propia (ajustes ZOOM_ACCOUNT_ID, ZOOM_S2S_CLIENT_ID, secreto ZOOM_S2S_CLIENT_SECRET). Hosts: zoom.us y api.zoom.us. | 7 granulares en 2 grupos (meetings, userinfo): meeting:write/update/delete/read, meeting:read:list_meetings, user:read:user, user:read:email. Por defecto los cinco primeros. | 6 acciones: meetings.create/update/delete/get/list y users.me.get. Consumidora: zoom 2.0.0 (contrato de conferencing intacto). Los clientes antiguos y las instancias sin clave siguen recibiendo zoom 1.4.1. |
slack (core-slacklib) | OAuth v2 de Slack (tokenFormat: slack-v2): token de BOT y de USUARIO como dos cuentas independientes, scopes de usuario con prefijo user:, errores {ok:false}, rotación opcional y revocación con auth.revoke. Sin PKCE (Slack no lo usa): secreto obligatorio. Host: slack.com. | 9 en 3 grupos (chat, channels, users): chat:write, chat:write.public, channels:read, groups:read, users:read, users:read.email y user:chat:write, user:channels:read, user:users:read. Por defecto: chat:write, channels:read, users:read. | 7 acciones: chat.postMessage (solo a los canales de SLACK_ALLOWED_CHANNELS, tope duro de 120/hora), conversations.list/listPrivate/info, users.list/info y auth.test; ninguna lee mensajes. Consumidora: slack-notify (avisos de correo con filtros, sin cuerpos por defecto). |
{
"id": "core-microsoftlib", "version": "1.0.0",
"requires": { "clientApi": 9, "capabilities": ["oauth.provider.v1", "oauth.provider.v2", "oauth.broker.v1", "settings.schema.v1"] },
"oauthProviders": [{
"id": "microsoft", "displayName": "Microsoft",
"authorizeUrl": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
"tokenUrl": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
"jwksUri": "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
"issuer": "https://login.microsoftonline.com/{claim:tid}/v2.0",
"userinfoUrl": "https://graph.microsoft.com/oidc/userinfo", "apiBase": "https://graph.microsoft.com",
"allowedHosts": ["login.microsoftonline.com", "graph.microsoft.com"],
"variables": { "tenant": { "setting": "MICROSOFT_TENANT", "default": "common", "pattern": "^(common|organizations|consumers|[0-9a-fA-F-]{36})$" } },
"pkce": true, "clientIdSetting": "MICROSOFT_CLIENT_ID", "clientSecretCredential": "MICROSOFT_CLIENT_SECRET",
"actions": [{ "id": "calendar.events.list", "group": "calendar", "method": "GET", "path": "/v1.0/me/events", "requiresScopes": ["Calendars.Read"],
"params": { "top": { "in": "query", "type": "integer", "min": 1, "max": 100, "queryName": "$top" } } }]
}]
}{
"id": "core-zoomlib", "version": "1.0.0",
"oauthProviders": [{
"id": "zoom", "displayName": "Zoom",
"authorizeUrl": "https://zoom.us/oauth/authorize", "tokenUrl": "https://zoom.us/oauth/token", "revokeUrl": "https://zoom.us/oauth/revoke",
"userinfoUrl": "https://api.zoom.us/v2/users/me", "apiBase": "https://api.zoom.us", "allowedHosts": ["zoom.us", "api.zoom.us"],
"pkce": true, "tokenAuth": "basic", "revokeToken": "access",
"serviceCredentials": { "grant": "account_credentials", "accountIdSetting": "ZOOM_ACCOUNT_ID", "clientIdSetting": "ZOOM_S2S_CLIENT_ID", "clientSecretCredential": "ZOOM_S2S_CLIENT_SECRET" },
"clientIdSetting": "ZOOM_CLIENT_ID", "clientSecretCredential": "ZOOM_CLIENT_SECRET"
}]
}
// core-zoom 2.0.0 (conferencing): "requires": { "extensions": { "core-zoomlib": "^1.0.0" } }, "permissions": ["OAUTH_ACCOUNT:zoom:meetings", "OAUTH_SHARED:zoom"]
await ctx.libs.zoom.meetings.create({ userId: "me", meeting: { topic: "Demo", type: 2 } }); // cuenta del usuario
await ctx.libs.zoom.meetings.create({ userId: "host@acme.com", meeting: { topic: "Demo" } }, { principal: "service" }); // Server-to-Server{
"id": "core-slacklib", "version": "1.0.0",
"oauthProviders": [{
"id": "slack", "displayName": "Slack", "tokenFormat": "slack-v2", "pkce": false,
"authorizeUrl": "https://slack.com/oauth/v2/authorize", "tokenUrl": "https://slack.com/api/oauth.v2.access", "revokeUrl": "https://slack.com/api/auth.revoke",
"apiBase": "https://slack.com", "allowedHosts": ["slack.com"],
"scopes": [{ "id": "chat:write", "group": "chat", "es": "Publicar mensajes", "en": "Post messages", "risk": "medium" },
{ "id": "user:chat:write", "group": "chat", "es": "Publicar como tú", "en": "Post as you", "risk": "medium" }],
"actions": [{ "id": "chat.postMessage", "group": "chat", "method": "POST", "path": "/api/chat.postMessage", "write": true, "quotaPerHour": 120, "requiresScopes": ["chat:write"],
"params": { "channel": { "in": "body", "type": "string", "required": true, "pattern": "^[CG][A-Z0-9]{2,20}$", "allowedFromSetting": "SLACK_ALLOWED_CHANNELS" },
"text": { "in": "body", "type": "string", "maxLength": 3000 } } }]
}]
}
// slack-notify: "permissions": ["OAUTH_ACCOUNT:slack:chat"]
await ctx.libs.slack.chat.postMessage({ channel: ctx.settings.channel, text: "Nuevo correo de Ana" });Funciones v3 (oauth.provider.v3): credencial de bot y rutas con dispatch#
DiscordLib 1.1.0 añade, bajo la capacidad oauth.provider.v3 (clientApi 12), solo campos nuevos: una credencial de bot del proveedor (botCredential, token write-only que solo guarda el núcleo), acciones con credential: "bot" (cabecera Authorization: Bot inyectada por el núcleo, nunca devuelta), moderation (acciones apagadas salvo ajuste del admin), guild (restricción a servidores permitidos), pathSettings y rutas con dispatch (tras verificar una firma hmac Ed25519 el router entrega el evento a los hooks de otras extensiones). Las instancias sin la capacidad siguen recibiendo la versión anterior. Detalle y ejemplos en DiscordLib: bot de Discord.
Funciones v2 (oauth.provider.v2)#
| Necesidad | Campo | Quién lo usa |
|---|---|---|
| Tenant o región en la URL | variables ({nombre} solo en la ruta; el valor sale de un ajuste, validado por pattern y por un juego de caracteres fijo del núcleo que impide salir del segmento) | MicrosoftLib |
| Emisor OIDC que depende del token | issuer con {claim:tid} (comparación exacta tras verificar la firma; el parámetro iss de la respuesta también se acota) | MicrosoftLib |
| Cliente con cabecera Basic | tokenAuth: "basic" (ni el id ni el secreto van en el cuerpo) | ZoomLib |
| Revocar por access token | revokeToken: "access" (se refresca antes si caducó) | ZoomLib |
| Servidor a servidor | serviceCredentials + principal: "service" + permiso OAUTH_SHARED (el token S2S lo canjea y cachea el núcleo; los scopes :admin se normalizan) | ZoomLib |
| Respuesta de token no estándar | tokenFormat: "slack-v2" | SlackLib |
Query con $ | queryName en el parámetro (top → $top) | MicrosoftLib |
| Valores que fija el admin | allowedFromSetting (lista vacía = nada permitido; lo aplica el núcleo) | SlackLib |
| Tope duro por hora | quotaPerHour en la acción | SlackLib |
Desvincular y revocar, por proveedor#
| Proveedor | Qué hace el núcleo al desvincular | Qué debe hacer además el usuario o el admin |
|---|---|---|
| Revoca el refresh token (RFC 7009) y borra las filas. | Nada. | |
| Microsoft | No existe endpoint estándar de revocación para aplicaciones: el núcleo borra los tokens locales (resultado not_supported). No se usa revokeSignInSessions porque invalidaría las sesiones del usuario en TODAS sus aplicaciones. | El usuario puede retirar el consentimiento en myaccount.microsoft.com → Aplicaciones (cuentas personales: account.live.com/consent/Manage); el admin del tenant, en Entra ID → Aplicaciones empresariales → Permisos, o eliminar la aplicación. |
| Zoom | Revoca el access token con Basic (refresca antes si caducó) y borra la cuenta; un fallo de Zoom no impide desvincular. | Nada (el usuario también puede quitar la app en Zoom Marketplace → Manage → Added Apps). |
| Slack | Revoca con auth.revoke el token de usuario; el token de bot solo si ningún otro usuario del espacio conserva una copia (resultado shared), para no dejar sin bot a los demás. Borra bot y usuario. | Para quitar la app del espacio: administración de Slack → Apps. |
Plantilla y generador#
bloomx-extensions/_oauth-provider-template/ es un ejemplo documentado y probado ("Acme"): manifest.src.mjs comentado, server.js de estado, README paso a paso y pruebas. Como _template, empieza por _ y su JSON se llama manifest.template.json: no es publicable y los sincronizadores, la validación y el control de versiones la ignoran. npm run new:oauth-provider -- <id> crea <id>lib/ con los datos ficticios sustituidos (los hosts quedan como <id>-todo-replace.com para que no se olviden) y la prueba de flujo contra un servidor OAuth falso. Un id reservado exige --core y solo vale para su extensión oficial.
cd bloomx-extensions
npm run new:oauth-provider -- mi-crm # crea mi-crmlib/ y la prueba de flujo del frontend
npm run new:oauth-provider -- github --core # extension OFICIAL de un id reservado (core-githublib)
node --experimental-strip-types _shared/sdk/build-manifest.mjs mi-crmlib/manifest.src.mjs
node --experimental-strip-types _shared/validate.mjs mi-crmlib
node --experimental-strip-types --test mi-crmlib/tests/*.test.mjs
cd ../bloomx && npx vitest run src/lib/oauth/__tests__/mi-crm.flow.test.tsProbar con credenciales reales#
Las pruebas automáticas usan servidores OAuth falsos y manifests reales; no se han ejecutado contra Microsoft, Zoom ni Slack reales (requiere credenciales). Para comprobarlo en una instancia con clave de dominio:
- Microsoft: en Entra ID registra una aplicación web con redirect
https://<instancia>/api/oauth/microsoft/callback, crea un secreto y concede los permisos delegados del catálogo. GuardaMICROSOFT_CLIENT_ID,MICROSOFT_TENANT(organizationso el GUID de tu tenant;commonadmite cuentas personales, pero las reuniones de Teams exigen cuenta de trabajo o escuela) y el secreto en los ajustes de MicrosoftLib. - Zoom: crea una app *General* (OAuth de usuario) con redirect
https://<instancia>/api/oauth/zoom/callbacky los scopes granulares; guardaZOOM_CLIENT_IDyZOOM_CLIENT_SECRET. Para S2S crea otra app *Server-to-Server OAuth* y guardaZOOM_ACCOUNT_ID,ZOOM_S2S_CLIENT_ID,ZOOM_S2S_CLIENT_SECRETyZOOM_HOST_EMAIL. - Slack: crea la app en api.slack.com/apps con redirect
https://<instancia>/api/oauth/slack/callback, scopes de botchat:write,channels:read,users:read; guardaSLACK_CLIENT_ID,SLACK_CLIENT_SECRETy los ids de canal enSLACK_ALLOWED_CHANNELS, e invita al bot al canal (/invite @app). - Como administrador, aprueba los hosts del proveedor (
/admin/extensionsooauth approve <id>) y guarda el secreto (oauth secret set); como usuario, vincula la cuenta en Ajustes → Integraciones o con/api/oauth/<id>/starty revisa Cuentas vinculadas en/admin/accounts. - Prueba: crear una reunión de Teams o Zoom desde el selector de ubicación de un evento, o activar
slack-notifyy pulsar "Enviar prueba". Nunca compartas los secretos: se guardan cifrados en la instancia y no los ve ninguna extensión.
Migrar una instancia#
- Simulacro:
node --experimental-strip-types scripts/migrate-googlelib.mjs(backend). - Aplicar: añade
--apply. Copia los ajustes no secretos; las extensiones de origen no se tocan. - Credenciales: el script NO mueve secretos. Guárdalas en cada instancia con
oauth secret set(cifradas con su clave). - Revertir:
--rollback(desactiva) o--rollback --purge. Las cuentas vinculadas y los tokens no se tocan.