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)
- Google OAuth
- Passkeys (WebAuthn) — Touch-ID / Windows Hello / llaves físicas
- Sessions persistidas en Postgres
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
- Después de registrarse, el frontend puede llamar
POST /api/v1/auth/resend-verificationcon{ email }para enviar el correo. - El usuario recibe un enlace
{BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>. - El cliente extrae el token de la URL y llama
POST /api/v1/auth/verify-emailcon{ token }. - El endpoint responde
{ userId }y el campoemailVerifieddel usuario pasa atrue.
// 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-verificationdevuelve 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
| Var | Requerida | Default | Notas |
|---|---|---|---|
APP_URL | sí | http://localhost:3004 | Origen del frontend. El browser firma clientDataJSON.origin con esta URL durante la ceremony; un mismatch hace fallar verifyRegistration. |
RP_ID | no | hostname de APP_URL | Relying Party ID. Solo el dominio (sin scheme/puerto/path). En dev local queda en "localhost". |
RP_NAME | no | APP_NAME | Texto 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-optionsPOST /api/auth/passkey/verify-registrationPOST /api/auth/passkey/generate-authentication-optionsPOST /api/auth/passkey/verify-authenticationGET /api/auth/passkey/list-user-passkeysPOST /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()devuelveSESSION_NOT_FRESHy el UI abre un dialog de re-auth: reingresá la contraseña (auto-reintentaaddPasskeyal volver) o pedí un magic link (consumilo y volvé a hacer clic en Agregar passkey). Las dos vías reusan los endpoints/auth/sign-iny/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.comRP_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.originno matchea eloriginconfigurado, el registro funciona en el browser (el prompt del SO completa) y después el server devuelveFAILED_TO_VERIFY_REGISTRATION. Esto nos pasó en #153 en dev — el fix fue derivarorigindeAPP_URL(el frontend), no deBETTER_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
// → 204Restricciones
- 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
AccountDeletionRequestsobrevive 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.