SaaS Starter

Autenticação

BetterAuth com magic link, Google OAuth, organizações e RBAC.

Por que BetterAuth

NextAuth é pesado demais para o nosso setup (Express + Next standalone). BetterAuth: TypeScript-first, schema em código, plugins compostáveis, adapter oficial do Prisma.

Setup

Variáveis obrigatórias (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 com verificação de email
  • Magic link (Resend)
  • Google OAuth
  • Passkeys (WebAuthn) — Touch-ID / Windows Hello / chaves físicas
  • Sessions persistidas no Postgres

Verificação de email

A verificação de email está habilitada mas não é obrigatória para fazer login (requireEmailVerification: false). O campo emailVerified aparece em /api/v1/auth/me.

Fluxo

  1. Após o registro, o frontend pode chamar POST /api/v1/auth/resend-verification com { email } para enviar o e-mail.
  2. O usuário recebe um link {BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>.
  3. O cliente extrai o token da URL e chama POST /api/v1/auth/verify-email com { token }.
  4. O endpoint responde { userId } e o campo emailVerified do usuário passa a true.
// Reenviar
POST /api/v1/auth/resend-verification
{ "email": "[email protected]" }
// → 200 { "ok": true } — sempre, mesmo que o email já esteja verificado (no-enumeration)

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

[!NOTE] resend-verification retorna 200 mesmo que o email já esteja verificado ou não exista, por segurança (no-enumeration). O e-mail só é enviado se o endereço existir e não estiver verificado.

Passkeys (WebAuthn)

Passkeys permitem registrar uma credencial respaldada por hardware (Touch-ID, Windows Hello, chave física) e usá-la para fazer login sem senha. Implementado com @better-auth/passkey.

Variáveis de ambiente

VarObrigatóriaDefaultNotas
APP_URLsimhttp://localhost:3004Origem do frontend. O browser assina clientDataJSON.origin com esta URL durante a ceremony; um mismatch faz verifyRegistration falhar.
RP_IDnãohostname de APP_URLRelying Party ID. Apenas o domínio (sem scheme/porta/path). Em dev local fica em "localhost".
RP_NAMEnãoAPP_NAMETexto humano que aparece no prompt do SO ("Entrar em {RP_NAME}").

Endpoints

O catch-all do BetterAuth em /api/auth/* expõe toda a superfície do plugin — não há 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 passam pelo authIpLimiter (rate-limit por IP, mesmo tier do resto de /auth/*).

Cliente

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

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

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

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

// Fazer login (dispara prompt do SO)
await authClient.signIn.passkey();

A UI fica em /settings/security (lista + adicionar + revogar) e o botão de sign-in em /login.

[!WARNING] Para adicionar uma passkey, BetterAuth exige uma sessão "fresca" (default: < 24 horas). Se o usuário estiver logado há mais tempo, o endpoint retorna SESSION_NOT_FRESH e ele precisa re-autenticar. Por enquanto a UI mostra um toast genérico — o dialog de re-auth está capturado no ticket #186.

[!NOTE] A ceremony WebAuthn não pode ser emulada em CI headless. Os testes do server cobrem wiring do plugin + rate-limit; a correção do flow é validada humanamente em cada PR que mexa nele (Touch-ID Mac / Windows Hello / etc.).

Organizações

Multi-tenancy com membership. Um usuário pode pertencer a várias organizações com roles distintos em cada uma.

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

Trocar a organização ativa = trocar o contexto nos cookies (active-org). Todas as queries do dashboard filtram por organizationId.

RBAC

Catálogo de permissions em @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;

Mapeamento role → permissões em ROLE_TO_PERMISSIONS. Owner = todas, admin = quase todas, member = principalmente leitura.

O middleware requirePermission(perm) valida em cada request:

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

Exclusão de conta (GDPR)

O fluxo de exclusão de conta é assíncrono: o sistema cria uma solicitação pendente, o worker BullMQ a processa após o período de carência e exclui o usuário em cascata.

Período de carência

Controlado pela variável de ambiente DELETION_GRACE_DAYS (padrão: 30). Durante o período de carência o usuário pode cancelar a solicitação. Em ambientes de teste/E2E recomenda-se DELETION_GRACE_DAYS=0.

Endpoints

// Solicitar exclusão (re-autenticação necessária)
POST /api/v1/me/deletion
{ "password": "...", "reason": "..." }
// → 202 { "requestId": "...", "scheduledFor": "ISO-8601", "graceDays": 30 }

// Ver solicitação pendente
GET /api/v1/me/deletion
// → 200 { "pending": { "id": "...", "requestedAt": "...", "scheduledFor": "..." } | null }

// Cancelar durante o período de carência
DELETE /api/v1/me/deletion
// → 204

Restrições

  • O usuário deve fornecer a senha atual (re-auth).
  • Se o usuário for o único proprietário de uma organização, o worker rejeita a exclusão com erro de conflito.
  • A linha AccountDeletionRequest sobrevive à exclusão do usuário como tombstone de conformidade GDPR.

Exportação de dados (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": "..." }

O link de download expira em 24 horas. O JSON inclui: user, memberships, auditEntries, sessions, accountDeletionRequests, dataExportRequests, subscriptions.

Roles dinâmicos (futuro)

Tracked em Tasky #98 — permitir que cada organização defina roles custom ("Editor", "Reviewer") com permissões do catálogo. Hoje: enum hardcoded.

Nesta página