# Plano — Auth real v0.2 (pré-piloto) > Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet. > Pré-requisito: MAI CALL v0.1 completo (ver [`mai-call-v0.1.md`](./mai-call-v0.1.md)). Estado do código no momento do design verificado contra o repo. ## Objetivo numa frase Fechar a porta das traseiras (`AUTH_DEV_AUTOLOGIN`) e dar a cada tipo de utilizador o login certo: **admin/supervisor → email + password**; **operador → escolha na lista + PIN de 4–6 dígitos**. Tudo o resto do produto (MAI CALL) continua a funcionar igual. ## Decisões fixadas (não revisitar sem motivo forte) 1. **Um único segredo por utilizador, guardado em `User.passwordHash`** (já existe, nullable). Password (admin) e PIN (operador) são ambos "um segredo que o utilizador prova" — mesmo campo, mesma função de hash. O `role` decide qual UI/validação se aplica. 2. **Hashing com `crypto.scrypt` nativo do Node 22** — zero dependências, sem build nativo (evita atrito no Windows + pnpm 11 que bloqueia postinstall). NÃO instalar bcrypt/argon nativos. 3. **`AUTH_DEV_AUTOLOGIN` não desaparece — passa a ser ignorado quando `NODE_ENV === 'production'`.** Em dev/test continua a funcionar (conveniência local + E2E). A porta das traseiras fica fechada **no código**, não só por honestidade do `.env`. 4. **Operador entra por lista + PIN** (não password). O picker que já existe ganha um segundo passo (teclado de PIN). Isto mantém o caminho para o RFID limpo: trocar o teclado por um leitor de cartão, sem mexer no resto. 5. **Admin-web ganha Auth.js a sério** (hoje não tem nenhum). Mesmo padrão da operator-pwa: Credentials provider + JWT + middleware + página de login. 6. **Lockout mínimo** (5 tentativas falhadas → bloqueio de 5 min) porque um PIN numérico exposto na rede sem qualquer travão a força-bruta é irresponsável para um piloto. É a única adição de schema. 7. **Continua single-tenant na prática.** O login procura o utilizador por email com `findFirst` (o email só é único *por tenant*, `@@unique([tenantId, email])`). Para o piloto (uma fábrica = um tenant) é suficiente. Multi-tenant login (selector de tenant ou email globalmente único) fica para quando entrar o 2º cliente. --- ## 1. Modelo de dados Sem modelos novos. Só **dois campos** em `User`, para o lockout: ```prisma model User { // ... campos existentes (id, tenantId, email, passwordHash, role, createdAt, relations) ... failedAttempts Int @default(0) lockedUntil DateTime? } ``` `passwordHash` já existe e é `String?` — passa a ser efetivamente usado. **Não** mudar o tipo nem a nulidade (um utilizador sem segredo definido simplesmente não consegue entrar — `verifySecret` devolve `false`). `tenant-extension.ts` não precisa de alteração (`User` já está em `TENANT_SCOPED_MODELS`), mas atenção: o login corre **antes** de haver tenant, por isso usa o `prisma` não-scoped (ver §3). **Migration:** após editar o schema, correr `pnpm db:migrate` (nome sugerido: `auth_v0_2_lockout`). --- ## 2. Função de hashing — `@repo/db` Cria-se `packages/db/src/crypto.ts` e exporta-se de `packages/db/src/index.ts`. Fica em `@repo/db` (e não em `@repo/api`) **de propósito**: o `seed.ts` vive em `packages/db` e precisa de `hashSecret`; como `api` depende de `db` (e não o contrário), só `db` pode ser importado por todos sem ciclo. ```ts // packages/db/src/crypto.ts import { randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(_scrypt); const KEYLEN = 64; /** Devolve "scrypt$$". Serve para password (admin) e PIN (operador). */ export async function hashSecret(plain: string): Promise { const salt = randomBytes(16); const derived = (await scrypt(plain, salt, KEYLEN)) as Buffer; return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`; } /** Verificação em tempo constante. `stored` null/malformado → false (nunca lança). */ export async function verifySecret(plain: string, stored: string | null): Promise { if (!stored) return false; const [scheme, saltHex, hashHex] = stored.split('$'); if (scheme !== 'scrypt' || !saltHex || !hashHex) return false; const salt = Buffer.from(saltHex, 'hex'); const expected = Buffer.from(hashHex, 'hex'); const derived = (await scrypt(plain, salt, expected.length)) as Buffer; return derived.length === expected.length && timingSafeEqual(derived, expected); } ``` Export em `packages/db/src/index.ts`: `export { hashSecret, verifySecret } from './crypto';` --- ## 3. Verificação de credenciais — `@repo/api` Lógica única partilhada pelas duas apps (lookup + verify + lockout). Vai em `@repo/api` porque toca Prisma + regra de negócio, e ambas as apps já dependem de `@repo/api`. ```ts // packages/api/src/auth.ts import { prisma, verifySecret } from '@repo/db'; import type { SessionUser } from './context'; const MAX_ATTEMPTS = 5; const LOCK_MS = 5 * 60_000; /** * Autentica por email + segredo (PIN ou password), restringindo a roles permitidos. * Usa o prisma NÃO-scoped: o login acontece antes de existir tenant. * Devolve o SessionUser em sucesso, null em qualquer falha (credenciais, role, lockout). */ export async function authenticateCredential(opts: { email: string; secret: string; allowedRoles: SessionUser['role'][]; }): Promise { const user = await prisma.user.findFirst({ where: { email: opts.email } }); if (!user) return null; if (!opts.allowedRoles.includes(user.role)) return null; if (user.lockedUntil && user.lockedUntil > new Date()) return null; const ok = await verifySecret(opts.secret, user.passwordHash); if (!ok) { const attempts = user.failedAttempts + 1; await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockedUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_MS) : null, }, }); return null; } if (user.failedAttempts !== 0 || user.lockedUntil) { await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockedUntil: null }, }); } return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId }; } ``` Export em `packages/api/src/index.ts`: `export { authenticateCredential } from './auth';` (`SessionUser` já é exportado de `@repo/api` — confirmado em `context.ts`.) --- ## 4. Fechar a porta das traseiras (`NODE_ENV` gating) A regra única: **autologin só é honrado se `AUTH_DEV_AUTOLOGIN` E `NODE_ENV !== 'production'`.** **operator-pwa** — `apps/operator-pwa/lib/auth.ts`, em `resolveUser()`: ```ts const autologinAllowed = env.AUTH_DEV_AUTOLOGIN && process.env.NODE_ENV !== 'production'; if (autologinAllowed) { // ... fallback admin@demo.local (igual ao atual) } ``` **operator-pwa** — `apps/operator-pwa/middleware.ts`: ```ts const isAutologin = process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; ``` O redirect passa a apontar para `/select-operator` na operator-pwa (já aponta) — sem mudança de destino. **admin-web** — passa a ter `resolveUser()` real (ver §6), com o mesmo gating no fallback. > **Porque é que isto não parte o E2E:** o Playwright corre `next dev` (logo `NODE_ENV === 'development'`) e injecta `AUTH_DEV_AUTOLOGIN=true` nos webServers. O gating só desliga o autologin em `production`. O happy-path continua a entrar por autologin sem mexer no teste. --- ## 5. operator-pwa — login do operador (lista + PIN) ### 5a. Credentials provider (`apps/operator-pwa/lib/auth.ts`) Substituir o provider placeholder (que aceitava email sem password) por: ```ts Credentials({ name: 'Operador (PIN)', credentials: { email: { type: 'text' }, pin: { type: 'password' } }, async authorize(credentials) { const email = credentials?.email; const pin = credentials?.pin; if (typeof email !== 'string' || typeof pin !== 'string') return null; const u = await authenticateCredential({ email, secret: pin, allowedRoles: ['OPERATOR'] }); if (!u) return null; return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId }; }, }), ``` `allowedRoles: ['OPERATOR']` garante que admins não entram pela app do operador. ### 5b. Cookie name distinto (evita colisão com admin-web) Em `apps/operator-pwa/lib/auth.config.ts`, dentro do objeto `authConfig`, acrescentar: ```ts cookies: { sessionToken: { name: 'fieldops-op.session-token' }, callbackUrl: { name: 'fieldops-op.callback-url' }, csrfToken: { name: 'fieldops-op.csrf-token' }, }, ``` > **Porquê:** cookies não são isolados por porto. Sem nomes distintos, abrir admin-web (:3001) e operator-pwa (:3000) na mesma máquina sobrepõe a sessão. Cada app tem de ter o seu prefixo. ### 5c. Ecrã de PIN `/select-operator` mantém-se como entrada, mas o `OperatorPicker` ganha **dois estados**: 1. **Lista** (como hoje): mostra operadores; tap selecciona em vez de fazer `signIn` imediato. 2. **Teclado de PIN**: depois de escolher, mostra o nome do operador + um teclado numérico (botões 0–9, apagar) + indicador de dígitos. Ao atingir o comprimento (aceitar 4 a 6 dígitos; submeter no botão "Entrar") chama: ```ts const result = await signIn('credentials', { email, pin, redirect: false }); ``` - sucesso (`!result?.error`): `router.push('/'); router.refresh();` - erro: limpar dígitos e mostrar **"PIN incorreto ou conta bloqueada. Tente novamente."** (não distinguir os dois casos — não dar pistas a quem ataca). - botão "Voltar" regressa à lista. Mobile-first, botões grandes (target dedo com luva). Reaproveitar as classes Tailwind já usadas no picker atual. `apps/operator-pwa/app/select-operator/page.tsx` não muda a query (continua a listar operadores via `prisma` direto). Só passa a precisar do `id`+`email` (já passa). `sign-out-button.tsx` (botão "Trocar") não muda — `signOut({ callbackUrl: '/select-operator' })` continua correto. --- ## 6. admin-web — login do admin (email + password) A admin-web **não tem Auth.js**. Adiciona-se o stack completo, espelhando a operator-pwa. ### 6a. Dependência Em `apps/admin-web/package.json`, acrescentar a `dependencies` (mesma versão exata da operator-pwa, para não haver drift): ```json "next-auth": "5.0.0-beta.25" ``` Correr `pnpm install`. ### 6b. Ficheiros novos - **`apps/admin-web/lib/auth.config.ts`** — cópia do edge-safe config da operator-pwa, com: - `pages: { signIn: '/login' }` - os mesmos callbacks `jwt`/`session` (copiar tal e qual). - `cookies` com prefixo **`fieldops-admin.`** (sessionToken / callbackUrl / csrfToken) — ver §5b. - **`apps/admin-web/lib/auth.ts`** — substitui o `resolveUser` placeholder atual. Estrutura igual à operator-pwa: ```ts export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, secret: env.AUTH_SECRET, providers: [ Credentials({ name: 'Email + password', credentials: { email: { type: 'email' }, password: { type: 'password' } }, async authorize(c) { if (typeof c?.email !== 'string' || typeof c?.password !== 'string') return null; const u = await authenticateCredential({ email: c.email, secret: c.password, allowedRoles: ['ADMIN', 'SUPERVISOR'], }); if (!u) return null; return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId }; }, }), ], }); export async function resolveUser(): Promise { const session = await auth(); const u = session?.user as any; if (u?.id && u?.tenantId) return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId }; const autologinAllowed = process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; if (autologinAllowed) { const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); if (admin) return { id: admin.id, email: admin.email, role: admin.role as any, tenantId: admin.tenantId }; } return null; } ``` - **`apps/admin-web/app/api/auth/[...nextauth]/route.ts`** — idêntico ao da operator-pwa: ```ts import { handlers } from '@/lib/auth'; export const { GET, POST } = handlers; export const runtime = 'nodejs'; ``` - **`apps/admin-web/middleware.ts`** — espelha o da operator-pwa, mas redireciona para `/login`: ```ts import NextAuth from 'next-auth'; import { authConfig } from './lib/auth.config'; const { auth } = NextAuth(authConfig); export default auth((req) => { const isLoggedIn = !!req.auth?.user; const isAutologin = process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; const { pathname } = req.nextUrl; if (pathname === '/login') { if (isLoggedIn) return Response.redirect(new URL('/maintenance', req.url)); return; } if (!isLoggedIn && !isAutologin) return Response.redirect(new URL('/login', req.url)); }); export const config = { matcher: ['/((?!api/auth|api/trpc|_next/static|_next/image|favicon.ico).*)'], }; ``` - **`apps/admin-web/app/login/page.tsx`** + **`login-form.tsx`** (client component): - form com email + password, botão "Entrar". - `const result = await signIn('credentials', { email, password, redirect: false });` - sucesso → `router.push('/maintenance')`; erro → "Email ou password incorretos. Tente novamente." - Estilo: reaproveitar componentes `@repo/ui` (Card/Button) já usados na queue. > `signIn`/`signOut` de `next-auth/react` funcionam **sem** `SessionProvider`, por isso `apps/admin-web/app/providers.tsx` não precisa de mudar. ### 6c. env da admin-web `apps/admin-web/env.ts` — acrescentar ao bloco `server` (hoje só tem `DATABASE_URL`, `AUTH_DEV_AUTOLOGIN`, `LOG_LEVEL`): ```ts AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'), ``` e no `runtimeEnv`: `AUTH_SECRET: process.env.AUTH_SECRET,` **Não** adicionar `AUTH_URL`: o `.env` partilhado tem `AUTH_URL=http://localhost:3000` (da operator-pwa) e isso seria errado para a admin. Com `trustHost: true` (já no authConfig), o Auth.js infere o host a partir do request — não precisa de `AUTH_URL` em dev. --- ## 7. Seed — definir PINs e password demo `packages/db/prisma/seed.ts`: - importar `hashSecret` de `@repo/db` (ou diretamente de `./crypto` — mas o seed já importa `@prisma/client` dinamicamente; usar import estático de `../src/crypto.js`/`@repo/db` conforme o que o build resolver; preferir `@repo/db`). - admin: `passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD)`. - cada operador: `passwordHash: await hashSecret(pin)` com PINs distintos. Como `createMany` não permite valores assíncronos por linha de forma limpa, trocar o `createMany` dos operadores por um loop `for ... create`. - constantes no topo: ```ts const DEMO_ADMIN_PASSWORD = 'admin1234'; const OPERATORS = [ { email: 'op1@demo.local', pin: '1111' }, { email: 'op2@demo.local', pin: '2222' }, { email: 'op3@demo.local', pin: '3333' }, ]; ``` - imprimir as credenciais demo no final (`console.warn`) para Pedro saber o que digitar: ``` Seed: admin=admin@demo.local / admin1234 | operadores: op1=1111 op2=2222 op3=3333 ``` `failedAttempts`/`lockedUntil` ficam nos defaults (0/null), não precisam de set no seed. --- ## 8. `.env.example` + README - **`.env.example`**: na nota do `AUTH_DEV_AUTOLOGIN`, acrescentar que **mesmo a `true` é ignorado em produção** (`NODE_ENV=production`). Manter default `"false"`. - **README** — secção "Known limitations": remover/atualizar a linha "No real authentication" (agora há), e a linha "Operator picker, not TAG/card" mantém-se mas passa a "picker + PIN". Acrescentar as credenciais demo ao "Demo flow" (admin: `admin@demo.local`/`admin1234`; operadores: PINs). Atualizar o aviso da porta das traseiras para refletir o gating por `NODE_ENV`. --- ## 9. Verificação — smoke script (segue o padrão do repo) Criar `scripts/auth-smoke.ts` (como os outros `scripts/*-smoke.ts`): 1. corre contra a BD seeded. 2. `authenticateCredential({ email:'op1@demo.local', secret:'1111', allowedRoles:['OPERATOR'] })` → devolve user. ✓ 3. PIN errado 5× → 6ª chamada com PIN **certo** devolve `null` (lockout ativo). ✓ 4. role errado: `authenticateCredential({ email:'admin@demo.local', secret:'admin1234', allowedRoles:['OPERATOR'] })` → `null`. ✓ 5. admin com password certa e `allowedRoles:['ADMIN','SUPERVISOR']` → user. ✓ 6. no fim, repor `failedAttempts=0, lockedUntil=null` do op1 para não deixar a BD num estado bloqueado (ou re-seed). Adicionar ao README a linha `pnpm tsx scripts/auth-smoke.ts`. O E2E `mai-call.spec.ts` **não muda** (continua via autologin em dev, §4). --- ## 10. Cortes propositados — o que NÃO entra na v0.2 | Cortado | Porquê | Quando volta | |---|---|---| | RFID/cartão do operador | PIN é a ponte; arquitetura já preparada | quando MY QUALITY entrar | | Reset de password / "esqueci o PIN" self-service | Sem email/SMS infra; admin re-seed ou futura UI | v0.3 | | UI de gestão de utilizadores (criar/editar/role) | Cria-se via seed/SQL no piloto | com onboarding multi-tenant | | Selector de tenant no login | Single-tenant no piloto; email `findFirst` chega | 2º cliente | | SSO / MFA | Não pedido pelo piloto | se cliente exigir | | Rate-limit por IP (além do lockout por conta) | Lockout por conta cobre o PIN; IP precisa de infra | se necessário | | Rotação de `AUTH_SECRET` / política de sessão curta | Default JWT chega para piloto | endurecimento pós-piloto | | Expiração/rotação de PIN | Sem valor demonstrado no piloto | quando pedirem | --- ## 11. Plano de implementação (passos pequenos, ordenados) Cada passo é mergeable e tem critério de aceitação testável. ### Passo 1 — Schema + crypto util **Faz:** §1 (campos `failedAttempts`/`lockedUntil` + migration) e §2 (`packages/db/src/crypto.ts` + export). **AC:** `pnpm db:migrate` corre sem erro; um script ad-hoc consegue `hashSecret('1234')` e `verifySecret('1234', hash) === true`, `verifySecret('9999', hash) === false`, `verifySecret('x', null) === false`. ### Passo 2 — `authenticateCredential` em `@repo/api` **Faz:** §3 (`packages/api/src/auth.ts` + export). **AC:** importável de `@repo/api`; `pnpm typecheck` verde. (Comportamento real testado no Passo 7.) ### Passo 3 — Seed com PINs/password **Faz:** §7. **AC:** `pnpm db:seed` corre e imprime as credenciais; em Prisma Studio, `passwordHash` dos 4 utilizadores está preenchido (formato `scrypt$...`). ### Passo 4 — Fechar a porta das traseiras **Faz:** §4 (gating `NODE_ENV` na operator-pwa: `resolveUser` + `middleware`). **AC:** com `AUTH_DEV_AUTOLOGIN=true` e `next dev`, a operator-pwa continua a entrar (dev). Simular produção (`NODE_ENV=production` num build) → abrir `/` redireciona para `/select-operator` em vez de auto-entrar. ### Passo 5 — operator-pwa: provider PIN + cookie name **Faz:** §5a + §5b (authorize com PIN + `allowedRoles:['OPERATOR']`; cookies `fieldops-op.*`). **AC:** `signIn('credentials',{email:'op1@demo.local',pin:'1111'})` cria sessão; `pin:'0000'` falha. Cookie no browser chama-se `fieldops-op.session-token`. ### Passo 6 — operator-pwa: ecrã de PIN **Faz:** §5c (picker em 2 estados: lista → teclado PIN). **AC:** Demo: limpar cookies → `/` redireciona ao picker → escolher op1 → teclar `1111` → "Entrar" → home mostra `op1@demo.local`. PIN errado mostra a mensagem e limpa os dígitos. ### Passo 7 — admin-web: Auth.js completo + login **Faz:** §6 inteiro (dep, auth.config, auth.ts, route handler, middleware, /login, env `AUTH_SECRET`, cookies `fieldops-admin.*`). **AC:** com autologin **off** (ou prod), abrir :3001 redireciona a `/login`; entrar com `admin@demo.local`/`admin1234` chega à fila `/maintenance`; password errada falha; um OPERATOR não consegue entrar na admin-web (role bloqueado). ### Passo 8 — Smoke + docs **Faz:** §9 (`scripts/auth-smoke.ts`) + §8 (`.env.example` + README). **AC:** `pnpm tsx scripts/auth-smoke.ts` passa todos os casos (incl. lockout às 5 tentativas); `pnpm test:e2e` continua verde; outro dev segue o README e consegue fazer login real nas duas apps. --- **Sequência crítica:** Passos 1→3 são fundação (schema, crypto, credenciais). Passo 4 fecha o buraco de segurança e pode ir cedo. Passos 5–6 (operador) e 7 (admin) são independentes — podem ir em paralelo se houver 2 devs. Passo 8 fecha. **Risco principal:** a colisão de cookies entre :3000 e :3001 (§5b/§6b) — se os nomes de cookie não forem distintos, fazer login numa app desloga a outra de forma confusa. Verificar explicitamente os nomes dos cookies no DevTools no Passo 5 e 7.