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)
- Email OTP — código de 6 dígitos sem senha (default do onboarding do mibio)
- Google OAuth
- Passkeys (WebAuthn) — Touch-ID / Windows Hello / chaves físicas
- Sessions persistidas no Postgres
OTP sem senha
Usado pelo onboarding do mibio (/register → /register/verify). Reduz o
atrito a "digite seu email, digite os 6 dígitos, pronto" — sem senha pra
lembrar.
Fluxo
POST /api/auth/email-otp/send-verification-otpcom{ email, type: "sign-in" }— o server gera um OTP de 6 dígitos, envia por email, retorna200.POST /api/auth/email-otp/sign-incom{ email, otp }— o server verifica o código, retorna o usuário e seta o cookie de sessão.
Códigos de 6 dígitos, expiram em 10 minutos. O plugin é registrado como
emailOTP em better-auth.adapter.ts; entrega pelo mesmo emailProvider
de magic-link e password-reset (em dev sem SMTP/Resend o código aparece no
stdout via LogEmailProvider).
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
- Após o registro, o frontend pode chamar
POST /api/v1/auth/resend-verificationcom{ email }para enviar o e-mail. - O usuário recebe um link
{BETTER_AUTH_URL}/api/auth/verify-email?token=<JWT>. - O cliente extrai o token da URL e chama
POST /api/v1/auth/verify-emailcom{ token }. - O endpoint responde
{ userId }e o campoemailVerifieddo usuário passa atrue.
// 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-verificationretorna 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
| Var | Obrigatória | Default | Notas |
|---|---|---|---|
APP_URL | sim | http://localhost:3004 | Origem do frontend. O browser assina clientDataJSON.origin com esta URL durante a ceremony; um mismatch faz verifyRegistration falhar. |
RP_ID | não | hostname de APP_URL | Relying Party ID. Apenas o domínio (sem scheme/porta/path). Em dev local fica em "localhost". |
RP_NAME | não | APP_NAME | Texto 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-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 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_FRESHe 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
// → 204Restriçõ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
AccountDeletionRequestsobrevive à 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.