Ir al contenido

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. Los proveedores sociales y el panel de login llegan después.

  1. nodejs_compat en tu wrangler.jsonc. better-auth usa node:crypto y sin esa marca el sitio no arranca:

    wrangler.jsonc
    { "compatibility_flags": ["nodejs_compat"] }
  2. Un secreto con el que firmar las sesiones. En local va en .dev.vars, que está en tu .gitignore:

    Ventana de terminal
    openssl rand -base64 32
    .dev.vars
    BETTER_AUTH_SECRET=el-valor-que-acabas-de-generar
  3. En producción, como secreto del Worker:

    Ventana de terminal
    npx wrangler secret put BETTER_AUTH_SECRET
  4. Genera y aplica la migración con las tablas de sesión:

    Ventana de terminal
    npx cms db:migrate
    npx cms db:apply

No hay ninguna opción que configurar en cms.config.ts: si el secreto está, la autenticación funciona.

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 codegen emite cuatro 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
session Las sesiones abiertas No
account Las credenciales, incluido el hash de la contraseña No
verification Los tokens de un solo uso No

Las tres 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.

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.

cms.config.ts
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 e image. El email acaba siempre required y unique aunque no lo declares: sin el índice único, dos cuentas podrían compartir dirección.

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.

/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
Ventana de terminal
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.

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.

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:

  1. La cuenta se crea con la dirección sin verificar. La respuesta no trae cookie.

  2. POST /api/auth/sign-in/email responde 403, y en ese momento envía el correo de verificación.

    En local no hace falta buzón: wrangler dev simula el envío y escribe la ruta del fichero con el mensaje en la consola.

  3. El enlace del correo apunta a GET /api/auth/verify-email. Seguirlo marca la dirección.

  4. POST /api/auth/sign-in/email responde ahora 200 con 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.

POST /api/auth/request-password-reset manda el correo. Su enlace lleva a GET /api/auth/reset-password/:token, y la contraseña nueva se guarda con un POST a /api/auth/reset-password. El token dura una hora.

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 401
}

Proteger una página son dos líneas:

src/pages/cuenta.astro
---
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 endpoints, donde un redirect no sirve: lanza un error que la API REST traduce a un 401.

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 declara access: { read: 'public' }; si no, pide sesión
POST, PATCH, DELETE Piden sesión siempre
Estado
Pantalla para reenviar el correo de verificación No. El endpoint existe, la pantalla llega con el admin
Formulario de restablecimiento No. El flujo funciona por API; la pantalla llega con el admin
Invitar usuarios por correo No. Llega con el alta de usuarios del admin
Google y GitHub No. Se configurarán desde el admin, sin desplegar
Pantalla de login Sí, en /admin/login
Cierre del registro tras el primer usuario Sí. La primera cuenta lo cierra
Roles y permisos No