En Rockfeller, cada sistema nuevo nacía con su propia pantalla de login. El día en que nace el sistema, eso parece el camino más rápido. En los meses siguientes se convierte en mantenimiento, soporte y riesgo, multiplicados por el número de sistemas.
La Conta Rockfeller es mi respuesta a eso: un login único, al estilo de Google Accounts, que hoy autentica ocho sistemas internos. Voy a contar las decisiones que tomé por el camino, incluida una que tuve que deshacer. En SSO, escribir el flujo de login es la parte fácil. Lo que me llevó tiempo fue decidir dónde vive cada responsabilidad.
Cómo era antes
Mirando un sistema a la vez, todo estaba bien. Mirándolos todos juntos, cada uno repetía la misma pila de autenticación, escrita desde cero y con sus propios bugs: tabla de usuarios, hash de contraseña, "olvidé mi contraseña", correo de restablecimiento, invitación con contraseña inicial y algún script para crear el primer admin.
Quien trabaja en una escuela franquiciada usa varios de esos sistemas el mismo día. Eran varias contraseñas y varios lugares donde cambiar el nombre o la foto. Cuando alguien dejaba una escuela, había que quitarle el acceso sistema por sistema, y bastaba con olvidar uno para que la persona siguiera entrando.
La regla más importante del negocio, algo como "el coordinador de la escuela X ve a los alumnos de la escuela X", también existía en varias versiones, cada una con nombres y casos límite distintos. Para colmo, varios sistemas en el mismo dominio padre usaban cookies llamadas session, y a veces uno leía la cookie del vecino.
Los datos también envejecían. Si el nombre de alguien cambiaba o se desactivaba una cuenta, el sistema solo se enteraba en el siguiente login de la persona, si es que se enteraba. Y con la llegada de los servidores MCP, cada producto empezó a emitir API keys personales para conectar agentes de IA. Esas claves se filtran, no tienen segundo factor, no caducan e ignoran el permiso por escuela.
Nada de esto es exótico. Es el coste de no tener una identidad central, y crece con cada sistema nuevo.
Lo que quería
Antes de escribir código, anoté qué significaba "terminado". Una sola cuenta, con selector de cuentas como en Google. Cada sistema seguiría siendo dueño de su propia sesión: el SSO dice quién es la persona y el sistema decide qué hacer con eso. La autorización por escuela tendría una única fuente de verdad, y desactivar a alguien en un lugar valdría para todos. Integrar de la forma incorrecta debería costar más que integrar de la forma correcta. Y nada de protocolos inventados en casa.
Esa última exigencia ya elige el protocolo: OpenID Connect, sobre OAuth 2.0.
Decisión 1: OIDC en lugar de una cookie compartida
El atajo obvio sería una cookie de sesión en el dominio padre, leída por todos los sistemas. Funciona la primera semana. Después, cualquier sistema puede leer (y filtrar) la sesión de todos los demás, el logout se convierte en un problema de sincronización y un XSS en un subdominio olvidado compromete a toda la empresa.
La Conta Rockfeller es un OpenID Provider. La sesión central está en una cookie host-only, que solo ve el propio SSO. Cada sistema es un cliente OIDC con su propia cookie, y la conexión entre ellos ocurre a través del protocolo.
El flujo es Authorization Code con PKCE:
Hay tres reglas que valen para todos los clientes. El PKCE es obligatorio incluso para los confidenciales, que ya tienen un secreto: protege el code contra la interceptación y no cuesta nada. La redirect URI tiene que coincidir exactamente, sin comodines ni prefijos. Y no existe registro dinámico de clientes. Un admin da de alta cada sistema, con clientes separados para preproducción y producción, y cada uno recibe su propio aud, así que un token de preproducción nunca pasa en producción.
Por qué Better Auth
No quería escribir un servidor OAuth desde cero, ni operar un Keycloak con un equipo pequeño. Better Auth, con el plugin de OAuth provider, me dio el núcleo del protocolo (authorize, token, JWKS, discovery, consentimiento y end-session) dentro de una aplicación TypeScript que controlo. Las sesiones múltiples, el segundo factor, el código por correo y las passkeys vienen como plugins.
Lo que Better Auth no decide por mí es justamente lo que hace de este SSO el SSO de Rockfeller: la política de acceso, el modelo de roles, los webhooks y la rotación de secretos. Ahí se fue el tiempo.
Decisión 2: un solo origen
OIDC exige un issuer único y estable, y la sesión central tiene que vivir en un único host. El proyecto, sin embargo, tiene dos piezas: un portal en Next.js (login, consentimiento, "Mi cuenta" y el panel de admin) y una API en NestJS, donde corre Better Auth.
La salida fue servir ambas desde el mismo origen:
El portal reenvía las rutas de autenticación a la API, y la API no acepta tráfico que llegue por otro camino. El detalle que más trabajo dio fue que el proxy reenviara bien las cabeceras de host. Cuando se equivoca, el discovery anuncia un issuer distinto del que espera el cliente, y nada funciona.
Decisión 3: identidad y autorización van por separado
La decisión que más pesó en el proyecto fue definir lo que el token no lleva.
Personas con vínculos
No existe userType. Todo el mundo es un User. Alumno, profesor, secretaría, finanzas, director de la sede y franquiciado son roles dentro de vínculos con una organización, que puede ser la franquiciadora o una escuela.
Una misma persona puede ser profesora en una escuela y coordinadora en otra. Cada vínculo guarda también su origen: vino del sistema de gestión escolar, de una invitación o de una asignación de la franquiciadora. Eso importa al revocar. Revocar una invitación no puede borrar el vínculo que vino de la sincronización, y viceversa.
Evalué el plugin de organizaciones de Better Auth y lo descarté. Guarda un rol por miembro, no tiene tipo ni jerarquía de organización y trae endpoints para crear organizaciones, invitar y cambiar la organización activa, que habría tenido que desactivar uno por uno. Aquí nadie crea escuelas a mano: llegan del sistema de gestión escolar, que es la fuente de verdad de las sedes, por webhook y por una reconciliación nocturna.
Token ligero, autorización en el sistema
El access token es un JWT que lleva solo sub, aud y scope. La escuela activa y la lista de permisos quedan fuera.
La Conta Rockfeller guarda los vínculos y el catálogo (rol → permisos, por sistema). El sistema, que conoce el recurso, decide si la persona puede tocar ese alumno o ese grupo. Los permisos tienen namespace por sistema, y cada sistema solo ve el suyo.
Una operación solo pasa si se cumplen todas las condiciones de abajo:
// Denegar es lo predeterminado: cada línea es una condición necesaria.
const allowed =
account.active &&
app.accessGranted &&
membership.validFor(schoolId) &&
permissions.includes('students.read') &&
(await resourceBelongsTo(studentId, schoolId)) &&
authIsRecentEnough(permission, session.authTime);La escuela siempre viene explícita en la operación. Una "escuela activa" guardada en la sesión es estado implícito, y el estado implícito es el origen clásico de datos que se filtran de una escuela a otra.
El backend del sistema consulta los permisos con la credencial de su propio cliente (client_credentials) y guarda la respuesta en una caché corta. Algunos permisos son sensibles, como reembolsar un pago o asignar roles, y exigen un login reciente. El sistema compara esa exigencia con el auth_time del ID token y, si el login es antiguo, devuelve a la persona al SSO con max_age antes de ejecutar.
El catálogo de roles y permisos está en la base de datos y se edita desde el panel de admin. Cada instancia mantiene una copia en memoria y la descarta cuando cambia la versión en la caché compartida, así que una modificación llega a todas en segundos.
Tokens y sesiones
| Qué | Duración | Por qué |
|---|---|---|
| Access token | Corta | Se valida localmente con el JWKS; como no se puede revocar, tiene que caducar pronto |
| ID token | Corta | Solo sirve para montar la sesión local |
| Refresh token | Larga, rotativo | Cualquier reutilización se trata como replay y tumba la familia (RFC 9700) |
| Sesión central | Larga, renovada con el uso | Quien la usa a diario no tiene que volver a entrar |
Las claves de firma cambian cada cierto tiempo. La nueva aparece en el JWKS antes de empezar a firmar y la antigua sigue publicada durante un periodo de gracia, para que ningún sistema con el JWKS en caché rechace un token válido.
La barrera está en la emisión del token
El OAuth provider no tiene un hook de "denegar autorización" que vea el cliente. Por eso la política de acceso de cada sistema se aplica en dos puntos.
El primero es la emisión de cualquier token, sea por authorization_code o por refresh_token. Esa es la barrera real, y gracias a ella una cuenta desactivada o un vínculo revocado dejan de renovar tokens al instante. El segundo es un hook antes de /authorize, que solo mejora la experiencia: muestra una pantalla de "acceso denegado" en lugar de devolver a la persona al sistema con un error en crudo.
Cada sistema es open (entra cualquier cuenta activa y verificada) o restricted (exige un rol que dé acceso a él), y además puede aceptar solo correos corporativos. El SSO nunca emite tokens para un correo no verificado, lo que permite a los sistemas vincular cuentas por correo de forma segura.
Perfil en el ID token
Por defecto, el provider solo entrega nombre y correo en /userinfo. Puse esos claims en el propio ID token, según los scopes concedidos. El sistema monta la sesión local sin un viaje más por la red, y el perfil llega firmado y atado al nonce.
Logout global
Salir de un sistema llama al end-session, que cierra la sesión central. Después, el SSO envía un back-channel logout (un JWT firmado, de servidor a servidor) a cada sistema en el que la persona tenía sesión, y cada uno tumba la suya.
Cuando un admin desactiva una cuenta, el camino es el mismo, con un paso más: también se revocan los refresh tokens de acceso offline, que el logout estándar dejaría intactos.
El back-channel es un solo intento. Cuando la sesión termina por otro motivo (caducó o se cerró en "Mi cuenta"), el sistema recibe un evento session.ended a través de la infraestructura de webhooks, que es el tema de la siguiente sección.
Webhooks con outbox
El logout resuelve las sesiones, pero los datos viejos siguen ahí. Para ellos, la Conta Rockfeller avisa a los sistemas mediante webhooks: user.updated, user.disabled, user.deleted, membership.granted, membership.revoked, app.access_revoked y session.ended.
Lo difícil es garantizar dos cosas a la vez: no avisar nunca de algo que no ocurrió y no dejar nunca de avisar de algo que ocurrió. Escribir en la base de datos y publicar en la cola son dos operaciones, y si la API cae entre ambas el aviso miente. La respuesta clásica es el transactional outbox:
El evento y una entrega por destinatario se escriben en la misma transacción que el cambio, y nada va a la cola antes del commit. Un worker reserva las entregas pendientes con FOR UPDATE SKIP LOCKED y las encola con un jobId determinista, así que una caída a mitad de camino no duplica nada.
La entrega es at-least-once, con el formato Standard Webhooks, y va firmada con HMAC. El webhook-id es el id del evento y se repite en todos los intentos, así que el sistema deduplica por él. Como no hay garantía de orden, el updatedAt del usuario funciona como versión. Los reintentos usan backoff con jitter, y un endpoint que falla durante demasiado tiempo se desactiva y los admins reciben un correo.
El detalle que más me gusta es el de user.updated. Se calcula como la diferencia respecto a la última instantánea enviada a ese sistema y se filtra por los scopes que recibió el sistema. Un sistema sin el scope de correo nunca se entera de un cambio de correo, y si no cambió nada visible, el evento no sale.
Como el SSO hace peticiones a URLs registradas por otros equipos, es candidato natural a SSRF. La entrega de webhooks se escribió teniendo eso en cuenta, y los secretos de firma se guardan cifrados en la base de datos.
Seguridad
Buena parte de lo que hay es lo esperable: rate limit global y por ruta, captcha en las pantallas de autenticación, CSP con nonce por petición, HSTS, frame-ancestors 'none' y comprobación de la cabecera Origin en las mutaciones autenticadas por cookie. Algunas decisiones merecen explicación.
Se rechazan las contraseñas que ya aparecieron en filtraciones. La comprobación usa Pwned Passwords mediante k-anonymity, así que la contraseña en sí nunca sale del servidor. Los correos desechables también quedan fuera del registro.
Una passkey con verificación de usuario ya cuenta como dos factores, porque junta la posesión del dispositivo con la biometría o el PIN. Para quien lo prefiera, existe TOTP. El segundo factor es obligatorio para los admins, y el panel de admin solo se abre con él verificado en la sesión actual.
Cambiar el correo, gestionar las passkeys o exportar los datos exige un login reciente. Entrar desde un dispositivo nuevo dispara un correo de aviso, que es barato de hacer y es de las alertas más útiles para quien cayó en un phishing.
La auditoría solo acepta inserciones. El usuario de base de datos de la aplicación no tiene permiso para modificar ni borrar esos registros, así que ni un bug ni alguien que tome el control de la aplicación puede borrar rastros.
Cambiar secretos sin echar a nadie dio más trabajo del que esperaba. Al principio, cambiar el secreto principal de Better Auth cerraba la sesión de todo el mundo y dejaba ilegible todo lo que se había cifrado con él. Hoy los secretos tienen versión: el más nuevo firma y cifra, los antiguos solo abren lo que ya existía, y lo que estaba en la versión antigua se migra poco a poco. Cambiar un secreto pasó a ser una tarea de mantenimiento más.
La LGPD (la ley brasileña de protección de datos) estuvo presente desde el primer día. Eliminar la cuenta anonimiza los datos tras un plazo de desistimiento, y un sub nunca se reutiliza. La persona puede exportar sus propios datos, y las sesiones, códigos, tokens y registros de auditoría caducados se borran mediante una rutina de retención.
El SDK que dije que no haría
Una de las primeras decisiones que registré fue "no habrá SDK propio". Cada sistema usaría una biblioteca OIDC genérica y seguiría una guía de integración. Parecía lo más honesto: estándar abierto, sin lock-in y sin paquete que mantener.
Duró poco. En la práctica, cada sistema copiaba de la documentación una configuración con una docena de opciones, más la ruta de back-channel, el token del propio sistema y la caché de permisos. Y es al copiar donde aparecen los errores: un scope sin offline_access, un override de perfil olvidado, un back-channel que no comprobaba el nonce. Uno de los sistemas llegó a integrarse primero con el proveedor OAuth genérico y lo rehízo todo con el plugin poco después. Ahí quedó claro que la guía no bastaba.
Deshice la decisión y escribí un plugin para Better Auth. La integración quedó así:
import { betterAuth } from 'better-auth';
import { sso } from './lib/sso';
export const auth = betterAuth({
plugins: [
sso({
clientId: process.env.SSO_CLIENT_ID,
clientSecret: process.env.SSO_CLIENT_SECRET,
}),
],
});El plugin concentra las decisiones que antes cada sistema tomaba por su cuenta. Las direcciones del SSO se derivan del issuer, sin discovery en el arranque, así que el sistema arranca aunque el SSO esté caído. El perfil sale del ID token, sin pasar por /userinfo. El login local queda bloqueado por defecto, con una salida explícita para quien necesite mantener otro público con login propio.
También renueva el token de cada cuenta de uno en uno. Con refresh rotativo y detección de reutilización, dos pestañas renovando a la vez tumbarían la familia de tokens. Los permisos quedan en una caché corta, que se invalida de golpe cuando llega un back-channel logout. Y signOut() devuelve la URL de end-session sin redirigir, para que el sistema elija entre cerrar solo su sesión o cerrar también el SSO.
Para APIs hay dos puntos de entrada separados: un verificador de tokens que no depende de Better Auth y un módulo de NestJS que protege todas las rutas por defecto.
@Controller('schools/:schoolId/students')
export class StudentsController {
@Get()
@RequirePermission('students.read')
list(@Param('schoolId') schoolId: string) {
// Solo llega aquí con un token válido, vínculo con la escuela y el permiso.
return this.students.listBySchool(schoolId);
}
}"Sin SDK" sigue siendo una buena postura de principios. Como postura operativa, con varios equipos integrando con prisa, no se sostuvo. El estándar sigue abierto y la guía para integrar sin el plugin sigue ahí. El plugin solo les quita a los equipos las decisiones que no deberían estar tomando.
Agentes de IA
La especificación de autorización de MCP sigue OAuth 2.1: el servidor MCP es un recurso protegido y el login ocurre en un servidor de autorización, papel que la Conta Rockfeller ya cumplía.
El problema es que agentes como Claude, ChatGPT o Cursor no son clientes dados de alta por un admin. Para ellos, el SSO acepta Client ID Metadata Documents: el agente se identifica con una URL que publica sus metadatos, y el SSO crea el cliente la primera vez que la ve. El registro dinámico sigue desactivado, y solo se aceptan agentes de dominios aprobados.
Todo cliente de agente nace con consentimiento obligatorio (la pantalla deja claro que es un agente de IA y qué dominio lo identifica) y solo con scopes de identidad. Los scopes de gestión se rechazan. Lo que el agente puede hacer en cada servidor MCP depende de los permisos de la persona en esa escuela, que el servidor MCP consulta con su propia credencial.
Las API keys personales salieron de escena. En su lugar quedaron tokens de corta duración, que la persona revoca en "Mi cuenta" y que respetan el segundo factor y el logout global.
La adopción
Hoy, ocho sistemas internos se autentican con la Conta Rockfeller, y la migración siguió el mismo guion en todos.
En el primer login, la cuenta local se vincula por el correo verificado; a partir de ahí, por el sub. Después viene la parte más satisfactoria, que es borrar código: restablecimiento y cambio de contraseña, invitación con contraseña, creación del primer admin, DTOs de login y JWT hecho a mano. En más de un sistema la migración eliminó más líneas de las que añadió, y donde había contraseñas almacenadas se borraron de la base de datos. Cada sistema recibió también un prefijo propio en la cookie, para no volver a leer nunca la sesión de un vecino, y un endpoint que comprueba la firma de los webhooks e ignora los eventos repetidos.
El generador interno de proyectos ya ofrece la integración como opción, y el design system tiene el botón "Entrar con la Conta Rockfeller". Un sistema nuevo ya nace integrado, así que el camino correcto pasó a ser también el más corto.
Lo que me llevo
Mantener el access token ligero y dejar la autorización del recurso en manos del sistema me libró de la trampa más común del SSO corporativo, que es el token inflado, lleno de permisos que quedan desactualizados al minuto siguiente. Poner la política de acceso en la emisión de cada token, incluido el refresh, protege más que cualquier pantalla de "acceso denegado".
Registrar cada decisión en un ADR corto, con contexto y consecuencias, ayudó más de lo que imaginaba. Cuando cayó el "sin SDK", estaba escrito por qué existía y por qué dejó de tener sentido, y nadie tuvo que adivinar.
La documentación sola no escala entre equipos con prisa. Lo que escaló fue un plugin con valores seguros por defecto, un botón en el design system y una opción en el generador de proyectos.
Por último, la parte operativa también es producto. Cambiar secretos sin echar a nadie, publicar las claves antes de usarlas y borrar los datos caducados no aparecen en ninguna demo, pero son lo que permite operar el SSO en el día a día.
Si en una empresa cada sistema nuevo todavía nace con su propia pantalla de login, conviene centralizar la identidad antes de que aparezca el siguiente.