Autenticación
Kevin CMS autentica con better-auth sobre tu base de datos D1. En esta versión hay email y contraseña, con la dirección verificada por correo y restablecimiento de contraseña. El panel trae las pantallas de entrada y de recuperar la contraseña; los proveedores sociales llegan después.
Además de la cookie, una petición puede autenticarse con un token Bearer o con una clave de API (para un script, un servidor o una app que no es un navegador). Una cuenta suspendida no autentica por ninguna vía. Ver Bearer y claves de API y Cuentas suspendidas y administración de cuentas.
Prepáralo
Sección titulada «Prepáralo»-
nodejs_compaten tuwrangler.jsonc. better-auth usanode:cryptoy sin esa marca el sitio no arranca:wrangler.jsonc { "compatibility_flags": ["nodejs_compat"] } -
Un secreto con el que firmar las sesiones. En local va en
.dev.vars, que está en tu.gitignore:Ventana de terminal pnpm cms secret:setLo genera con
randomBytes(32)y escribeBETTER_AUTH_SECRETen.dev.varssin pisar nada de lo que ese fichero ya tenga. El valor no se imprime: lo que dice el comando es dónde quedó. Si la clave ya está puesta no la pisa; para rotarla,--force. -
En producción, como secreto del Worker:
Ventana de terminal pnpm cms secret:set --remoteViaja por la entrada estándar, así que no queda en la tabla de procesos ni en el historial del shell. Rotarlo invalida todas las sesiones abiertas.
-
Genera y aplica la migración con las tablas de sesión:
Ventana de terminal npx cms db:migratenpx cms db:apply
No hay ninguna opción que configurar en cms.config.ts: si el secreto está, la autenticación funciona.
La URL base
Sección titulada «La URL base»Por defecto, better-auth deduce el origen de cada petición, que es lo que quiere un Worker: funciona sin configurar nada y sigue funcionando en cualquier dominio de vista previa.
Si tu sitio vive detrás de un proxy y el Host que llega no es el público, pon la URL real en el
siteUrl de los ajustes y pasará a ser la URL base. De ella salen los enlaces de
verificación y de restablecimiento. El correo de restablecimiento lleva a /admin/reset/:token bajo esa
misma URL (o bajo el origen de la petición si no hay siteUrl), no a un endpoint de better-auth.
Las tablas
Sección titulada «Las tablas»El codegen emite cinco tablas junto a las de tus colecciones, en el mismo .cms/schema.ts y en la misma
migración, para que no haya dos sistemas de migraciones conviviendo.
| Tabla | Qué guarda | ¿Es una colección? |
|---|---|---|
users |
Las cuentas | Sí |
session |
Las sesiones abiertas | No |
account |
Las credenciales, incluido el hash de la contraseña | No |
verification |
Los tokens de un solo uso | No |
apikey |
Las claves de API, con la clave guardada con hash | No |
Las cuatro que no son colecciones son fontanería: no aparecen en locals.cms.collections, ni en el admin, ni
en la API REST. Un GET /api/cms/session responde 404, igual que cualquier slug que no existe.
Al actualizar desde una versión sin Bearer, claves ni suspensión de cuentas, cms db:migrate genera una
migración solo aditiva: CREATE TABLE apikey (con sus tres índices), ALTER TABLE users ADD para
banned, banReason y banExpires, y ALTER TABLE session ADD impersonatedBy. Las tablas users y
session no se recrean y ninguna fila existente cambia: las columnas nuevas nacen en NULL. Léela y
aplícala como cualquier migración: Schema y migraciones.
impersonatedBy la pide el plugin de administración aunque la suplantación esté cerrada; queda siempre en
NULL.
La colección users
Sección titulada «La colección users»La colección users del CMS es la tabla de usuarios de better-auth. Es un solo sitio: se gestionan
desde el admin como cualquier otra colección y a la vez son el sujeto de la sesión.
Si no la declaras, se añade sola. Si la declaras, se fusiona: tus campos se conservan y se añaden los que la autenticación necesita.
defineCollection({ slug: 'users', fields: [ { name: 'bio', type: 'textarea' }, { name: 'twitter', type: 'text' }, ],})Esa colección acaba con tus dos campos y con email, name, emailVerified, image, role y los
tres de suspensión (banned, banReason, banExpires). El
email acaba siempre required y unique aunque no lo declares: sin el índice único, dos cuentas podrían
compartir dirección.
role es el rol de la cuenta, un text sin valor por defecto que llega a user.role en la
sesión. La primera cuenta recibe el que nombres en admin.firstUserRole; las demás, NULL. Nadie se lo
cambia a sí mismo, ni por la API ni por el panel. Cómo se usa en una política, en
Control de acceso.
banned, banReason y banExpires los escribe el plugin de administración al suspender una cuenta
(ver Cuentas suspendidas) y están protegidos igual que role: un PATCH a
/api/cms/users/:id que los mande responde 400, también a un administrador; solo el sistema (SQL, o
overrideAccess: true) las escribe. Una cuenta suspendida no autentica por ninguna vía: ni cookie, ni
Bearer, ni clave de API.
Declarar uno de esos campos con un tipo incompatible —un email de tipo number— es un error de config
que nombra el campo y el tipo que se esperaba.
Los endpoints
Sección titulada «Los endpoints»/api/auth/* lo sirve better-auth entero. Kevin CMS no reimplementa nada.
| Método | Ruta | Qué hace |
|---|---|---|
POST |
/api/auth/sign-up/email |
Crea la primera cuenta, ya verificada. Después, 403 |
POST |
/api/auth/sign-in/email |
Inicia sesión, o 403 si falta verificar |
POST |
/api/auth/sign-out |
Cierra la sesión |
GET |
/api/auth/get-session |
La sesión actual, o null |
GET |
/api/auth/verify-email |
El enlace del correo de verificación |
POST |
/api/auth/send-verification-email |
Reenvía el correo de verificación |
POST |
/api/auth/request-password-reset |
Manda el correo de restablecimiento |
POST |
/api/auth/reset-password |
Guarda la contraseña nueva |
curl -X POST http://localhost:4321/api/auth/sign-up/email \ -H 'content-type: application/json' \ -d '{"email":"kevin@example.com","password":"una-contraseña-larga","name":"Kevin"}'La sesión viaja en una cookie, así que desde el navegador no tienes que hacer nada. Con curl, guarda las
cookies con -c y reenvíalas con -b.
Una contraseña incorrecta y un email que no existe dan la misma respuesta, un 401. Es deliberado:
distinguirlas permitiría averiguar qué direcciones tienen cuenta. Una cuenta sin verificar es distinta —
un 403— porque ahí sí hay algo que el usuario puede hacer.
Los endpoints de los plugins
Sección titulada «Los endpoints de los plugins»El Bearer, las claves de API y la administración de cuentas son plugins de better-auth, y sus endpoints
cuelgan del mismo /api/auth/*. Solo están abiertos los que actúan sobre las claves de quien llama; el
resto responde 404, igual que una ruta que no existe, y no hace nada:
| Método | Ruta | Quién | Qué hace |
|---|---|---|---|
POST |
/api/auth/api-key/create |
Con sesión | Crea una clave para ti. La respuesta es la única vez que trae la clave |
GET |
/api/auth/api-key/list |
Con sesión | Tus claves, sin la clave |
POST |
/api/auth/api-key/delete |
Con sesión | Revoca una clave tuya ({ "keyId": "…" }) |
Cada una actúa solo sobre las claves de la cuenta que llama: list no devuelve las de otra, delete no
borra la de otra (ni siquiera a un administrador) y create rechaza un userId ajeno y los ajustes que solo
fija el servidor. Una clave no vale para llamarlas: hace falta una sesión (cookie o Bearer).
Los cierres son decisión, no olvido, y cada uno tiene su motivo:
Ruta cerrada (404) |
Por qué |
|---|---|
/admin/set-user-password, /admin/revoke-user-sessions, /admin/ban-user, /admin/unban-user |
El plugin decide quién puede llamarlos solo por el nombre del rol (admin.firstUserRole, o admin) y no mira el access de la colección users. Con una política más estrecha en tu config —un update de users que exige un rol superior— una cuenta con ese nombre de rol cambiaría la contraseña de un rol superior: tomar su cuenta. Siguen cerradas: el panel de usuarios hace esas cuatro cosas desde el servidor y con el access del CMS delante (#271), sin pasar por estas rutas ni por el mapa de roles del plugin |
/admin/set-role |
Abriría el camino a users.role que el CMS cierra a propósito: el rol se escribe con admin.firstUserRole o por SQL, no por la API |
/admin/impersonate-user, /admin/stop-impersonating |
Crearía una sesión a nombre de otra cuenta |
/admin/list-user-sessions, /admin/revoke-user-session |
El primero devolvería los tokens de sesión de otros (con Bearer, credenciales); el segundo se apoya en ellos. Usa revoke-user-sessions |
/admin/remove-user |
Se saltaría las guardas de borrado de la colección users |
/admin/create-user, /admin/update-user, /admin/list-users, /admin/get-user |
Esquivarían el access de la colección users. El alta la hace el panel desde el servidor (sin ruta HTTP), y aunque create-user se abriera, el hook de alta lo rechazaría: una petición HTTP lleva request |
/admin/has-permission |
No sirve de nada al CMS |
/api-key/get, /api-key/update |
Una clave no se edita ni se relee: se revoca y se crea otra |
disabledPaths actúa solo sobre HTTP: el código del servidor sigue teniendo auth.api.*. Y es
fail-closed: cualquier endpoint que un plugin añada en una versión futura de better-auth queda en 404
hasta que alguien lo decide en el código, y una prueba falla hasta entonces.
Alta, verificación y sesión
Sección titulada «Alta, verificación y sesión»El primer usuario
Sección titulada «El primer usuario»La primera cuenta de una instalación nace verificada. No podría ser de otra forma: la verificación es obligatoria, y el correo que la desbloquea sale con el remitente que esa misma persona todavía no ha configurado. Así que se da de alta y entra, sin recibir nada.
Lo normal es no hacerlo a mano: la primera visita a /admin en una instalación vacía es el asistente de
configuración, que pide los ajustes del sitio y la primera cuenta en un solo formulario y deja la sesión
abierta.
Esa primera cuenta recibe el rol de admin.firstUserRole, si lo declaras, y de ese rol depende quién
puede, sin más access, editar y borrar cuentas ajenas y cambiar los ajustes del sitio (ver
Las cuentas sin access). cms init y create-cms
lo escriben en el config que generan: admin: { firstUserRole: 'admin' }.
Los demás
Sección titulada «Los demás»Todo usuario posterior —una vez el admin sepa invitarlos— sigue el flujo completo. El alta no deja sesión iniciada y no manda correo: el correo sale en el primer intento de iniciar sesión, que además se rechaza:
-
La cuenta se crea con la dirección sin verificar. La respuesta no trae cookie.
-
POST /api/auth/sign-in/emailresponde403, y en ese momento envía el correo de verificación.En local no hace falta buzón:
wrangler devsimula el envío y escribe la ruta del fichero con el mensaje en la consola. -
El enlace del correo apunta a
GET /api/auth/verify-email. Seguirlo marca la dirección. -
POST /api/auth/sign-in/emailresponde ahora200con la cookie.
¿Por qué en el inicio de sesión y no en el alta? Porque quien se da de alta y no vuelve nunca genera un
correo que nadie iba a abrir, y quien sí vuelve lo recibe justo cuando lo necesita — recién pedido, no
caducado. Si hace falta otro, POST /api/auth/send-verification-email lo reenvía.
Restablecer la contraseña
Sección titulada «Restablecer la contraseña»Desde el panel: en /admin/login, «¿Olvidaste tu contraseña?» lleva a /admin/forgot, que pide el correo.
-
/admin/forgotcontesta siempre lo mismo, exista la cuenta o no: «Si hay una cuenta con ese correo, te hemos mandado un enlace». Solo si existe sale el correo, con un enlace a/admin/reset/:token. -
/admin/reset/:tokenenseña el formulario de la contraseña nueva (con confirmación). Abrir el enlace no lo gasta: el token se consume al guardar la contraseña, así que un antivirus de correo que abra la página no deja el enlace inservible. -
Al guardar, el panel te lleva a
/admin/logincon el aviso «Contraseña cambiada». No inicia sesión: entras con la contraseña nueva.
Un enlace caducado, ya usado o inventado enseña la misma pantalla, «Este enlace no es válido», con un enlace para pedir otro. No dice cuál de los tres era ni nada de la cuenta. El token dura una hora y se usa una sola vez: pedir otro enlace no anula el anterior, pero usar uno borra los demás que esa cuenta tuviera pendientes.
Una contraseña demasiado corta o larga, o dos contraseñas que no coinciden, se señalan en el formulario sin gastar el enlace. Las reglas de longitud son las de better-auth.
Estas dos pantallas son para quien no tiene sesión: con una abierta, /admin/forgot y /admin/reset/:token
llevan al escritorio, igual que /admin/login, y el enlace sigue valiendo. Quien ya está dentro cambia la
contraseña en /admin/account. En una instalación sin ninguna cuenta, las dos llevan al asistente.
Completar el reset cierra todas las sesiones abiertas de esa cuenta, no solo las de otros navegadores: quien restablece la contraseña ya ha perdido el control de la suya, así que la sesión de quien se la quitó se cae con las demás. También borra todas las claves de API de la cuenta, y las integraciones que las usaban dejan de funcionar hasta que se generen otras (ver qué le pasa a una clave cuando cambia la contraseña): la pantalla de la contraseña nueva lo avisa antes de guardar.
El límite de intentos es el de better-auth, el mismo que el de la API y no uno aparte: 3 peticiones de
enlace por hora y por IP y, para guardar la contraseña, el general de /api/auth/* (60 por minuto).
Pasado el tope, el formulario lo dice sobre la misma pantalla, sin romperla. Ver
Límites.
Por la API sigue siendo POST /api/auth/request-password-reset y, para guardar la contraseña, un POST a
/api/auth/reset-password con el token del enlace. El correo de la API ya no depende de redirectTo:
siempre lleva a /admin/reset/:token. La respuesta del reset no trae cookie, de modo que hay que volver a
/admin/login y entrar con la contraseña nueva.
Astro.locals.cms.auth
Sección titulada «Astro.locals.cms.auth»interface AuthLocals { getSession(): Promise<SessionData> // la sesión, o null getUser(): Promise<SessionUser | null> // el usuario, o null requireUser(): Promise<SessionUser> // el usuario, o lanza UnauthorizedError (401)}Proteger una página son dos líneas:
---export const prerender = false
const user = await Astro.locals.cms.auth.getUser()if (!user) return Astro.redirect('/login')---
<p>Hola, {user.name}</p>getSession() se consulta una vez por petición: llamarlo en el layout y otra vez en un componente no
cuesta el doble, porque lo que se guarda es la promesa y no el valor —dos llamadas simultáneas también
comparten la consulta—. La caché muere con la petición, así que ninguna sesión se filtra a la siguiente.
Nada de esto se construye hasta que lo pides: una página que no lee cms no toca la base de datos.
requireUser() es para tus rutas propias, donde un redirect no sirve. Lanza un
UnauthorizedError, y solo la API REST del CMS lo traduce a un 401: en una ruta tuya de Astro, si nada
lo atrapa, la respuesta es un 500. Envuélvelo en un try/catch con errorResponse, que se importa de
@kevolution-co/cms, y sale el mismo 401 en JSON que da la REST. En una página .astro no lo uses
sin más: ahí es getUser() y Astro.redirect, como arriba.
Bearer y claves de API
Sección titulada «Bearer y claves de API»Dos formas de autenticar sin cookie, para un script, un servidor o una app móvil. En las dos, quien actúa
es una cuenta: el rol sale del usuario dueño de la sesión o de la clave, se lee en cada petición y se
evalúa con las mismas funciones de access que con la cookie. Ni el token
ni la clave llevan permisos propios.
Token Bearer
Sección titulada «Token Bearer»El mismo token de la sesión, por cabecera. Lo trae la respuesta de cualquier inicio de sesión, en la
cabecera set-auth-token:
curl -i -X POST https://tu-sitio.com/api/auth/sign-in/email \ -H 'content-type: application/json' \ -d '{"email":"kevin@example.com","password":"una-contraseña-larga"}'# …# set-auth-token: 4Yx9…firma…
curl https://tu-sitio.com/api/cms/posts -H 'Authorization: Bearer 4Yx9…firma…'- El token es el valor firmado, el mismo de la cookie
better-auth.session_token. Eltokencrudo que guarda la tablasession(y que devuelve el cuerpo del inicio de sesión) no vale: así, quien lea la tabla —una copia de seguridad, una inyección de solo lectura— no obtiene sesiones usables sin conocerBETTER_AUTH_SECRET. - Vale lo que la sesión: caduca cuando ella caduca y deja de valer al cerrarla (
/api/auth/sign-outcon el mismo Bearer) o al revocarla un administrador. - Funciona en la API REST, en
/api/auth/*y enAstro.locals.cms.authde tus páginas.
Claves de API
Sección titulada «Claves de API»Para un cliente que no inicia sesión, una clave de larga vida. Lo normal es crearla desde
/admin/account; por HTTP se crea con una sesión (cookie o Bearer):
curl -X POST https://tu-sitio.com/api/auth/api-key/create \ -H 'Authorization: Bearer 4Yx9…firma…' -H 'content-type: application/json' \ -d '{"name":"integración con el CRM","expiresIn":7776000}'La respuesta trae el campo key. Es la única vez que la verás: en la base solo queda su hash SHA-256
y GET /api/auth/api-key/list no la devuelve. Se usa con la cabecera X-API-Key en la
API REST:
curl https://tu-sitio.com/api/cms/posts -H 'X-API-Key: la-clave'- La clave es del usuario que la creó y se comporta como esa cuenta: si le cambian el rol, la misma clave cambia con él. Si la cuenta se borra, sus claves se borran con ella; si se suspende, la clave deja de autenticar.
- Solo vale para la API REST. No es una sesión de better-auth: no entra en
/admin, ni crea más claves ni llama a ningún otro endpoint de/api/auth/*. Tampoco la leeAstro.locals.cms.auth. - Toda clave caduca. Sin
expiresIn, la clave dura 90 días; el mínimo es un día y el máximo 365 (expiresInva en segundos). No hay claves eternas: una credencial de larga vida que nadie recuerda es la que acaba filtrada. Las claves creadas antes de esta regla no cambian. - Una clave no sobrevive a una contraseña que se restablece: ver más abajo.
- Regenerar es crear la nueva y revocar la vieja (
POST /api/auth/api-key/deletecon{ "keyId": "…" }); no hay un endpoint que lo haga de una vez (el panel sí: ver más abajo). Revocada, regenerada, caducada o desactivada, la clave recibe un401. - Se guarda
start, los 6 primeros caracteres de la clave, para que puedas reconocerla en la lista. Es decisión de better-auth y es parte de lo que ve quien lea la tabla. - No hay una clave por usuario forzada en el servidor: un usuario puede crear varias, hasta 10 por hora y por IP por HTTP, y 10 por hora y por usuario desde el panel.
- El límite por clave de better-auth está apagado (el de serie son 10 peticiones al día, escritas en la fila de cada clave): una API con ese techo no sirve. Una clave que se filtra se corta revocándola; para un tope por petición, pon delante una regla de rate limiting de Cloudflare.
- Las consultas de better-auth sobreviven al fin de su petición. El plugin lanza, sin esperarla, la
limpieza de claves caducadas al final de
listy dedelete. Si la respuesta salía antes de que acabara, workerd cortaba la consulta y la conexión de better-auth con D1 se quedaba bloqueada: toda petición con sesión que llegaba después esperaba para siempre (y por esoPOST /api/auth/api-key/createparecía colgarse enastro dev, #301). El paquete entrega cada consulta de better-auth awaitUntil, así que acaban aunque nadie las espere. Quien montecreateAutha mano tiene que pasarlewaitUntil(el último argumento): sin él no se entrega nada. - Cada petición con clave cuesta algo más que una con cookie: una lectura de la clave por su hash, una
escritura sobre su fila (better-auth actualiza
updatedAten cada verificación) y la lectura del usuario dueño.
Qué le pasa a una clave cuando cambia la contraseña
Sección titulada «Qué le pasa a una clave cuando cambia la contraseña»Quien roba una clave de API conserva el acceso aunque la persona «recupere» la cuenta, porque la clave dura hasta 365 días y no depende de la contraseña. Por eso el CMS la corta cuando la contraseña cambia por una vía que cierra sesiones:
| Qué pasa | Sus claves de API |
|---|---|
Restablecer la contraseña (/admin/reset/:token, POST /api/auth/reset-password) |
Se borran todas, siempre |
Cambiar la contraseña con revokeOtherSessions: true (POST /api/auth/change-password, auth.api.changePassword, y /admin/account, que lo manda siempre) |
Se borran todas |
Fijar la contraseña de otra cuenta desde el panel de cuentas (/admin/collections/users) |
Se borran todas las de esa cuenta |
Cambiar la contraseña sin esa opción, o con revokeOtherSessions: false |
Siguen vivas |
| Un intento que falla (token inválido, contraseña actual incorrecta) | No se toca nada |
| Cualquiera de los de arriba, sobre otra cuenta | Las claves de las demás cuentas no se tocan |
Las claves se borran y no se desactivan: la fila desaparece y la clave recibe un 401, igual que una
revocada a mano. Las integraciones que las usaban dejan de funcionar hasta que generes otras desde
/admin/account y las cambies en cada cliente: avísalas antes de
restablecer la contraseña de una cuenta con integraciones en producción. Las pantallas de recuperación y de
cambio de contraseña lo dicen en el momento.
Fijar la contraseña de otra cuenta desde el panel de cuentas es el flujo de «recuperar una cuenta
comprometida» desde el otro lado, y hace lo mismo: cierra sus sesiones, borra sus enlaces de recuperación y
borra sus claves de API. Las integraciones de esa cuenta dejan de funcionar hasta que su dueña genere
otras desde /admin/account, y el formulario y el aviso posterior lo dicen. Las claves de la cuenta que
fija la contraseña, y las de las demás, no se tocan. Una contraseña rechazada (demasiado corta, dos que no
coinciden) no borra nada.
Desde la pantalla de cuenta
Sección titulada «Desde la pantalla de cuenta»/admin/account lista tus claves —nombre, cómo empiezan (los 6 primeros caracteres), último uso,
caducidad y estado— y deja generar, regenerar y revocar sin JavaScript: son formularios nativos.
- La clave en claro se ve una sola vez, en la respuesta del propio envío del formulario, con un aviso encima. No viaja en la URL, ni en una redirección, ni en una cookie, ni se registra; al salir de esa pantalla solo queda el prefijo. Si recargas esa respuesta, el navegador vuelve a enviar el formulario y se acuña otra clave (cuenta para el tope de abajo). No hay botón de copiar: necesitaría JavaScript, y el campo es de solo lectura para copiarlo con el teclado o el ratón.
- Regenerar y revocar piden confirmación en una pantalla aparte (
?confirm=revoke&key=…), que no cambia nada por sí sola: la escritura es unPOST. Revocar corta la clave en el acto. Regenerar conserva el nombre y la duración que tenía y borra primero la vieja: si crear la nueva falla, la filtrada ya no vale y el panel lo dice. - Al generar se elige el nombre (hasta 32 caracteres) y el plazo de una lista: 30, 90 (el defecto), 180 o 365 días. No se puede elegir otro, ni fijar permisos propios: la clave actúa como tu cuenta, con el rol que tengas en cada petición.
- Solo con la sesión de cookie del panel. Un
Authorization: Bearero unaX-API-Keyen la petición reciben un403y la pantalla ni lista las claves: una credencial robada no sirve para acuñar otra. Una cuenta suspendida no entra al panel, así que tampoco ve ni genera claves. - Solo las tuyas. Un id de una clave ajena y un id que no existe dan el mismo
404, y la clave ajena sigue valiendo. Ni siquiera un administrador gestiona las de otra cuenta desde aquí. - Generar y regenerar comparten un tope de 10 por hora y usuario; revocar tiene el suyo (límites).
- Cambiar tu contraseña desde esta pantalla revoca todas tus claves de API, además de cerrar tus demás sesiones: el panel pide cerrarlas siempre, y las claves van detrás. Las integraciones que las usen dejarán de funcionar hasta que generes otras (el cambio lo avisa). Ver qué le pasa a una clave cuando cambia la contraseña.
Qué credencial manda
Sección titulada «Qué credencial manda»| La petición lleva | Resultado |
|---|---|
| Nada | Anónima, como siempre: lo que access deje a un visitante |
| Solo la cookie | La sesión de la cookie |
Authorization: Bearer … válido |
Su sesión. Manda sobre la cookie: si hay las dos, la cookie no se mira |
Authorization: Bearer … inválido, caducado, de una sesión cerrada o de una cuenta suspendida |
401, no la cookie de reserva ni una lectura anónima |
X-API-Key válida |
La cuenta dueña. Manda sobre la cookie |
X-API-Key revocada, regenerada, caducada, desactivada, inventada o de una cuenta suspendida |
401, con el mismo mensaje en todos los casos |
Authorization: Bearer y X-API-Key a la vez |
401: no se adivina cuál quería |
X-API-Key vacía |
Como si no estuviera |
Authorization con otro esquema (Basic …) |
No es una credencial: se ignora |
Un 401 y no «anónima» para una credencial que falla es deliberado: un cliente con la clave vieja tiene que
enterarse, no recibir en silencio lo público. Vale también para una lectura de una colección
access: { read: 'public' }: con una credencial en la cabecera, el CMS la comprueba aunque la lectura sea
pública.
Dentro de /api/auth/* mandan las reglas propias de better-auth, no esta tabla.
Cuentas suspendidas y administración de cuentas
Sección titulada «Cuentas suspendidas y administración de cuentas»El plugin de administración de better-auth está montado, pero ninguno de sus endpoints tiene ruta HTTP:
cambiar la contraseña de otra cuenta, cerrarle las sesiones, suspenderla o levantarle la suspensión por
/api/auth/admin/* responde 404 (ver los endpoints cerrados). Lo que
hace el panel es lo mismo desde el servidor y con el access de tu config decidiendo antes.
Desde el panel
Sección titulada «Desde el panel»En /admin/collections/users (el botón «Crear» y la pantalla de cada cuenta) quien tiene permiso puede:
| Acción | Qué pasa |
|---|---|
| Crear una cuenta | Correo, nombre, contraseña inicial y —si el access del campo role lo permite— rol. La cuenta nace con el correo verificado y puede entrar con esa contraseña. No abre el registro: sign-up/email sigue respondiendo 403 |
| Fijar la contraseña de otra cuenta | La vieja deja de abrir sesión, se cierran todas sus sesiones, se borran sus enlaces de recuperación pendientes y se borran todas sus claves de API |
| Cerrar todas las sesiones de otra cuenta | Sin tocar nada más de la cuenta |
| Suspender y levantar la suspensión | Con motivo y, si quieres, días (de 1 a 3650). Al suspender se cierran sus sesiones. Solo si el access.update de banned, banReason y banExpires lo permite |
| Cambiar el rol | Solo si tu access.update del campo role lo permite (ver Roles) |
Cada acción es un formulario nativo (POST, respuesta 303) y el servidor decide al recibirlo: quitar el
botón de la pantalla no es la defensa, un POST a mano se rechaza igual. El orden de las comprobaciones,
antes de tocar nada, es este:
- Hay una sesión de cookie. Un token
Bearero unaX-API-Keyen la petición no valen para esto, aunque pertenezcan a quien sí tiene permiso: para tomar cuentas ajenas solo se acepta el navegador. - La cuenta tiene acceso al panel (
access.admindeusers). - Tu config ha declarado
access.create(el alta) oaccess.update(lo demás) enusers: si no,403que dice qué declarar. Y la cuenta no ha superado 30 acciones por minuto. accessdeuserslo permite, con la cuenta de destino delante: tiene que poder leerla y editarla. Una condición de tuupdatese evalúa contra esa fila. La cuenta de destino sale de la URL, nunca de un campo del formulario.- El destino no es la propia cuenta: nadie se administra a sí mismo desde aquí, ni su rol, ni su suspensión, ni su contraseña. Eso evita quedarse sin acceso al panel por error (quien actúa es siempre una cuenta con acceso, así que quitárselo a otra nunca deja el panel sin nadie). Tu contraseña y tus sesiones se gestionan en Cuenta, donde se pide la actual.
Toda negativa que no sea la del paso 5 es la misma respuesta: 403, el mismo mensaje y ningún dato, tanto
si la cuenta no existe, como si no puedes verla, como si no puedes editarla. No sirve para averiguar qué
cuentas hay.
La suspensión se decide como el rol, campo a campo: el CMS niega banned, banReason y banExpires a
todo el mundo por defecto (ni el PATCH ni el editor los escriben), y el panel no abre lo que esa regla
cierra. Para poder suspender y levantar la suspensión desde el panel declara el access.update de los
tres campos (se escriben los tres, también al levantarla); la función recibe data con lo que se va a
escribir, así que puedes permitir suspender y no levantar. Sin ello, la pantalla enseña el estado de la
suspensión y dice qué declarar. Ver el ejemplo en
Administrar cuentas desde el panel.
El rol se asigna con el access.update del campo role, que por defecto lo niega a todo el mundo: asignar
roles desde el panel es una decisión tuya, y la función recibe data.role para que decidas qué roles puede
dar cada rol. Un rol no puede llevar comas (el plugin las lee como varios roles), pasar de 64 caracteres ni
llevar caracteres de control.
Estas acciones escriben por internalAdapter de better-auth y no por la Local API de la colección, así que
los hooks de la colección users de tu proyecto no se ejecutan al crear una cuenta, cambiar su rol ni
suspenderla (un afterChange de auditoría, por ejemplo, no se entera). Si necesitas dejar rastro, hazlo en tu
propio código alrededor del panel o en la base de datos.
Tres límites conocidos: dos administradores que se degradan a la vez entre sí pueden dejarse sin acceso (no
hay transacción entre llamadas de better-auth), entre escribir la contraseña nueva y cerrar las sesiones
hay una ventana de milisegundos, y una cuenta con el rol de administración puede borrarse a sí misma
mientras queden otras: la guarda solo impide borrar la última. Si era la única con ese rol, las que quedan
no tienen quien administre, y como role es de solo lectura (ni el panel ni la REST lo escriben) no hay
forma de volver a dárselo sin SQL. Ver
Recuperar el acceso a los ajustes. Borrar una cuenta sigue siendo el delete de la colección, con su propio
access.delete, con una excepción que ninguna política levanta, tampoco overrideAccess: true: la última
cuenta no se borra (LAST_ACCOUNT, un 400 por REST). Sin ninguna, el registro público volvería a abrirse
para quien llegue primero. Vale para la API REST, la Local API y el panel, y también en el borrado en bloque.
El efecto de la suspensión
Sección titulada «El efecto de la suspensión»Se escribe desde el panel, desde el servidor (auth.api.banUser) o por SQL (banned = 1), y es siempre el
mismo:
- Una cuenta suspendida recibe un
403conBANNED_USERal iniciar sesión (el panel lo dice con «Esta cuenta está suspendida») y deja de autenticar por cookie, Bearer y clave de API, aunque conserve una sesión o una clave anteriores.banExpires(obanExpiresInal banear) la levanta sola al cumplirse. - Suspender borra las sesiones de la cuenta, y nadie se suspende a sí mismo.
Qué protege la API REST
Sección titulada «Qué protege la API REST»Con la autenticación en su sitio, las reglas de la API REST por fin se pueden cumplir:
| Operación | Regla |
|---|---|
GET |
Pública si la colección —o el global— declara access: { read: 'public' }; si no, pide sesión |
POST, PATCH, DELETE |
Piden sesión siempre, el PATCH de un global incluido |
Esa es la regla sin access. Con una función declarada, la decide ella: 401 sin sesión, 403 si la
función dice que no a quien la tiene y 404 si una condición deja fuera el documento. Ver
Control de acceso.
«Sesión» es la de la cookie, la de un token Bearer o la cuenta de una
clave de API: da igual cuál, la regla es la misma. Una credencial de cabecera que no
resuelve a una cuenta es un 401, no una petición anónima.
Lo que todavía no hay
Sección titulada «Lo que todavía no hay»| Estado | |
|---|---|
| Pantalla para reenviar el correo de verificación | No. El endpoint existe, la pantalla llega con el admin |
| Pantallas de olvidé mi contraseña y de contraseña nueva | Sí, en /admin/forgot y /admin/reset/:token |
| Invitar usuarios por correo | No. El alta es manual: quien administra escribe el correo, el nombre y una contraseña inicial, y se la da por otro canal |
| Crear cuentas, fijar su contraseña, suspenderlas, cerrarles las sesiones y darles rol desde el panel | Sí, en /admin/collections/users, con el access de users decidiendo. Ver Desde el panel |
| Google y GitHub | No. Se configurarán desde el admin, sin desplegar |
| Pantalla de login | Sí, en /admin/login |
| Cambiar tu contraseña sabiendo la actual | Sí, en /admin/account. Cierra tus demás sesiones |
| Ver y cerrar tus sesiones abiertas | Sí, en /admin/account |
| Cierre del registro tras el primer usuario | Sí. La primera cuenta lo cierra |
| Control de acceso por rol, documento y campo | Sí, con access en el config. Un solo role por cuenta; no hay lista de roles |
| Token Bearer y claves de API para clientes sin navegador | Sí. Las claves valen solo para la API REST |
| Pantalla para crear, regenerar y revocar claves de API | Sí, en /admin/account, solo con la sesión de cookie. Ver Desde la pantalla de cuenta. Sin botón de copiar (necesita JavaScript) |
| Claves de API sin caducidad | No. Toda clave caduca: 90 días si no se dice, 365 como máximo |
| Suspender, cambiar la contraseña o cerrar sesiones de otra cuenta por HTTP | No, a propósito: el plugin decide por el nombre del rol y no mira tu access. Se hace desde el panel, que sí lo mira |
Suplantar a otra cuenta (impersonate), cambiar el rol por la API, listar usuarios por /api/auth/admin |
No, a propósito: ver los endpoints cerrados |