From 763f64080a23f04c9e442e13c58f7baa60d50136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 10:56:18 -0300 Subject: [PATCH 1/4] Deliver B40: biometric login for /campanha. WebAuthn platform passkeys: opt-in enrollment after a password login, a password-less sign-in that mints the same 14-day campaign-token, and revocation from the profile. The session is minted through Payload's public pieces (checkLoginPermission, addSessionToUser, getFieldsToSign, jwtSign) inside a transaction with an advisory lock, so a passkey login neither bypasses a lockout nor evicts the account's other sessions. The rpID is derived from NEXT_PUBLIC_SITE_URL and resolves to null when the request host disagrees, so previews never offer a ceremony the browser would refuse. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 11 +- docs/plans/login-biometrico-campanha.md | 29 +- docs/roadmap.md | 12 +- package.json | 2 + pnpm-lock.yaml | 217 + src/app/(campaign)/campanha/(app)/layout.tsx | 33 +- .../(campaign)/campanha/(app)/perfil/page.tsx | 23 + .../(campaign)/campanha/actions/webauthn.ts | 43 + .../(campaign)/campanha/login/LoginForm.tsx | 11 +- src/app/(campaign)/campanha/login/page.tsx | 7 +- .../campanha/webauthn/login-options/route.ts | 36 + .../campanha/webauthn/login/route.ts | 56 + .../webauthn/register-options/route.ts | 38 + .../campanha/webauthn/register/route.ts | 49 + src/collections/CampaignUser.ts | 22 + src/collections/CampaignWebAuthnCredential.ts | 98 + .../auth/CampaignBiometricLoginButton.tsx | 88 + .../campaign/auth/CampaignPasskeysCard.tsx | 231 + .../campaign/auth/CampaignProfileSettings.tsx | 9 +- .../auth/useCampaignBiometricsAvailable.ts | 28 + .../shell/BiometricEnrollmentToast.tsx | 113 + src/lib/campaignAuthCopy.ts | 56 + src/lib/campaignWebAuthn.ts | 48 + src/lib/campaignWebAuthnClient.ts | 128 + src/lib/campaignWebAuthnSupport.ts | 40 + src/lib/deviceLabel.ts | 30 + src/lib/schemas/campaignWebAuthn.ts | 65 + ...958_add_campaign_webauthn_credentials.json | 12186 ++++++++++++++++ ...41958_add_campaign_webauthn_credentials.ts | 89 + src/migrations/index.ts | 8 +- src/payload-types.ts | 48 + src/payload.config.ts | 2 + src/utilities/access/webauthnCredentials.ts | 29 + src/utilities/campaignAccess.ts | 6 + src/utilities/campaignBiometricsPrompt.ts | 65 + src/utilities/campaignWebAuthnCeremony.ts | 308 + src/utilities/campaignWebAuthnChallenge.ts | 118 + src/utilities/campaignWebAuthnConfig.ts | 97 + src/utilities/campaignWebAuthnSession.ts | 106 + tests/e2e/campaignAuth.e2e.spec.ts | 2 +- tests/e2e/campaignWebAuthn.e2e.spec.ts | 121 + tests/e2e/fixtures/campaignE2EFixtures.ts | 5 +- tests/e2e/fixtures/e2eTest.ts | 44 +- tests/e2e/setup.e2e.spec.ts | 5 + tests/int/campaignWebAuthnAccess.int.spec.ts | 165 + tests/int/campaignWebAuthnSession.int.spec.ts | 186 + .../campaignWebAuthnChallenge.unit.spec.ts | 109 + .../unit/campaignWebAuthnConfig.unit.spec.ts | 54 + 49 files changed, 15248 insertions(+), 29 deletions(-) create mode 100644 src/app/(campaign)/campanha/actions/webauthn.ts create mode 100644 src/app/(campaign)/campanha/webauthn/login-options/route.ts create mode 100644 src/app/(campaign)/campanha/webauthn/login/route.ts create mode 100644 src/app/(campaign)/campanha/webauthn/register-options/route.ts create mode 100644 src/app/(campaign)/campanha/webauthn/register/route.ts create mode 100644 src/collections/CampaignWebAuthnCredential.ts create mode 100644 src/components/campaign/auth/CampaignBiometricLoginButton.tsx create mode 100644 src/components/campaign/auth/CampaignPasskeysCard.tsx create mode 100644 src/components/campaign/auth/useCampaignBiometricsAvailable.ts create mode 100644 src/components/campaign/shell/BiometricEnrollmentToast.tsx create mode 100644 src/lib/campaignWebAuthn.ts create mode 100644 src/lib/campaignWebAuthnClient.ts create mode 100644 src/lib/campaignWebAuthnSupport.ts create mode 100644 src/lib/deviceLabel.ts create mode 100644 src/lib/schemas/campaignWebAuthn.ts create mode 100644 src/migrations/20260728_041958_add_campaign_webauthn_credentials.json create mode 100644 src/migrations/20260728_041958_add_campaign_webauthn_credentials.ts create mode 100644 src/utilities/access/webauthnCredentials.ts create mode 100644 src/utilities/campaignBiometricsPrompt.ts create mode 100644 src/utilities/campaignWebAuthnCeremony.ts create mode 100644 src/utilities/campaignWebAuthnChallenge.ts create mode 100644 src/utilities/campaignWebAuthnConfig.ts create mode 100644 src/utilities/campaignWebAuthnSession.ts create mode 100644 tests/e2e/campaignWebAuthn.e2e.spec.ts create mode 100644 tests/int/campaignWebAuthnAccess.int.spec.ts create mode 100644 tests/int/campaignWebAuthnSession.int.spec.ts create mode 100644 tests/unit/campaignWebAuthnChallenge.unit.spec.ts create mode 100644 tests/unit/campaignWebAuthnConfig.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 114b1429..afd455e0 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` é a 6ª cópia do mesmo fetch — **e essa segunda o `main` resolveu antes**, no B32+ ✓, então adotar `campaignJsonMutationRoute`/`postCampaignJson` nas 4 rotas e no cliente do WebAuthn é trabalho de pós-rebase, não débito. - **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `