SaaS Starter

Autenticación

BetterAuth con magic link, Google OAuth, organizaciones y RBAC.

Por qué BetterAuth

NextAuth pesa demasiado para nuestro setup (Express + Next standalone). BetterAuth: TypeScript-first, schema en código, plugins composables, adapter oficial de Prisma.

Setup

Variables requeridas (apps/server/.env):

BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3005
GOOGLE_CLIENT_ID=  # opcional
GOOGLE_CLIENT_SECRET=

Métodos habilitados

  • Email + password con verificación de email
  • Magic link (Resend)
  • Email OTP — código de 6 dígitos sin contraseña (default del onboarding de mibio)
  • Google OAuth
  • Passkeys (WebAuthn) — Touch-ID / Windows Hello / llaves físicas
  • Sessions persistidas en Postgres

OTP sin contraseña

Lo usa el onboarding de mibio (/register → /register/verify). Reduce la fricción a "tipea tu email, tipea los 6 dígitos, listo" — sin contraseña que recordar.

Flujo

  1. POST /api/auth/email-otp/send-verification-otp con { email, type: "sign-in" } — el server genera un OTP de 6 dígitos, lo envía por email, devuelve 200.
  2. POST /api/auth/email-otp/sign-in con { email, otp } — el server verifica el código, devuelve el usuario y setea la cookie de sesión.

Códigos de 6 dígitos, vencen en 10 minutos. El plugin se registra como emailOTP en better-auth.adapter.ts; la entrega va por el mismo emailProvider que magic-link y password-reset (en dev sin SMTP/Resend el código se loguea en stdout vía el LogEmailProvider).

Verificación de email

La verificación de email está habilitada pero no es obligatoria para iniciar sesión (requireEmailVerification: false). El campo emailVerified aparece en /api/v1/auth/me.

Flujo

  1. Después de registrarse, el frontend puede llamar POST /api/v1/auth/resend-verification con { email } para enviar el correo.
  2. El usuario recibe un enlace {BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>.
  3. El cliente extrae el token de la URL y llama POST /api/v1/auth/verify-email con { token }.
  4. El endpoint responde { userId } y el campo emailVerified del usuario pasa a true.
// Resend
POST /api/v1/auth/resend-verification
{ "email": "[email protected]" }
// → 200 { "ok": true } — siempre, aunque el email ya esté verificado (no-enumeration)

// Verificar
POST /api/v1/auth/verify-email
{ "token": "<JWT from email URL>" }
// → 200 { "userId": "..." }  |  422 si el token es inválido

[!NOTE] resend-verification devuelve 200 incluso si el email ya está verificado o no existe, por seguridad (no-enumeration). El correo sólo se envía si el email existe y no está verificado.

Passkeys (WebAuthn)

Las passkeys permiten registrar una credencial respaldada por hardware (Touch-ID, Windows Hello, llave física) y usarla para iniciar sesión sin password. Implementado con @better-auth/passkey.

Variables de entorno

VarRequeridaDefaultNotas
APP_URLhttp://localhost:3004Origen del frontend. El browser firma clientDataJSON.origin con esta URL durante la ceremony; un mismatch hace fallar verifyRegistration.
RP_IDnohostname de APP_URLRelying Party ID. Solo el dominio (sin scheme/puerto/path). En dev local queda en "localhost".
RP_NAMEnoAPP_NAMETexto humano que aparece en el prompt del SO ("Iniciar sesión en {RP_NAME}").

Endpoints

El catch-all de BetterAuth en /api/auth/* expone toda la superficie del plugin — no hay controllers wrapeados:

  • POST /api/auth/passkey/generate-register-options
  • POST /api/auth/passkey/verify-registration
  • POST /api/auth/passkey/generate-authentication-options
  • POST /api/auth/passkey/verify-authentication
  • GET /api/auth/passkey/list-user-passkeys
  • POST /api/auth/passkey/delete-passkey

Todos pasan por authIpLimiter (rate-limit por IP, mismo tier que el resto de /auth/*).

Cliente

import { authClient } from '@/lib/auth-client';

// Registrar (dispara prompt del SO)
await authClient.passkey.addPasskey({ name: 'MacBook Touch-ID' });

// Listar
const { data } = await authClient.passkey.listUserPasskeys();

// Revocar
await authClient.passkey.deletePasskey({ id });

// Iniciar sesión (dispara prompt del SO)
await authClient.signIn.passkey();

El UI vive en /settings/security (lista + agregar + revocar) y el botón de sign-in en /login.

[!WARNING] Para agregar una passkey, BetterAuth exige una sesión "fresca" (default: < 24 horas). Si el usuario está logueado hace más, addPasskey() devuelve SESSION_NOT_FRESH y el UI abre un dialog de re-auth: reingresá la contraseña (auto-reintenta addPasskey al volver) o pedí un magic link (consumilo y volvé a hacer clic en Agregar passkey). Las dos vías reusan los endpoints /auth/sign-in y /auth/magic-link/send — no hay endpoint nuevo.

[!NOTE] La ceremony WebAuthn no se puede emular en CI headless. Los tests del server cubren wiring del plugin + rate-limit; la corrección del flow se valida humanamente en cada PR que la toque (Touch-ID Mac / Windows Hello / etc.).

Deploying passkeys a producción

Los defaults funcionan en localhost porque el browser, el API y el RP_ID configurado colapsan a un solo hostname. En producción hay que pensarlo.

Single-host deploy. Frontend y API en el mismo registrable domain (por ejemplo https://example.com para los dos):

APP_URL=https://example.com
# RP_ID toma por default "example.com" (hostname de APP_URL) — dejarlo vacío.

Split-host deploy. Frontend en https://app.example.com, API en https://api.example.com:

APP_URL=https://app.example.com
RP_ID=example.com

RP_ID DEBE ser el registrable domain (eTLD+1), no el hostname del frontend — si no, la credencial creada en app.example.com no se puede presentar en ningún otro subdomain. El browser sigue firmando clientDataJSON.origin con APP_URL (el origin real de la página), que la config passkey({ origin }) de BetterAuth valida en el server.

[!WARNING] Si clientDataJSON.origin no matchea el origin configurado, el registro funciona en el browser (el prompt del SO completa) y después el server devuelve FAILED_TO_VERIFY_REGISTRATION. Esto nos pasó en #153 en dev — el fix fue derivar origin de APP_URL (el frontend), no de BETTER_AUTH_URL (el API).

Organizaciones

Multi-tenancy con membership. Un usuario puede pertenecer a varias organizaciones con roles distintos en cada una.

type MembershipRole = 'owner' | 'admin' | 'member';

Cambiar de organización activa = cambiar el contexto en cookies (active-org). Todas las queries del dashboard filtran por organizationId.

RBAC

Catálogo de permissions en @app/shared/permissions:

export const PERMISSIONS = {
  ORG_MEMBERS_READ: 'org:members:read',
  ORG_MEMBERS_WRITE: 'org:members:write',
  BILLING_READ: 'billing:read',
  BILLING_WRITE: 'billing:write',
  // ...
} as const;

Mapeo rol → permisos en ROLE_TO_PERMISSIONS. Owner = todos, admin = casi todos, member = mayormente lectura.

El middleware requirePermission(perm) valida en cada request:

router.post('/billing', requirePermission(PERMISSIONS.BILLING_WRITE), handler);

Eliminación de cuenta (GDPR)

El flujo de eliminación de cuenta es asíncrono: el sistema crea una solicitud pendiente, el trabajador de BullMQ la procesa tras el período de gracia y elimina al usuario en cascada.

Período de gracia

Controlado por la variable de entorno DELETION_GRACE_DAYS (por defecto: 30). Durante el período de gracia el usuario puede cancelar la solicitud. En entornos de prueba/E2E se recomienda DELETION_GRACE_DAYS=0 para observar la eliminación inmediatamente.

Endpoints

// Solicitar eliminación (re-autenticación requerida)
POST /api/v1/me/deletion
{ "password": "...", "reason": "..." }
// → 202 { "requestId": "...", "scheduledFor": "ISO-8601", "graceDays": 30 }

// Ver solicitud pendiente
GET /api/v1/me/deletion
// → 200 { "pending": { "id": "...", "requestedAt": "...", "scheduledFor": "..." } | null }

// Cancelar durante el período de gracia
DELETE /api/v1/me/deletion
// → 204

Restricciones

  • El usuario debe proporcionar su contraseña actual (re-auth).
  • Si el usuario es el único propietario de una organización, el trabajador rechaza la eliminación con un error de conflicto.
  • La fila AccountDeletionRequest sobrevive la eliminación del usuario como tombstone de cumplimiento GDPR.

Exportación de datos (GDPR portability)

POST /api/v1/me/export
// → 202 { "requestId": "...", "status": "pending" }

GET /api/v1/me/export/:id
// → 200 { "id": "...", "status": "pending"|"ready"|"failed"|"expired", "downloadUrl": "...", "expiresAt": "..." }

El enlace de descarga caduca a las 24 horas. El JSON incluye: user, memberships, auditEntries, sessions, accountDeletionRequests, dataExportRequests, subscriptions.

Roles dinámicos (futuro)

Tracked en Tasky #98 — permitir que cada organización defina roles custom ("Editor", "Reviewer") con permisos del catálogo. Hoy: enum hardcodeado.

En esta página