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.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 10:56:18 -03:00
parent eab4dae430
commit 763f64080a
49 changed files with 15248 additions and 29 deletions

File diff suppressed because one or more lines are too long

View file

@ -163,14 +163,19 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role.
- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — calls `setCampaignAuthCookie` before redirecting to `/campanha`. `logoutCampaign` authenticates the current token and delegates revocation of its `_sid` to Payload's public transactional `logoutOperation`, then clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`; malformed tokens or database failures never prevent local cookie/PWA cleanup.
- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.
- **Biometric login (WebAuthn, B40 — 2026-07-28).** A second way into the same session, never a second session: the biometric path ends at the same `setCampaignAuthCookie` with the 14-day TTL (enrolling the device _is_ the trust opt-in), and the password path is untouched. Credentials live in `campaignWebAuthnCredential` (owner-only access in `src/utilities/access/webauthnCredentials.ts`; `update` is denied to users because the signature counter and `lastUsedAt` are written with `overrideAccess: true` inside the transaction). Four anonymous-or-authenticated JSON routes sit in `src/app/(campaign)/campanha/webauthn/` — deliberately **outside `(app)`**, since two of them run before there is a session and the cookie's `/campanha` path already covers them. **No `Consent` key, and this is not an art. 11 question:** the OS never releases the fingerprint, so what we store is a public key.
- **There is no password-less login in Payload 3.82.0.** `loginOperation` requires `password`. `issueCampaignWebAuthnSession` (`src/utilities/campaignWebAuthnSession.ts`) mints the session from public exports only: `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign`. **Never load the user with `payload.findByID` here.** `addSessionToUser` read-modify-writes the _whole_ user document, so a doc that passed through `CampaignUser`'s `afterRead` `removePrivateAuthFields` would be written back without `sessions` or `email` — logging the user out of every other device — and with `avatar` expanded into an object. `user.updatedAt = null` is set for the same reason Payload's own login does it. The mint runs inside `withPayloadTransaction` under `campaign-webauthn-session:{userId}`, because rewriting the whole `sessions` array makes two concurrent logins lose one.
- **RP ID must match the host that served the page.** `getCampaignWebAuthnRelyingParty` derives `rpID`/`origin` from `getCampaignInviteBaseURL`, _and_ compares the request's host against it — returning `null` when they differ, which hides the button. Without that comparison a Vercel preview resolves the canonical origin successfully and the browser throws `SecurityError` mid-ceremony.
- **The challenge cookie must carry the library's own challenge.** It is an HMAC-signed httpOnly cookie (5 min, single-use, path `/campanha`, precedent `supporterImportToken.ts`) — but store the exact base64url string `generateRegistrationOptions`/`generateAuthenticationOptions` returned. Generating our own alongside theirs makes every `register` answer 400.
- **Deleting a `campaignUser` needs the cascade in a hook.** Payload emits `ON DELETE SET NULL` for the required `user` relationship, which cannot apply to a `NOT NULL` column; `CampaignUser` has a `beforeDelete` that removes the user's credentials in the same transaction. E2E uses a CDP virtual authenticator (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`), which also makes `isUserVerifyingPlatformAuthenticatorAvailable()` answer `true`.
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; `20260728_041958_add_campaign_webauthn_credentials` added the WebAuthn credential table; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.
## Campaign Municípios model (2026-07-23 remodel)
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `campaignWebAuthnCredential` (biometric login — see "Campaign auth"), `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Engagement level (E14):** `municipality.engagementLevel` (`n0…n4`, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. Only `isCampaignUnrestricted` actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an `allocationDecision` (`outcome: 'movimento'`) in the same transaction under an advisory lock. Hysteresis rules are pure (`src/lib/engagementLevel.ts`) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill.
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
@ -256,7 +261,7 @@ src/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Users, CampaignUser, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, Activity, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── collections/ # Users, CampaignUser, CampaignWebAuthnCredential, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, Activity, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── globals/ # Global configs (SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals)
├── components/ # campaign/<domínio>/ (municipality, activity, supporter, …, shared, shell — Pass 2 W2), ui from shadcn
├── lib/ # cities.ts, bahiaTerritories.ts, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, municipalityCatalog.ts, electionAggregates/ (committed TSE artifact) + bahiaElectionAggregates.ts, formData.ts, zod schemas (lib/schemas/*)

View file

@ -1,7 +1,7 @@
# Login com biometria (WebAuthn) em `/campanha`
Status: rascunho
Atualizado em: 2026-07-26
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B40)
Impeccable: C — fluxo novo na tela de login + registro pós-primeiro-login; brief compacto
Appetite: ~1,5–2 dias eng; WebAuthn platform authenticator + storage server-side + UI login/perfil; migration leve
@ -49,10 +49,29 @@ Não há WebAuthn / passkey / Credential Management de produto no código hoje.
- **Login biométrico emite o TTL longo (B39 "lembrar")** — o enrollment no dispositivo _é_ o consentimento de confiança. **Rejeitado:** TTL curto no biométrico (derrota o job); exigir checkbox remember no fluxo biométrico.
- **i18n e naming:** `campaignWebAuthn`, `passkey`/`webauthnCredential` nos identificadores; copy pt-BR ("Entrar com digital ou Face ID", "Ativar neste aparelho").
## Questões em aberto
## Revisão na entrega (2026-07-28)
- **Collection nova `campaignWebAuthnCredential` vs array no `campaignUser`?** **Opções:** A) collection join (1:N, admin-visível, delete fácil) | B) array no user. **Recomendação:** A — contadores WebAuthn e revogação por dispositivo ficam limpos; access `create/read/delete` = self. _(assumido.)_
- **Mostrar o botão biométrico antes de digitar o identificador (discoverable credentials) ou depois?** **Opções:** A) botão sempre visível se o browser reportar platform authenticator + há credencial no device | B) só após blur do identificador. **Recomendação:** A com conditional UI (`PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable`).
As seis lacunas que a auditoria pré-implementação abriu, e como cada uma fechou:
1. **Como o token é forjado sem senha.** Não existe caminho password-less no Payload 3.82.0 — `loginOperation` exige `password`. `issueCampaignWebAuthnSession` ([src/utilities/campaignWebAuthnSession.ts](../../src/utilities/campaignWebAuthnSession.ts)) monta a sessão só com exports públicos: `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (de `payload/shared`) → `getFieldsToSign` + `jwtSign` → `setCampaignAuthCookie`.
2. **A mina, confirmada e neutralizada.** `addSessionToUser` reescreve o documento inteiro do usuário. Carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` de [CampaignUser](../../src/collections/CampaignUser.ts) e gravaria de volta um usuário sem `sessions` nem `email`, derrubando **todas as outras sessões** e transformando `avatar` em objeto. O `db.findOne` cru está no código com o comentário nomeando o perigo, e `campaignWebAuthn.int.spec.ts` prova que uma sessão pré-existente sobrevive ao mint.
3. **Concorrência.** O mint roda em `withPayloadTransaction` + `acquireTextAdvisoryLocks(['campaign-webauthn-session:{id}'])`, precedente do E14.
4. **`getFieldsToSign` exige `email: string`.** Conta de liderança não tem e-mail; passamos `user.email ?? ''`, claim decorativa (`payload.auth` relê o usuário do banco).
5. **RP ID / origem — e o furo que a auditoria não previu.** `getCampaignWebAuthnRelyingParty` deriva `rpID`/`origin` de `getCampaignInviteBaseURL` e devolve `null` quando ela falha. Mas isso **não bastava**: num preview da Vercel a função devolvia a origem canônica com sucesso, o botão aparecia, e o browser estourava `SecurityError` porque o host servindo a página é outro. A função agora compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem — a UI se esconde onde a cerimônia não pode funcionar, que era a intenção declarada.
6. **Challenge em cookie HMAC**, path `/campanha`, 5 min, apagado na verificação (molde de `supporterImportToken.ts`). **A armadilha custou uma rodada de e2e:** a primeira versão gerava o challenge por conta própria e deixava o `@simplewebauthn/server` gerar o dele, então o valor no cookie e o valor assinado pelo autenticador eram bytes diferentes e todo `register` respondia 400. O helper agora recebe o challenge que `generateRegistrationOptions`/`generateAuthenticationOptions` já produziram e guarda **aquela** string base64url, verbatim.
Outros achados da entrega:
- **Deleção de `campaignUser` quebrava.** A FK que o Payload gera é `ON DELETE SET NULL` sobre um `user_id` `NOT NULL`. Em vez de editar a migration, `CampaignUser` ganhou um `beforeDelete` que apaga as credenciais do usuário — cascade explícito, na mesma transação.
- **Mensagem de conta bloqueada.** `checkLoginPermission` lança o erro genérico do Payload; o mint captura e relança a cópia pt-BR, para que o usuário trancado saiba que é bloqueio e não falha de biometria.
- **Anúncio duplicado no leitor de tela.** O erro vivia no `FieldError` **e** na região `aria-live` do botão; o `sr-only` agora só anuncia o estado pendente.
- **Medição de bundle:** `@simplewebauthn/browser` adiciona **3,0 kB** gzip (orçamento do plano: 10 kB), com `/campanha/login` em **130 kB** de First Load JS. Sem débito registrado.
- **e2e com autenticador virtual CDP** cobre cadastrar → sair → entrar com biometria → revogar. Para isso o fixture ganhou `expectedRequestFailurePaths`: o teste da credencial revogada provoca um 4xx de propósito, e o guard global de falhas tratava o `console.error` do fetch como erro não tratado.
## Questões em aberto (fechadas na entrega)
- **Collection nova `campaignWebAuthnCredential` vs array no `campaignUser`?** **Opções:** A) collection join (1:N, admin-visível, delete fácil) | B) array no user. **Recomendação:** A — contadores WebAuthn e revogação por dispositivo ficam limpos; access `create/read/delete` = self. **Entregue como A.**
- **Mostrar o botão biométrico antes de digitar o identificador (discoverable credentials) ou depois?** **Opções:** A) botão sempre visível se o browser reportar platform authenticator + há credencial no device | B) só após blur do identificador. **Recomendação:** A com conditional UI (`PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable`). **Entregue como A**, com um segundo gate no servidor (o RP tem de estar configurado para o host que serve a página).
## Abordagem proposta

File diff suppressed because one or more lines are too long

View file

@ -71,6 +71,8 @@
"@payloadcms/richtext-lexical": "3.82.0",
"@payloadcms/storage-vercel-blob": "3.82.0",
"@payloadcms/ui": "3.82.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"class-variance-authority": "^0.7.1",

View file

@ -38,6 +38,12 @@ importers:
'@payloadcms/ui':
specifier: 3.82.0
version: 3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@simplewebauthn/browser':
specifier: ^13.3.0
version: 13.3.0
'@simplewebauthn/server':
specifier: ^13.3.2
version: 13.3.2
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)
@ -1170,6 +1176,9 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@ -1533,6 +1542,9 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@lexical/clipboard@0.41.0':
resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
@ -2056,6 +2068,46 @@ packages:
react: ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
'@peculiar/asn1-android@2.8.0':
resolution: {integrity: sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==}
'@peculiar/asn1-cms@2.8.0':
resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==}
'@peculiar/asn1-csr@2.8.0':
resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==}
'@peculiar/asn1-ecc@2.8.0':
resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==}
'@peculiar/asn1-pfx@2.8.0':
resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==}
'@peculiar/asn1-pkcs8@2.8.0':
resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==}
'@peculiar/asn1-pkcs9@2.8.0':
resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==}
'@peculiar/asn1-rsa@2.8.0':
resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==}
'@peculiar/asn1-schema@2.8.0':
resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
'@peculiar/asn1-x509-attr@2.8.0':
resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==}
'@peculiar/asn1-x509@2.8.0':
resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==}
'@peculiar/utils@2.0.3':
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
'@peculiar/x509@1.14.3':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -2916,6 +2968,13 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@simplewebauthn/browser@13.3.0':
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
'@simplewebauthn/server@13.3.2':
resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==}
engines: {node: '>=20.0.0'}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
@ -3734,6 +3793,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1js@3.0.10:
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
engines: {node: '>=12.0.0'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -6283,6 +6346,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
qs-esm@8.0.1:
resolution: {integrity: sha512-eZ7l+0ZVy3He9c85pEM9KEBR9DFA4jrmWvIjm9wpcHvScwc/vgZDl2TNOF0pm0JsWKw24XBUZOY0Wxn7/nvJnw==}
engines: {node: '>=18'}
@ -6429,6 +6499,9 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -7010,6 +7083,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -7018,6 +7094,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@ -8275,6 +8355,8 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@hexagon/base64@1.1.28': {}
'@hono/node-server@1.19.11(hono@4.12.7)':
dependencies:
hono: 4.12.7
@ -8522,6 +8604,8 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
'@levischuck/tiny-cbor@0.2.11': {}
'@lexical/clipboard@0.41.0':
dependencies:
'@lexical/html': 0.41.0
@ -9204,6 +9288,106 @@ snapshots:
- supports-color
- typescript
'@peculiar/asn1-android@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-cms@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/asn1-x509-attr': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-csr@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-ecc@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pfx@2.8.0':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-pkcs8': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.8.0':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-pfx': 2.8.0
'@peculiar/asn1-pkcs8': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/asn1-x509-attr': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-rsa@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-schema@2.8.0':
dependencies:
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-x509@2.8.0':
dependencies:
'@peculiar/asn1-schema': 2.8.0
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/utils@2.0.3':
dependencies:
tslib: 2.8.1
'@peculiar/x509@1.14.3':
dependencies:
'@peculiar/asn1-cms': 2.8.0
'@peculiar/asn1-csr': 2.8.0
'@peculiar/asn1-ecc': 2.8.0
'@peculiar/asn1-pkcs9': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@pinojs/redact@0.4.0': {}
'@playwright/test@1.58.2':
@ -10058,6 +10242,19 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@simplewebauthn/browser@13.3.0': {}
'@simplewebauthn/server@13.3.2':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.8.0
'@peculiar/asn1-ecc': 2.8.0
'@peculiar/asn1-rsa': 2.8.0
'@peculiar/asn1-schema': 2.8.0
'@peculiar/asn1-x509': 2.8.0
'@peculiar/x509': 1.14.3
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
@ -10839,6 +11036,12 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1js@3.0.10:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
assertion-error@2.0.1: {}
ast-module-types@6.0.2: {}
@ -13704,6 +13907,12 @@ snapshots:
punycode@2.3.1: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
qs-esm@8.0.1: {}
qs@6.15.0:
@ -13906,6 +14115,8 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
reflect-metadata@0.2.2: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -14628,6 +14839,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.21.0:
@ -14637,6 +14850,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
tw-animate-css@1.4.0: {}
type-check@0.4.0:

View file

@ -1,7 +1,13 @@
import { cookies } from 'next/headers'
import config from '@payload-config'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import {
BiometricEnrollmentToast,
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
@ -13,8 +19,11 @@ import {
SidebarTrigger,
} from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { loadCampaignPasskeys } from '@/utilities/campaignWebAuthnCeremony'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig'
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
const user = await getCampaignUser()
@ -28,6 +37,27 @@ export default async function CampaignAppLayout({ children }: { children: React.
const hasSidebarCookie = sidebarStateCookie !== undefined
const defaultOpen = sidebarStateCookie ? sidebarStateCookie.value === 'true' : true
// B40 discovery. The relying party is decided from headers alone, so it is
// resolved first and this layout — which runs on every authenticated
// navigation — reads no passkeys at all on an origin that cannot host a
// ceremony. Whether THIS device is already enrolled is a question only the
// browser can answer, so the island finishes the decision.
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
let biometricEnrollment: BiometricEnrollmentOffer | null = null
if (relyingParty) {
const payload = await getPayload({ config })
const [passkeys, requestHeaders] = await Promise.all([
loadCampaignPasskeys(payload, user.id),
headers(),
])
biometricEnrollment = {
// Only the emptiness crosses the boundary: the device labels have no
// business in the RSC payload of every page.
hasEnrolledPasskeys: passkeys.length > 0,
suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')),
}
}
return (
// print: unlock the h-svh/overflow-hidden shells and drop the app chrome,
// otherwise only the first page of the municipality dossier prints (E16).
@ -59,6 +89,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignBottomNav role={user.role} />
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</SidebarInset>
</SidebarProvider>
)

View file

@ -1,9 +1,16 @@
import config from '@payload-config'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProfileSettings'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { loadCampaignPasskeys } from '@/utilities/campaignWebAuthnCeremony'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig'
export const metadata: Metadata = {
title: 'Meu perfil | Campanha',
@ -24,6 +31,13 @@ export default async function CampaignProfilePage({ searchParams }: CampaignProf
redirect('/campanha/login')
}
const payload = await getPayload({ config })
const [passkeys, relyingParty, requestHeaders] = await Promise.all([
loadCampaignPasskeys(payload, user.id),
resolveCampaignWebAuthnRelyingParty(),
headers(),
])
return (
<CampaignProfileSettings
user={{
@ -32,6 +46,15 @@ export default async function CampaignProfilePage({ searchParams }: CampaignProf
username: user.username,
}}
passwordResetBanner={params.passwordReset === '1'}
// Passed as a slot so the settings component stays a client island for
// its forms while this card owns its own state next to it.
biometricsSlot={
<CampaignPasskeysCard
passkeys={passkeys}
biometricsConfigured={relyingParty !== null}
suggestedDeviceLabel={deviceLabelFromUserAgent(requestHeaders.get('user-agent'))}
/>
}
/>
)
}

View file

@ -0,0 +1,43 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
CAMPAIGN_BIOMETRIC_REMOVE_ERROR_MESSAGE,
CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE,
} from '@/lib/campaignAuthCopy'
import type { CampaignPasskeyRemoveInput } from '@/lib/schemas/campaignWebAuthn'
import { campaignPasskeyRemoveSchema } from '@/lib/schemas/campaignWebAuthn'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
import {
CAMPAIGN_PASSKEY_REMOVE_DENIED_MESSAGE,
removeCampaignPasskey,
} from '@/utilities/campaignWebAuthnCeremony'
/**
* Revokes one enrolled device. The delete runs with `overrideAccess: false`, so
* the owner-only row constraint on `campaignWebAuthnCredential` — not this
* action — is what refuses somebody else's passkey.
*
* The return type is annotated, not inferred: `runCampaignFormAction` types its
* failure branch without a `status` key at all, so the caller can only narrow on
* `status === 'success'` through `CampaignFormActionState`, where it is optional.
*/
export const removeCampaignPasskeyAction = async (
input: CampaignPasskeyRemoveInput,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const { passkeyId } = campaignPasskeyRemoveSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
await removeCampaignPasskey(payload, actor, passkeyId)
revalidatePath('/campanha/perfil')
return { message: CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE }
},
safeMessages: [CAMPAIGN_PASSKEY_REMOVE_DENIED_MESSAGE],
genericMessage: CAMPAIGN_BIOMETRIC_REMOVE_ERROR_MESSAGE,
})

View file

@ -5,6 +5,7 @@ import { useActionState, useState } from 'react'
import { loginCampaignFormAction, type LoginResult } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignAuthCardHeader } from '@/components/campaign/auth/CampaignAuthCardHeader'
import { CampaignBiometricLoginButton } from '@/components/campaign/auth/CampaignBiometricLoginButton'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
@ -39,7 +40,12 @@ const identifierInputMode = (value: string): 'tel' | 'email' | 'text' => {
return 'text'
}
export const LoginForm = () => {
type LoginFormProps = {
/** False when the origin cannot host a WebAuthn ceremony (Vercel previews). */
biometricsConfigured: boolean
}
export const LoginForm = ({ biometricsConfigured }: LoginFormProps) => {
const [state, formAction, pending] = useActionState(
loginCampaignFormAction,
{} satisfies LoginResult,
@ -132,6 +138,9 @@ export const LoginForm = () => {
</details>
</FieldGroup>
</form>
{/* Outside the form: this is a second, independent way in, and nesting
it would make it look like a submit path of the password fields. */}
{biometricsConfigured ? <CampaignBiometricLoginButton /> : null}
</CardContent>
</Card>
)

View file

@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'
import { CampaignAuthPageShell } from '@/components/campaign/auth/CampaignAuthPageShell'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig'
import { LoginForm } from './LoginForm'
export const metadata: Metadata = {
@ -20,9 +21,13 @@ export default async function CampaignLoginPage() {
redirect('/campanha')
}
// Half of the gate: this origin can host the ceremony at all. The other half
// (does this device have a fingerprint reader) only the browser knows.
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
return (
<CampaignAuthPageShell>
<LoginForm />
<LoginForm biometricsConfigured={relyingParty !== null} />
</CampaignAuthPageShell>
)
}

View file

@ -0,0 +1,36 @@
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server'
import { NextResponse } from 'next/server'
import type { CampaignWebAuthnOptionsResponse } from '@/lib/campaignWebAuthn'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import {
buildCampaignAuthenticationOptions,
campaignWebAuthnSafeMessages,
} from '@/utilities/campaignWebAuthnCeremony'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
type ResponseBody = CampaignWebAuthnOptionsResponse<PublicKeyCredentialRequestOptionsJSON>
/**
* Anonymous by design: the challenge is issued before anybody is identified, and
* because the passkey is discoverable the response carries no `allowCredentials`
* — so this endpoint cannot be used to probe whether an account exists.
*/
export async function POST(request: Request): Promise<NextResponse<ResponseBody>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
try {
const options = await buildCampaignAuthenticationOptions()
return NextResponse.json({ status: 'success', options })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar a entrada por biometria. Use sua senha.',
})
}
}

View file

@ -0,0 +1,56 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import type { CampaignWebAuthnLoginResponse } from '@/lib/campaignWebAuthn'
import { campaignWebAuthnLoginSchema } from '@/lib/schemas/campaignWebAuthn'
import { setCampaignAuthCookie } from '@/utilities/campaignAuth'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import {
campaignWebAuthnSafeMessages,
completeCampaignAuthentication,
} from '@/utilities/campaignWebAuthnCeremony'
import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/campaignWebAuthnChallenge'
import {
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
} from '@/utilities/campaignWebAuthnSession'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* Password-less sign-in. Anonymous on the way in and holding a `campaign-token`
* on the way out, with the same 14-day TTL the "Lembrar de mim" checkbox grants
* — enrolling the passkey on this device *is* the trust opt-in.
*/
export async function POST(request: Request): Promise<NextResponse<CampaignWebAuthnLoginResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { credential } = campaignWebAuthnLoginSchema.parse(parsed.body)
const payload = await getPayload({ config })
const { token, tokenExpiration } = await completeCampaignAuthentication(payload, credential)
await setCampaignAuthCookie(token, payload, tokenExpiration)
return NextResponse.json({ status: 'success', redirectTo: '/campanha' })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [
...campaignWebAuthnSafeMessages,
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
],
genericMessage: 'Não foi possível entrar com a biometria. Use sua senha.',
})
}
}

View file

@ -0,0 +1,38 @@
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/server'
import { NextResponse } from 'next/server'
import type { CampaignWebAuthnOptionsResponse } from '@/lib/campaignWebAuthn'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import {
buildCampaignRegistrationOptions,
campaignWebAuthnSafeMessages,
} from '@/utilities/campaignWebAuthnCeremony'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
type ResponseBody = CampaignWebAuthnOptionsResponse<PublicKeyCredentialCreationOptionsJSON>
/**
* Opens the enrollment ceremony for the signed-in account. Authenticated, so it
* lives here rather than in `(app)`: two of the four WebAuthn routes are
* anonymous by nature and the `/campanha` cookie path covers all of them.
*/
export async function POST(request: Request): Promise<NextResponse<ResponseBody>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
try {
const { payload, actor } = await getCampaignActionContext()
const options = await buildCampaignRegistrationOptions(payload, actor)
return NextResponse.json({ status: 'success', options })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar o cadastro da biometria. Tente novamente.',
})
}
}

View file

@ -0,0 +1,49 @@
import { NextResponse } from 'next/server'
import {
CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE,
} from '@/lib/campaignAuthCopy'
import type { CampaignWebAuthnRegisterResponse } from '@/lib/campaignWebAuthn'
import { campaignWebAuthnRegisterSchema } from '@/lib/schemas/campaignWebAuthn'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import {
campaignWebAuthnSafeMessages,
completeCampaignRegistration,
} from '@/utilities/campaignWebAuthnCeremony'
import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/campaignWebAuthnChallenge'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
export async function POST(
request: Request,
): Promise<NextResponse<CampaignWebAuthnRegisterResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { credential, deviceLabel } = campaignWebAuthnRegisterSchema.parse(parsed.body)
const { payload, actor } = await getCampaignActionContext()
const passkey = await completeCampaignRegistration(payload, actor, { credential, deviceLabel })
return NextResponse.json({
status: 'success',
message: CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE,
passkey,
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [...campaignWebAuthnSafeMessages, CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE],
genericMessage: CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
})
}
}

View file

@ -17,6 +17,7 @@ import {
APIError,
type CollectionAfterReadHook,
type CollectionBeforeChangeHook,
type CollectionBeforeDeleteHook,
type CollectionConfig,
} from 'payload'
@ -95,6 +96,26 @@ const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
return data
}
/**
* Revokes the account's passkeys (roadmap B40) before it is deleted. This is a
* hook rather than an `ON DELETE cascade` because Payload derives FK actions
* from the field config, so a hand-written cascade in the migration is drift the
* next `migrate:create` reverts. `campaign_web_authn_credential.user_id` is NOT
* NULL with `ON DELETE set null`, so without this the delete fails and the
* account becomes undeletable the moment it enrolled a passkey. Note that the
* test fixtures rely on it too: `campaignWebAuthnCredential` is the one
* collection with no `owned` entry, because `deleteOwned('campaignUser')`
* reaches it through here.
*/
const deleteCampaignUserPasskeys: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: id } },
overrideAccess: true,
req,
})
}
export const CampaignUser: CollectionConfig = {
slug: 'campaignUser',
labels: {
@ -148,6 +169,7 @@ export const CampaignUser: CollectionConfig = {
},
hooks: {
beforeChange: [preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields],
beforeDelete: [deleteCampaignUserPasskeys],
afterRead: [removePrivateAuthFields],
},
fields: [

View file

@ -0,0 +1,98 @@
import type { CollectionConfig } from 'payload'
import { CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH } from '@/lib/campaignWebAuthn'
import {
canDeleteOwnWebAuthnCredentials,
canReadOwnWebAuthnCredentials,
canWriteWebAuthnCredentials,
} from '@/utilities/campaignAccess'
/**
* A passkey enrolled on one device for one `campaignUser` (roadmap B40). Only
* the credential's public half is stored — the private key never leaves the
* device's secure element and the fingerprint/face template never leaves the
* OS, which is why biometric login opens no LGPD art. 11 question and needs no
* `Consent` key.
*
* Writes happen exclusively inside the verified `/campanha/webauthn/*`
* ceremonies with `overrideAccess: true`; reads and deletes are the owner's
* only (see `utilities/access/webauthnCredentials.ts`).
*/
export const CampaignWebAuthnCredential: CollectionConfig = {
slug: 'campaignWebAuthnCredential',
labels: {
singular: 'Acesso por biometria',
plural: 'Acessos por biometria',
},
admin: {
group: 'Campanha',
useAsTitle: 'deviceLabel',
defaultColumns: ['deviceLabel', 'user', 'lastUsedAt', 'createdAt'],
description:
'Chaves públicas dos aparelhos que entram com digital ou Face ID. Nenhum dado biométrico é armazenado.',
},
access: {
create: canWriteWebAuthnCredentials,
read: canReadOwnWebAuthnCredentials,
update: canWriteWebAuthnCredentials,
delete: canDeleteOwnWebAuthnCredentials,
},
fields: [
{
name: 'user',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Usuário',
required: true,
index: true,
},
{
name: 'credentialId',
type: 'text',
label: 'ID da credencial',
required: true,
unique: true,
index: true,
admin: { readOnly: true },
},
{
name: 'publicKey',
type: 'text',
label: 'Chave pública (base64url)',
required: true,
admin: { readOnly: true },
},
{
/**
* Authenticator signature counter. A response whose counter did not
* advance past the stored one is a replay (or a cloned authenticator),
* and the ceremony refuses it.
*/
name: 'counter',
type: 'number',
label: 'Contador de assinaturas',
required: true,
defaultValue: 0,
admin: { readOnly: true },
},
{
name: 'transports',
type: 'json',
label: 'Transportes',
admin: { readOnly: true },
},
{
name: 'deviceLabel',
type: 'text',
label: 'Aparelho',
required: true,
maxLength: CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH,
},
{
name: 'lastUsedAt',
type: 'date',
label: 'Último uso',
admin: { readOnly: true },
},
],
}

View file

@ -0,0 +1,88 @@
'use client'
import { FingerprintIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useCampaignBiometricsAvailable } from '@/components/campaign/auth/useCampaignBiometricsAvailable'
import { Button } from '@/components/ui/button'
import { FieldError } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { CAMPAIGN_BIOMETRIC_LOGIN_LABEL } from '@/lib/campaignAuthCopy'
import { CampaignWebAuthnError } from '@/lib/campaignWebAuthnSupport'
const FALLBACK_ERROR_MESSAGE = 'Não foi possível entrar com a biometria. Use sua senha.'
/**
* Password-less sign-in on the login screen. It is mounted only when the server
* says the relying party is configured for this origin (Vercel previews are
* not) **and** the browser confirms a user-verifying platform authenticator
* exists — a button that can only fail is worse than no button, and this screen
* is the one place where a dead control reads as "the app is broken".
*/
export const CampaignBiometricLoginButton = () => {
const router = useRouter()
const available = useCampaignBiometricsAvailable()
const [pending, setPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
if (!available) return null
const signIn = async () => {
setPending(true)
setErrorMessage(null)
try {
// Loaded on demand so `@simplewebauthn/browser` stays out of the login
// screen's First Load JS — nothing here runs before this tap.
const { signInWithCampaignPasskey } = await import('@/lib/campaignWebAuthnClient')
const redirectTo = await signInWithCampaignPasskey()
// The cookie is already set by the route; the refresh is what makes the
// authenticated layout pick it up. `pending` stays true through the
// navigation so the button cannot be pressed twice mid-redirect.
router.replace(redirectTo)
router.refresh()
return
} catch (error) {
// A cancelled prompt is a decision, not a failure: say it plainly and
// leave the password form untouched.
setErrorMessage(
error instanceof CampaignWebAuthnError ? error.message : FALLBACK_ERROR_MESSAGE,
)
}
setPending(false)
}
return (
// The spacing lives here, not on a wrapper in the form: this island renders
// nothing when the device has no authenticator, and an empty wrapper would
// still push the "Primeiro acesso?" line down.
<div className="mt-6 flex flex-col gap-2">
<div className="flex items-center gap-3" aria-hidden="true">
<span className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">ou</span>
<span className="h-px flex-1 bg-border" />
</div>
{errorMessage ? <FieldError>{errorMessage}</FieldError> : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
disabled={pending}
onClick={() => void signIn()}
>
{pending ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<FingerprintIcon data-icon="inline-start" aria-hidden="true" />
)}
<span>{pending ? 'Confirmando...' : CAMPAIGN_BIOMETRIC_LOGIN_LABEL}</span>
</Button>
{/* Pending only: the `FieldError` above is a `role="alert"`, so putting the
message here too made a screen reader say it twice. */}
<p className="sr-only" aria-live="polite">
{pending ? 'Confirmando a biometria.' : ''}
</p>
</div>
)
}

View file

@ -0,0 +1,231 @@
'use client'
import { FingerprintIcon, Trash2Icon } from 'lucide-react'
import { useEffect, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { removeCampaignPasskeyAction } from '@/app/(campaign)/campanha/actions/webauthn'
import { useCampaignBiometricsAvailable } from '@/components/campaign/auth/useCampaignBiometricsAvailable'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
CAMPAIGN_BIOMETRIC_ENROLL_LABEL,
CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE,
CAMPAIGN_BIOMETRIC_PRIVACY_NOTE,
CAMPAIGN_BIOMETRIC_REMOVE_ERROR_MESSAGE,
CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE,
CAMPAIGN_BIOMETRIC_SECTION_DESCRIPTION,
CAMPAIGN_BIOMETRIC_SECTION_TITLE,
} from '@/lib/campaignAuthCopy'
import {
CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH,
CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS,
type CampaignPasskeyView,
} from '@/lib/campaignWebAuthn'
import { CampaignWebAuthnError } from '@/lib/campaignWebAuthnSupport'
import { markCampaignBiometricsEnrolledHere } from '@/utilities/campaignBiometricsPrompt'
type CampaignPasskeysCardProps = {
passkeys: CampaignPasskeyView[]
/** False when this origin cannot host a ceremony (Vercel previews). */
biometricsConfigured: boolean
/** Suggested label, so the common case is one tap and no typing. */
suggestedDeviceLabel: string
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
const formatUse = (passkey: CampaignPasskeyView): string =>
passkey.lastUsedAt
? `Último uso em ${dateFormatter.format(new Date(passkey.lastUsedAt))}`
: `Cadastrado em ${dateFormatter.format(new Date(passkey.createdAt))} · ainda não usado`
/**
* The permanent home of biometric access (B40). It is the card, not the toast,
* that has to work: the toast is discoverability and can be dismissed forever,
* while revocation — the answer to "meu celular sumiu" — must always be
* findable in the profile.
*/
export const CampaignPasskeysCard = ({
passkeys,
biometricsConfigured,
suggestedDeviceLabel,
}: CampaignPasskeysCardProps) => {
// Optimistic list. Enrollment goes through a route handler, which revalidates
// nothing, so this is the only thing that renders the new row until the next
// navigation; removal is a server action and does revalidate, and the effect
// below is what lets the server's answer win.
const [items, setItems] = useState(passkeys)
const [deviceLabel, setDeviceLabel] = useState(suggestedDeviceLabel)
const [enrolling, setEnrolling] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [removingId, setRemovingId] = useState<number | null>(null)
const [, startTransition] = useTransition()
const platformAvailable = useCampaignBiometricsAvailable()
useEffect(() => setItems(passkeys), [passkeys])
const canEnroll = biometricsConfigured && platformAvailable
const atLimit = items.length >= CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS
const enroll = async () => {
const label = deviceLabel.trim()
if (!label || enrolling) return
setEnrolling(true)
setErrorMessage(null)
setStatusMessage(null)
try {
// Loaded on demand: this card renders for everyone who opens the profile,
// but only a tap here needs `@simplewebauthn/browser`.
const { enrollCampaignPasskey } = await import('@/lib/campaignWebAuthnClient')
const passkey = await enrollCampaignPasskey(label)
setItems((current) => [passkey, ...current])
setStatusMessage(CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE)
// Only this browser can know that THIS device is enrolled — the server
// only ever sees a credential count — so the prompt is silenced here.
markCampaignBiometricsEnrolledHere()
} catch (error) {
setErrorMessage(
error instanceof CampaignWebAuthnError
? error.message
: CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
)
} finally {
setEnrolling(false)
}
}
const remove = (passkey: CampaignPasskeyView) => {
setRemovingId(passkey.id)
setErrorMessage(null)
setStatusMessage(null)
startTransition(async () => {
const result = await removeCampaignPasskeyAction({ passkeyId: passkey.id })
setRemovingId(null)
if (result.status === 'success') {
setItems((current) => current.filter((entry) => entry.id !== passkey.id))
setStatusMessage(CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE)
// Toast as well as the live region: removing the device you are holding
// is the one action here whose confirmation must survive a navigation.
toast.success(CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE)
return
}
// A rejected id maps to `fieldErrors` with no message, and this card has
// no field to hang it on.
setErrorMessage(result.message ?? CAMPAIGN_BIOMETRIC_REMOVE_ERROR_MESSAGE)
})
}
return (
<Card>
<CardHeader>
<CardTitle>{CAMPAIGN_BIOMETRIC_SECTION_TITLE}</CardTitle>
<CardDescription>{CAMPAIGN_BIOMETRIC_SECTION_DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{items.length > 0 ? (
<ul className="flex flex-col gap-2">
{items.map((passkey) => (
<li
key={passkey.id}
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
<FingerprintIcon
className="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{passkey.deviceLabel}</p>
<p className="text-xs text-muted-foreground">{formatUse(passkey)}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
disabled={removingId === passkey.id}
aria-label={`Remover ${passkey.deviceLabel}`}
onClick={() => remove(passkey)}
>
{removingId === passkey.id ? (
<Spinner className="size-4" aria-hidden="true" />
) : (
<Trash2Icon className="size-4" aria-hidden="true" />
)}
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum aparelho cadastrado ainda.</p>
)}
{/* The enrollment form is absent — not disabled — when the platform
cannot do it: there is nothing the person could change here to make
a desktop without a reader work, and the list above is still the
useful half (they may revoke a phone from this very screen). */}
{canEnroll && !atLimit ? (
<div className="flex flex-col gap-3 border-t border-border pt-4">
<Field>
<FieldLabel htmlFor="passkey-device-label">Nome deste aparelho</FieldLabel>
<Input
id="passkey-device-label"
value={deviceLabel}
onChange={(event) => setDeviceLabel(event.target.value)}
maxLength={CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH}
className="min-h-11"
placeholder="Meu celular"
/>
<FieldDescription>
Serve para você reconhecer o aparelho nesta lista e removê-lo se perdê-lo.
</FieldDescription>
</Field>
<Button
type="button"
className="min-h-11 self-start"
disabled={enrolling || deviceLabel.trim().length === 0}
onClick={() => void enroll()}
>
{enrolling ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<FingerprintIcon data-icon="inline-start" aria-hidden="true" />
)}
<span>{enrolling ? 'Confirmando...' : CAMPAIGN_BIOMETRIC_ENROLL_LABEL}</span>
</Button>
</div>
) : null}
{atLimit ? (
<p className="text-sm text-muted-foreground">
Você já cadastrou {CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS} aparelhos. Remova um para
cadastrar outro.
</p>
) : null}
{errorMessage ? <FieldError>{errorMessage}</FieldError> : null}
<p className="text-xs text-muted-foreground">{CAMPAIGN_BIOMETRIC_PRIVACY_NOTE}</p>
{/* Success only: `FieldError` above is a `role="alert"`, so putting the
failure here too would announce it twice. Cleared on the next
attempt, so it never reads a stale success. */}
<p className="sr-only" aria-live="polite">
{enrolling ? 'Confirmando a biometria.' : (statusMessage ?? '')}
</p>
</CardContent>
</Card>
)
}

View file

@ -1,6 +1,6 @@
'use client'
import { useActionState } from 'react'
import { useActionState, type ReactNode } from 'react'
import { changeCampaignPasswordFormAction } from '@/app/(campaign)/campanha/actions/password'
import {
@ -24,6 +24,8 @@ type CampaignProfileSettingsProps = {
username?: string | null
}
passwordResetBanner?: boolean
/** Server-composed biometric card (B40), rendered between photo and password. */
biometricsSlot: ReactNode
}
const FormActionStatus = ({ state }: { state: CampaignFormActionState }) => {
@ -44,6 +46,7 @@ const FormActionStatus = ({ state }: { state: CampaignFormActionState }) => {
export const CampaignProfileSettings = ({
user,
passwordResetBanner = false,
biometricsSlot,
}: CampaignProfileSettingsProps) => {
const [passwordState, passwordAction, passwordPending] = useActionState(
changeCampaignPasswordFormAction,
@ -63,7 +66,7 @@ export const CampaignProfileSettings = ({
<div>
<h1 className="text-2xl font-semibold tracking-tight">Meu perfil</h1>
<p className="text-sm text-muted-foreground">
Gerencie sua foto e senha de acesso à ferramenta de campanha.
Gerencie sua foto, sua senha e a entrada por biometria.
</p>
</div>
@ -149,6 +152,8 @@ export const CampaignProfileSettings = ({
</CardContent>
</Card>
{biometricsSlot}
<Card>
<CardHeader>
<CardTitle>Alterar senha</CardTitle>

View file

@ -0,0 +1,28 @@
'use client'
import { useEffect, useState } from 'react'
import { canUseCampaignBiometrics } from '@/lib/campaignWebAuthnSupport'
/**
* Whether this browser can run a platform ceremony, resolved after mount
* because the probe is async and the server cannot answer it. Starts `false` so
* nothing biometric renders during hydration on a device that has no
* authenticator; the cancellation guard is the reason this is shared rather than
* written per island (B40 has two).
*/
export const useCampaignBiometricsAvailable = (): boolean => {
const [available, setAvailable] = useState(false)
useEffect(() => {
let active = true
void canUseCampaignBiometrics().then((supported) => {
if (active) setAvailable(supported)
})
return () => {
active = false
}
}, [])
return available
}

View file

@ -0,0 +1,113 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { toast } from 'sonner'
import {
CAMPAIGN_BIOMETRIC_ENROLL_LABEL,
CAMPAIGN_BIOMETRIC_TOAST_DESCRIPTION,
CAMPAIGN_BIOMETRIC_TOAST_TITLE,
} from '@/lib/campaignAuthCopy'
import { CampaignWebAuthnError, canUseCampaignBiometrics } from '@/lib/campaignWebAuthnSupport'
import {
dismissCampaignBiometricsPrompt,
forgetCampaignBiometricsEnrollment,
markCampaignBiometricsEnrolledHere,
wasCampaignBiometricsEnrolledHere,
wasCampaignBiometricsPromptDismissed,
} from '@/utilities/campaignBiometricsPrompt'
const TOAST_ID = 'campaign-biometric-enrollment'
export type BiometricEnrollmentOffer = {
/** Whether the ACCOUNT has any passkey — never whether THIS device does. */
hasEnrolledPasskeys: boolean
/** Default label for the credential, so accepting the offer needs no typing. */
suggestedDeviceLabel: string
}
/**
* Discovery for biometric login, modeled on `InstallPwaToast`: nobody opens the
* profile looking for a feature they have never heard of. It is an offer, so it
* is dismissible forever (per device, in `localStorage`) and the permanent home
* stays the profile card. Mounted only where a ceremony is possible at all — the
* layout does not render it when the origin has no relying party.
*/
export const BiometricEnrollmentToast = ({
hasEnrolledPasskeys,
suggestedDeviceLabel,
}: BiometricEnrollmentOffer) => {
const router = useRouter()
useEffect(() => {
// An account with no passkeys at all is the signal that this browser's
// "enrolled here" flag is stale — otherwise a person who lost every passkey
// would never be offered enrollment again on the phone they still hold.
if (!hasEnrolledPasskeys) forgetCampaignBiometricsEnrollment()
if (wasCampaignBiometricsEnrolledHere() || wasCampaignBiometricsPromptDismissed()) return
let cancelled = false
const offer = async () => {
try {
// Loaded on demand: `@simplewebauthn/browser` has no business in the
// First Load JS of every authenticated route this toast mounts on.
const { enrollCampaignPasskey } = await import('@/lib/campaignWebAuthnClient')
await enrollCampaignPasskey(suggestedDeviceLabel)
markCampaignBiometricsEnrolledHere()
toast.dismiss(TOAST_ID)
toast.success('Pronto. Na próxima vez, entre com a biometria.')
// The profile card lists devices from the server, so it has to re-read.
router.refresh()
} catch (error) {
// A cancelled prompt leaves the toast up: the person may have fumbled
// the sheet, and this is not the moment to decide for them that they
// are not interested.
if (error instanceof CampaignWebAuthnError && error.cancelled) return
toast.dismiss(TOAST_ID)
toast.error(
error instanceof CampaignWebAuthnError
? error.message
: 'Não foi possível cadastrar este aparelho agora.',
)
}
}
// Same beat as the install toast: let the first paint land before asking
// for anything.
const timer = window.setTimeout(() => {
void canUseCampaignBiometrics().then((supported) => {
if (!supported || cancelled) return
toast.message(CAMPAIGN_BIOMETRIC_TOAST_TITLE, {
id: TOAST_ID,
description: CAMPAIGN_BIOMETRIC_TOAST_DESCRIPTION,
duration: Infinity,
closeButton: true,
onDismiss: dismissCampaignBiometricsPrompt,
action: {
label: CAMPAIGN_BIOMETRIC_ENROLL_LABEL,
onClick: (event) => {
// Sonner closes the toast on action unless the click is
// defaulted-out (verified in 2.0.7). Keeping it open is what lets
// a cancelled OS prompt land back on a visible offer instead of
// vanishing with no explanation.
event.preventDefault()
void offer()
},
},
})
})
}, 2000)
return () => {
cancelled = true
window.clearTimeout(timer)
toast.dismiss(TOAST_ID)
}
}, [hasEnrolledPasskeys, router, suggestedDeviceLabel])
return null
}

View file

@ -20,6 +20,62 @@ export const CAMPAIGN_ACCOUNT_LOCKED_MESSAGE =
export const CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE = 'E-mail, celular ou senha inválidos.'
// ---------------------------------------------------------------------------
// Biometric login (B40)
// ---------------------------------------------------------------------------
/**
* "Digital ou Face ID" instead of the accurate "chave de acesso"/"passkey":
* the label has to name what the person is about to do with their hand, and
* nobody in the field calls it a passkey.
*/
export const CAMPAIGN_BIOMETRIC_LOGIN_LABEL = 'Entrar com digital ou Face ID'
export const CAMPAIGN_BIOMETRIC_SECTION_TITLE = 'Acesso por biometria'
export const CAMPAIGN_BIOMETRIC_SECTION_DESCRIPTION =
'Entre com a digital ou o Face ID deste aparelho, sem digitar a senha.'
/**
* The one sentence that keeps "biometria" from being read as a biometric
* database. It is a legal statement as much as a reassurance: the fingerprint
* never leaves the device, so this opens no LGPD art. 11 question.
*/
export const CAMPAIGN_BIOMETRIC_PRIVACY_NOTE =
'Sua digital não sai do aparelho e não é enviada para a campanha — guardamos apenas uma chave de acesso deste aparelho.'
export const CAMPAIGN_BIOMETRIC_ENROLL_LABEL = 'Ativar neste aparelho'
export const CAMPAIGN_BIOMETRIC_TOAST_TITLE = 'Entre sem digitar a senha'
export const CAMPAIGN_BIOMETRIC_TOAST_DESCRIPTION = 'Use a digital ou o Face ID deste aparelho'
/** The prompt was dismissed by the person; not an error worth a red message. */
export const CAMPAIGN_BIOMETRIC_CANCELLED_MESSAGE = 'Confirmação cancelada.'
export const CAMPAIGN_BIOMETRIC_UNSUPPORTED_MESSAGE =
'Este aparelho não tem digital ou Face ID disponível para o navegador.'
/**
* Outcomes both halves have to be able to say. The browser reaches some of them
* without asking the server (the OS refuses a second credential for a device it
* already holds) and falls back to others when a route answers without a safe
* message, so a single spelling is what keeps the two from drifting apart.
*/
export const CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE = 'Este aparelho já está cadastrado.'
export const CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE =
'Aparelho cadastrado. Na próxima vez, entre com a biometria.'
export const CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE =
'Não foi possível cadastrar este aparelho. Tente novamente.'
export const CAMPAIGN_BIOMETRIC_REMOVED_MESSAGE =
'Aparelho removido. Ele não entra mais com biometria.'
export const CAMPAIGN_BIOMETRIC_REMOVE_ERROR_MESSAGE =
'Não foi possível remover este aparelho. Tente novamente.'
/**
* Card titles on public campaign auth screens (login / forgot / reset).
* `!text-center` beats `[data-theme='campaign'] h1 { text-align: left }` (app shell).

View file

@ -0,0 +1,48 @@
/**
* Client-safe contract for the biometric login (WebAuthn) ceremonies shared by
* the browser islands and the `/campanha/webauthn/*` route handlers. Values
* only — the relying-party resolution and every verification live in
* `server-only` modules.
*/
/**
* Ceiling on passkeys per account. High enough for phone + tablet + laptop,
* low enough that a stolen session cannot seed an unbounded set of silent
* back doors into the account.
*/
export const CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS = 5
/** Cap on the device label so the profile list cannot be used as free storage. */
export const CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH = 60
export const CAMPAIGN_WEBAUTHN_ROUTES = {
loginOptions: '/campanha/webauthn/login-options',
login: '/campanha/webauthn/login',
registerOptions: '/campanha/webauthn/register-options',
register: '/campanha/webauthn/register',
} as const
/** A passkey as the profile list renders it. */
export type CampaignPasskeyView = {
id: number
deviceLabel: string
createdAt: string
lastUsedAt: string | null
}
type CampaignWebAuthnErrorResponse = {
status: 'error'
message: string
}
export type CampaignWebAuthnOptionsResponse<Options> =
| { status: 'success'; options: Options }
| CampaignWebAuthnErrorResponse
export type CampaignWebAuthnRegisterResponse =
| { status: 'success'; message: string; passkey: CampaignPasskeyView }
| CampaignWebAuthnErrorResponse
export type CampaignWebAuthnLoginResponse =
| { status: 'success'; redirectTo: string }
| CampaignWebAuthnErrorResponse

View file

@ -0,0 +1,128 @@
/**
* Browser half of the WebAuthn ceremonies (B40). Client-safe: it only speaks
* `fetch` to the `/campanha/webauthn/*` routes and hands the JSON to
* `@simplewebauthn/browser`. Both islands (login button, profile card) call
* these, so the wire shape and the platform-error mapping exist once.
*
* Reached only through a dynamic `import()` from a handler — the library it
* pulls in must not ride along in the First Load JS of routes that will never
* run a ceremony. The probe and the error type live in `campaignWebAuthnSupport`
* for exactly that reason.
*/
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import {
CAMPAIGN_BIOMETRIC_CANCELLED_MESSAGE,
CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE,
CAMPAIGN_BIOMETRIC_UNSUPPORTED_MESSAGE,
} from '@/lib/campaignAuthCopy'
import {
CAMPAIGN_WEBAUTHN_ROUTES,
type CampaignPasskeyView,
type CampaignWebAuthnLoginResponse,
type CampaignWebAuthnOptionsResponse,
type CampaignWebAuthnRegisterResponse,
} from '@/lib/campaignWebAuthn'
import { CampaignWebAuthnError } from '@/lib/campaignWebAuthnSupport'
const postJson = async <Response>(path: string, body?: unknown): Promise<Response> => {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body ?? {}),
})
// A 409/403/500 from these routes still carries the typed envelope, so the
// discriminant — not the status — decides. A non-JSON error page (proxy,
// offline) is the only case that has to be invented here.
try {
return (await response.json()) as Response
} catch {
throw new Error('Resposta inválida do servidor.')
}
}
/**
* `navigator.credentials` rejects with DOM exceptions whose names are the only
* reliable signal; the messages are browser-authored and untranslated. Hence an
* unrecognized name returns a plain `Error`: the islands show their own copy for
* anything that is not a `CampaignWebAuthnError`, and the original text stays in
* the console instead of reaching a pt-BR screen in English.
*/
const asCeremonyError = (error: unknown): Error => {
const name = error instanceof Error ? error.name : ''
// NotAllowedError covers both "user closed the sheet" and "timed out", which
// are the same thing from where the person is standing.
if (name === 'NotAllowedError' || name === 'AbortError') {
return new CampaignWebAuthnError(CAMPAIGN_BIOMETRIC_CANCELLED_MESSAGE, { cancelled: true })
}
// InvalidStateError on create means this authenticator already holds a
// credential we excluded — i.e. the device is enrolled already.
if (name === 'InvalidStateError') {
return new CampaignWebAuthnError(CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE)
}
if (name === 'NotSupportedError' || name === 'SecurityError') {
return new CampaignWebAuthnError(CAMPAIGN_BIOMETRIC_UNSUPPORTED_MESSAGE)
}
return error instanceof Error ? error : new Error('Falha desconhecida na cerimônia WebAuthn.')
}
const requireOptions = <Options>(payload: CampaignWebAuthnOptionsResponse<Options>): Options => {
if (payload.status !== 'success') throw new CampaignWebAuthnError(payload.message)
return payload.options
}
/** Enrolls this device. Returns the passkey so the list can render it at once. */
export const enrollCampaignPasskey = async (deviceLabel: string): Promise<CampaignPasskeyView> => {
const options = requireOptions(
await postJson<CampaignWebAuthnOptionsResponse<PublicKeyCredentialCreationOptionsJSON>>(
CAMPAIGN_WEBAUTHN_ROUTES.registerOptions,
),
)
let credential
try {
credential = await startRegistration({ optionsJSON: options })
} catch (error) {
throw asCeremonyError(error)
}
const result = await postJson<CampaignWebAuthnRegisterResponse>(
CAMPAIGN_WEBAUTHN_ROUTES.register,
{ credential, deviceLabel },
)
if (result.status !== 'success') throw new CampaignWebAuthnError(result.message)
return result.passkey
}
/** Signs in. On success the `campaign-token` cookie is already set. */
export const signInWithCampaignPasskey = async (): Promise<string> => {
const options = requireOptions(
await postJson<CampaignWebAuthnOptionsResponse<PublicKeyCredentialRequestOptionsJSON>>(
CAMPAIGN_WEBAUTHN_ROUTES.loginOptions,
),
)
let credential
try {
credential = await startAuthentication({ optionsJSON: options })
} catch (error) {
throw asCeremonyError(error)
}
const result = await postJson<CampaignWebAuthnLoginResponse>(CAMPAIGN_WEBAUTHN_ROUTES.login, {
credential,
})
if (result.status !== 'success') throw new CampaignWebAuthnError(result.message)
return result.redirectTo
}

View file

@ -0,0 +1,40 @@
/**
* The always-loaded half of the browser side of WebAuthn (B40): the capability
* probe and the error type. It imports nothing, which is the point —
* `campaignWebAuthnClient.ts` pulls in `@simplewebauthn/browser` (~9 kB) and is
* therefore only ever reached through a dynamic `import()` inside a handler, so
* the ceremony code stays out of the First Load JS of every campaign route the
* enrollment toast is mounted on.
*/
/**
* A ceremony failure with a message already written for the person. Everything
* else bubbles as a plain `Error` the island turns into its own fallback copy.
*/
export class CampaignWebAuthnError extends Error {
/** True when the person simply closed the OS prompt — no alarm, no red. */
readonly cancelled: boolean
constructor(message: string, options: { cancelled?: boolean } = {}) {
super(message)
this.name = 'CampaignWebAuthnError'
this.cancelled = options.cancelled ?? false
}
}
/**
* Whether this browser can complete a platform ceremony at all. Awaiting this
* before rendering is what keeps a dead "Entrar com digital" button off a
* desktop with no enrolled authenticator.
*/
export const canUseCampaignBiometrics = async (): Promise<boolean> => {
if (typeof window === 'undefined') return false
if (!window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) return false
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch {
// A browser that refuses to answer the probe cannot host the ceremony.
return false
}
}

30
src/lib/deviceLabel.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* A short, human name for the device behind a user-agent string, used to
* pre-fill the passkey label so enrolling is one tap and no typing (B40).
*
* Deliberately coarse: this is a suggestion the person can overwrite, and it
* has to read like something they would say out loud ("iPhone", "Android"),
* not like a fingerprint of their browser. It is resolved on the server from
* the request header so the input has its value on first paint.
*/
const PATTERNS: ReadonlyArray<[RegExp, string]> = [
[/iPhone/i, 'iPhone'],
[/iPad/i, 'iPad'],
[/Android/i, 'Android'],
[/Macintosh|Mac OS X/i, 'Mac'],
[/Windows/i, 'Windows'],
[/CrOS/i, 'Chromebook'],
[/Linux/i, 'Linux'],
]
const FALLBACK_DEVICE_LABEL = 'Este aparelho'
export const deviceLabelFromUserAgent = (userAgent: string | null | undefined): string => {
if (!userAgent) return FALLBACK_DEVICE_LABEL
// iPadOS reports itself as a Macintosh with touch points, which the header
// alone cannot distinguish — "Mac" is the honest answer from here.
const match = PATTERNS.find(([pattern]) => pattern.test(userAgent))
return match ? match[1] : FALLBACK_DEVICE_LABEL
}

View file

@ -0,0 +1,65 @@
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser'
import { z } from 'zod'
import { CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH } from '@/lib/campaignWebAuthn'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
/**
* Envelope checks only. The credential's cryptographic content is verified by
* `@simplewebauthn/server` against the stored challenge, the relying party and
* the enrolled public key, so re-describing every base64url field here would
* duplicate that authority. This layer exists to reject a body that is not a
* credential of the expected ceremony before it reaches the verifier.
*
* The final cast is the one place it happens: zod proves the envelope, and the
* union members of `clientExtensionResults` / `authenticatorAttachment` are
* browser-owned enums that only the verifier reads.
*/
const credentialEnvelope = <Credential>(
response: z.ZodType<Record<string, unknown>>,
): z.ZodType<Credential, unknown> =>
z
.looseObject({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal('public-key'),
clientExtensionResults: z.looseObject({}),
response,
})
.transform((value) => value as unknown as Credential)
const registrationCredential = credentialEnvelope<RegistrationResponseJSON>(
z.looseObject({
clientDataJSON: z.string().min(1),
attestationObject: z.string().min(1),
}),
)
const authenticationCredential = credentialEnvelope<AuthenticationResponseJSON>(
z.looseObject({
clientDataJSON: z.string().min(1),
authenticatorData: z.string().min(1),
signature: z.string().min(1),
}),
)
const campaignPasskeyDeviceLabel = z
.string()
.trim()
.min(1, 'Dê um nome a este aparelho.')
.max(CAMPAIGN_WEBAUTHN_DEVICE_LABEL_MAX_LENGTH)
export const campaignWebAuthnRegisterSchema = z.object({
deviceLabel: campaignPasskeyDeviceLabel,
credential: registrationCredential,
})
export const campaignWebAuthnLoginSchema = z.object({
credential: authenticationCredential,
})
export const campaignPasskeyRemoveSchema = z.object({
passkeyId: positiveRelationshipId,
})
export type CampaignPasskeyRemoveInput = z.infer<typeof campaignPasskeyRemoveSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Adds the `campaignWebAuthnCredential` collection (roadmap B40): one row per
* passkey enrolled on one device, holding only the credential's public half.
*
* Hand-edited from the Payload-generated diff: the statements are guarded
* (`IF NOT EXISTS` / `pg_constraint` probe) and `down()` drops the
* `payload_locked_documents_rels` bits BEFORE the table, because
* `DROP TABLE ... CASCADE` already removes the constraint the generated order
* then tried to drop by name.
*
* The FK keeps Payload's default `ON DELETE set null` even though `user_id` is
* NOT NULL — which alone would make an account undeletable once it enrolled a
* passkey. Deleting the credentials is instead a `beforeDelete` hook on
* `campaignUser`: the generator derives FK actions from the collection config,
* so a hand-written `cascade` here is drift that every future `migrate:create`
* would try to revert (verified with a probe migration).
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "campaign_web_authn_credential" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"credential_id" varchar NOT NULL,
"public_key" varchar NOT NULL,
"counter" numeric DEFAULT 0 NOT NULL,
"transports" jsonb,
"device_label" varchar NOT NULL,
"last_used_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "campaign_web_authn_credential_id" integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'campaign_web_authn_credential_user_id_campaign_user_id_fk'
) THEN
ALTER TABLE "campaign_web_authn_credential"
ADD CONSTRAINT "campaign_web_authn_credential_user_id_campaign_user_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."campaign_user"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "campaign_web_authn_credential_user_idx"
ON "campaign_web_authn_credential" USING btree ("user_id");
CREATE UNIQUE INDEX IF NOT EXISTS "campaign_web_authn_credential_credential_id_idx"
ON "campaign_web_authn_credential" USING btree ("credential_id");
CREATE INDEX IF NOT EXISTS "campaign_web_authn_credential_updated_at_idx"
ON "campaign_web_authn_credential" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "campaign_web_authn_credential_created_at_idx"
ON "campaign_web_authn_credential" USING btree ("created_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'payload_locked_documents_rels_campaign_web_authn_credenti_fk'
) THEN
ALTER TABLE "payload_locked_documents_rels"
ADD CONSTRAINT "payload_locked_documents_rels_campaign_web_authn_credenti_fk"
FOREIGN KEY ("campaign_web_authn_credential_id")
REFERENCES "public"."campaign_web_authn_credential"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_campaign_web_authn_credent_idx"
ON "payload_locked_documents_rels" USING btree ("campaign_web_authn_credential_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_campaign_web_authn_credenti_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_campaign_web_authn_credent_idx";
ALTER TABLE "payload_locked_documents_rels"
DROP COLUMN IF EXISTS "campaign_web_authn_credential_id";
DROP TABLE IF EXISTS "campaign_web_authn_credential" CASCADE;
`)
}

View file

@ -32,6 +32,7 @@ import * as migration_20260725_170000_whatsapp_subscription_consent_key from './
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity';
import * as migration_20260727_032523_add_users_roles from './20260727_032523_add_users_roles';
import * as migration_20260727_161752_add_municipality_engagement_level from './20260727_161752_add_municipality_engagement_level';
import * as migration_20260728_041958_add_campaign_webauthn_credentials from './20260728_041958_add_campaign_webauthn_credentials';
export const migrations = [
{
@ -202,6 +203,11 @@ export const migrations = [
{
up: migration_20260727_161752_add_municipality_engagement_level.up,
down: migration_20260727_161752_add_municipality_engagement_level.down,
name: '20260727_161752_add_municipality_engagement_level'
name: '20260727_161752_add_municipality_engagement_level',
},
{
up: migration_20260728_041958_add_campaign_webauthn_credentials.up,
down: migration_20260728_041958_add_campaign_webauthn_credentials.down,
name: '20260728_041958_add_campaign_webauthn_credentials'
},
];

View file

@ -70,6 +70,7 @@ export interface Config {
collections: {
users: User;
campaignUser: CampaignUser;
campaignWebAuthnCredential: CampaignWebAuthnCredential;
campaignInvite: CampaignInvite;
municipality: Municipality;
leadership: Leadership;
@ -105,6 +106,7 @@ export interface Config {
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
campaignUser: CampaignUserSelect<false> | CampaignUserSelect<true>;
campaignWebAuthnCredential: CampaignWebAuthnCredentialSelect<false> | CampaignWebAuthnCredentialSelect<true>;
campaignInvite: CampaignInviteSelect<false> | CampaignInviteSelect<true>;
municipality: MunicipalitySelect<false> | MunicipalitySelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
@ -301,6 +303,32 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* Chaves públicas dos aparelhos que entram com digital ou Face ID. Nenhum dado biométrico é armazenado.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignWebAuthnCredential".
*/
export interface CampaignWebAuthnCredential {
id: number;
user: number | CampaignUser;
credentialId: string;
publicKey: string;
counter: number;
transports?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
deviceLabel: string;
lastUsedAt?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite".
@ -1177,6 +1205,10 @@ export interface PayloadLockedDocument {
relationTo: 'campaignUser';
value: number | CampaignUser;
} | null)
| ({
relationTo: 'campaignWebAuthnCredential';
value: number | CampaignWebAuthnCredential;
} | null)
| ({
relationTo: 'campaignInvite';
value: number | CampaignInvite;
@ -1371,6 +1403,21 @@ export interface CampaignUserSelect<T extends boolean = true> {
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignWebAuthnCredential_select".
*/
export interface CampaignWebAuthnCredentialSelect<T extends boolean = true> {
user?: T;
credentialId?: T;
publicKey?: T;
counter?: T;
transports?: T;
deviceLabel?: T;
lastUsedAt?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite_select".
@ -2202,6 +2249,7 @@ export interface TaskCreateCollectionExport {
collectionSlug:
| 'users'
| 'campaignUser'
| 'campaignWebAuthnCredential'
| 'campaignInvite'
| 'municipality'
| 'leadership'

View file

@ -14,6 +14,7 @@ import { AllocationDecision } from './collections/AllocationDecision'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
import { CampaignWebAuthnCredential } from './collections/CampaignWebAuthnCredential'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { ElectionCandidate } from './collections/ElectionCandidate'
@ -85,6 +86,7 @@ export default buildConfig({
collections: [
Users,
CampaignUser,
CampaignWebAuthnCredential,
CampaignInvite,
Municipality,
Leadership,

View file

@ -0,0 +1,29 @@
// ---------------------------------------------------------------------------
// Biometric login credentials (WebAuthn passkeys)
// ---------------------------------------------------------------------------
import { isCampaignUser, isPayloadAdmin, payloadAdminOnly } from '@/utilities/access/shared'
import type { Access } from 'payload'
/**
* A passkey is a login factor, so it is strictly the owner's business: nobody
* on staff — coordinator included — reads or revokes another account's
* credentials. The row-level constraint (not a boolean) is what keeps a
* campaign JWT hitting `/api/campaignWebAuthnCredential` scoped to itself.
*/
export const canReadOwnWebAuthnCredentials: Access = ({ req }) => {
if (isPayloadAdmin(req.user)) return true
if (!isCampaignUser(req.user)) return false
return { user: { equals: req.user.id } }
}
export const canDeleteOwnWebAuthnCredentials: Access = canReadOwnWebAuthnCredentials
/**
* Enrollment and the sign-counter/`lastUsedAt` bump both run inside the
* ceremony's transaction with `overrideAccess: true`, after the server verified
* the attestation. Nothing else may write a credential: a client-shaped create
* would let a session register a public key the authenticator never signed.
*/
export const canWriteWebAuthnCredentials: Access = payloadAdminOnly

View file

@ -127,3 +127,9 @@ export {
canManageStateDeputy,
canReadStateDeputy,
} from '@/utilities/access/stateDeputies'
export {
canDeleteOwnWebAuthnCredentials,
canReadOwnWebAuthnCredentials,
canWriteWebAuthnCredentials,
} from '@/utilities/access/webauthnCredentials'

View file

@ -0,0 +1,65 @@
/**
* Browser-side memory for the biometric-enrollment prompt (B40). Sibling of
* `recentVisits.ts` / `campaignGeolocation.ts`: no Payload, no Next, just
* `localStorage` behind failure-tolerant reads.
*
* Two flags, and the split is the whole point: the server knows HOW MANY
* passkeys an account has, never WHICH device it is being read on. So "already
* enrolled here" and "stop offering" can only be answered locally, and they are
* kept in `localStorage` rather than `sessionStorage` — a decision about this
* phone should outlive the tab (the PWA install toast, which is a suggestion
* rather than an answer, deliberately uses `sessionStorage` instead).
*/
const CAMPAIGN_BIOMETRICS_ENROLLED_KEY = 'teqo:campaign:biometrics-enrolled'
/** Exported because the e2e spec pre-sets it instead of hardcoding the string. */
export const CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY = 'teqo:campaign:biometrics-prompt-dismissed'
const read = (key: string): boolean => {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(key) === '1'
} catch {
// localStorage throws in private mode / with storage disabled.
return false
}
}
const write = (key: string): void => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(key, '1')
} catch {
// Ignore quota / private-mode failures: the prompt reappearing is a far
// smaller problem than a thrown error inside a toast callback.
}
}
const clear = (key: string): void => {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(key)
} catch {
// Same tolerance as above.
}
}
export const wasCampaignBiometricsEnrolledHere = (): boolean =>
read(CAMPAIGN_BIOMETRICS_ENROLLED_KEY)
export const markCampaignBiometricsEnrolledHere = (): void =>
write(CAMPAIGN_BIOMETRICS_ENROLLED_KEY)
/**
* Called when the account has no passkeys at all: whatever this browser
* believed about "enrolled here" is stale, and a person who revoked every
* device should be offered enrollment again.
*/
export const forgetCampaignBiometricsEnrollment = (): void =>
clear(CAMPAIGN_BIOMETRICS_ENROLLED_KEY)
export const wasCampaignBiometricsPromptDismissed = (): boolean =>
read(CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY)
export const dismissCampaignBiometricsPrompt = (): void =>
write(CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY)

View file

@ -0,0 +1,308 @@
import 'server-only'
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/server'
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server'
import { APIError, type Payload } from 'payload'
import { cache } from 'react'
import { CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE } from '@/lib/campaignAuthCopy'
import { CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS, type CampaignPasskeyView } from '@/lib/campaignWebAuthn'
import type { CampaignUser, CampaignWebAuthnCredential } from '@/payload-types'
import {
clearCampaignWebAuthnChallenge,
readCampaignWebAuthnChallenge,
storeCampaignWebAuthnChallenge,
} from '@/utilities/campaignWebAuthnChallenge'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig'
import { issueCampaignWebAuthnSession } from '@/utilities/campaignWebAuthnSession'
import { requireRelationshipId } from '@/utilities/relationship'
const COLLECTION = 'campaignWebAuthnCredential'
/**
* Refusals a route may repeat verbatim (`campaignWebAuthnSafeMessages`), which is
* why they are module-local: the array IS the export, and letting a caller reach
* for one of them individually is how a message ends up shown without being on
* the allowlist that makes it safe.
*/
const CAMPAIGN_WEBAUTHN_UNAVAILABLE_MESSAGE =
'A entrada por biometria não está disponível neste endereço.'
const CAMPAIGN_WEBAUTHN_REJECTED_MESSAGE =
'Não foi possível confirmar a biometria. Tente novamente ou use sua senha.'
const CAMPAIGN_WEBAUTHN_UNKNOWN_DEVICE_MESSAGE =
'Este aparelho não está mais autorizado. Entre com sua senha e cadastre-o novamente.'
const CAMPAIGN_WEBAUTHN_LIMIT_MESSAGE = `Você já cadastrou ${CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS} aparelhos. Remova um antes de cadastrar outro.`
export const CAMPAIGN_PASSKEY_REMOVE_DENIED_MESSAGE =
'Este aparelho não pertence à sua conta ou já foi removido.'
export const campaignWebAuthnSafeMessages = [
CAMPAIGN_WEBAUTHN_UNAVAILABLE_MESSAGE,
CAMPAIGN_WEBAUTHN_REJECTED_MESSAGE,
CAMPAIGN_WEBAUTHN_UNKNOWN_DEVICE_MESSAGE,
CAMPAIGN_WEBAUTHN_LIMIT_MESSAGE,
CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE,
] as const
const requireRelyingParty = async () => {
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
if (!relyingParty) throw new Error(CAMPAIGN_WEBAUTHN_UNAVAILABLE_MESSAGE)
return relyingParty
}
const readTransports = (value: unknown): AuthenticatorTransportFuture[] | undefined =>
Array.isArray(value) && value.every((entry) => typeof entry === 'string')
? (value as AuthenticatorTransportFuture[])
: undefined
const toCampaignPasskeyView = (credential: CampaignWebAuthnCredential): CampaignPasskeyView => ({
id: credential.id,
deviceLabel: credential.deviceLabel,
createdAt: credential.createdAt,
lastUsedAt: credential.lastUsedAt ?? null,
})
const findCredentialsOfUser = async (
payload: Payload,
userID: number,
): Promise<CampaignWebAuthnCredential[]> => {
const result = await payload.find({
collection: COLLECTION,
where: { user: { equals: userID } },
depth: 0,
limit: CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS + 1,
pagination: false,
sort: '-createdAt',
overrideAccess: true,
})
return result.docs
}
/**
* The account's enrolled devices. React `cache()` because two consumers ask in
* the same request on `/campanha/perfil` — the shell layout, which only needs
* to know whether the list is empty, and the profile card, which renders it.
* Per-request only: a session's own passkeys must never be cached across
* requests.
*/
export const loadCampaignPasskeys = cache(
async (payload: Payload, userID: number): Promise<CampaignPasskeyView[]> =>
(await findCredentialsOfUser(payload, userID)).map(toCampaignPasskeyView),
)
export const buildCampaignRegistrationOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<PublicKeyCredentialCreationOptionsJSON> => {
const relyingParty = await requireRelyingParty()
const existing = await findCredentialsOfUser(payload, user.id)
if (existing.length >= CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS) {
throw new Error(CAMPAIGN_WEBAUTHN_LIMIT_MESSAGE)
}
const options = await generateRegistrationOptions({
rpID: relyingParty.rpID,
rpName: relyingParty.rpName,
// Identifier the OS shows in its passkey manager. A leadership account has
// no e-mail, so the phone (or the name) is what makes the entry legible.
userName: user.email ?? user.username ?? user.name,
userDisplayName: user.name,
attestationType: 'none',
// Already enrolled devices are excluded so the OS says "already registered"
// instead of silently creating a second credential for the same phone.
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId,
transports: readTransports(credential.transports),
})),
authenticatorSelection: {
// Discoverable + platform is what lets the login screen offer biometrics
// before the user types any identifier.
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
authenticatorAttachment: 'platform',
},
})
// Signed AFTER generation, so the cookie holds exactly the base64url the
// browser will echo back (see `storeCampaignWebAuthnChallenge`), and bound to
// this account so an enrollment challenge cannot be spent by another.
await storeCampaignWebAuthnChallenge('registration', options.challenge, String(user.id))
return options
}
export const completeCampaignRegistration = async (
payload: Payload,
user: CampaignUser,
{ credential, deviceLabel }: { credential: RegistrationResponseJSON; deviceLabel: string },
): Promise<CampaignPasskeyView> => {
const relyingParty = await requireRelyingParty()
const expectedChallenge = await readCampaignWebAuthnChallenge('registration', String(user.id))
let verification
try {
verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpID,
requireUserVerification: true,
})
} finally {
// Single-use whatever the verdict: a challenge that survived a failed
// attempt is a challenge an attacker gets to retry.
await clearCampaignWebAuthnChallenge('registration')
}
if (!verification.verified) throw new Error(CAMPAIGN_WEBAUTHN_REJECTED_MESSAGE)
const { credential: verified } = verification.registrationInfo
const existing = await findCredentialsOfUser(payload, user.id)
if (existing.length >= CAMPAIGN_WEBAUTHN_MAX_CREDENTIALS) {
throw new Error(CAMPAIGN_WEBAUTHN_LIMIT_MESSAGE)
}
if (existing.some((entry) => entry.credentialId === verified.id)) {
throw new Error(CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE)
}
const created = await payload.create({
collection: COLLECTION,
data: {
user: user.id,
credentialId: verified.id,
publicKey: Buffer.from(verified.publicKey).toString('base64url'),
counter: verified.counter,
transports: credential.response.transports ?? null,
deviceLabel,
},
depth: 0,
overrideAccess: true,
})
return toCampaignPasskeyView(created)
}
export const buildCampaignAuthenticationOptions =
async (): Promise<PublicKeyCredentialRequestOptionsJSON> => {
const relyingParty = await requireRelyingParty()
const options = await generateAuthenticationOptions({
rpID: relyingParty.rpID,
// No `allowCredentials`: the credential is discoverable, so the browser
// asks the user which account to use and we never have to be told an
// identifier before authenticating — which also means the login screen
// leaks no "this phone belongs to somebody" signal.
userVerification: 'required',
})
// No subject: nobody has identified themselves yet, which is the point.
await storeCampaignWebAuthnChallenge('authentication', options.challenge)
return options
}
/**
* Verifies an assertion and, on success, mints the campaign session. Returns
* the token so the route can set the cookie (only a route/action may).
*/
export const completeCampaignAuthentication = async (
payload: Payload,
credential: AuthenticationResponseJSON,
): Promise<{ token: string; tokenExpiration: number }> => {
const relyingParty = await requireRelyingParty()
const expectedChallenge = await readCampaignWebAuthnChallenge('authentication')
const stored = await payload.find({
collection: COLLECTION,
where: { credentialId: { equals: credential.id } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const record = stored.docs[0]
if (!record) {
await clearCampaignWebAuthnChallenge('authentication')
throw new Error(CAMPAIGN_WEBAUTHN_UNKNOWN_DEVICE_MESSAGE)
}
let verification
try {
verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpID,
requireUserVerification: true,
credential: {
id: record.credentialId,
publicKey: new Uint8Array(Buffer.from(record.publicKey, 'base64url')),
counter: record.counter,
transports: readTransports(record.transports),
},
})
} finally {
await clearCampaignWebAuthnChallenge('authentication')
}
if (!verification.verified) throw new Error(CAMPAIGN_WEBAUTHN_REJECTED_MESSAGE)
const userID = requireRelationshipId(record.user, CAMPAIGN_WEBAUTHN_UNKNOWN_DEVICE_MESSAGE)
const session = await issueCampaignWebAuthnSession(payload, userID)
// Counter + freshness are recorded only after the session was minted: if the
// mint refuses (locked account), the authenticator's counter must not have
// moved on our side, or the next legitimate attempt would look like a replay.
await payload.update({
collection: COLLECTION,
id: record.id,
data: {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date().toISOString(),
},
depth: 0,
overrideAccess: true,
})
return session
}
export const removeCampaignPasskey = async (
payload: Payload,
user: CampaignUser,
passkeyID: number,
): Promise<void> => {
try {
await payload.delete({
collection: COLLECTION,
id: passkeyID,
depth: 0,
user,
overrideAccess: false,
})
} catch (error) {
// Payload answers somebody else's id with Forbidden and an already-deleted
// one with NotFound; both become the same sentence, because distinguishing
// them would confirm that another account owns that credential. Anything
// else — a dropped connection, a failing hook — keeps bubbling: telling the
// person "not yours" would send them away from something worth retrying.
if (error instanceof APIError && (error.status === 403 || error.status === 404)) {
throw new Error(CAMPAIGN_PASSKEY_REMOVE_DENIED_MESSAGE)
}
throw error
}
}

View file

@ -0,0 +1,118 @@
import 'server-only'
import { cookies } from 'next/headers'
import { createHmac, timingSafeEqual } from 'node:crypto'
/** Window a browser has to complete a ceremony before the challenge cookie dies. */
const CHALLENGE_TTL_MS = 5 * 60 * 1000
/**
* A WebAuthn challenge only has to be unpredictable, single-use and short-lived
* — it is not a secret the server must be able to look up later. So it rides in
* an httpOnly cookie signed with `PAYLOAD_SECRET` (the `supporterImportToken`
* pattern) instead of a `campaignWebAuthnChallenge` collection, which would
* write a row every time somebody merely looks at the login screen and would
* then need an expiry sweep.
*
* The ceremony kind is inside the signature, so a challenge minted for
* enrollment cannot be spent on a login; the registration challenge is
* additionally bound to the account that asked for it.
*/
export type CampaignWebAuthnCeremony = 'registration' | 'authentication'
const COOKIE_PATH = '/campanha'
const COOKIE_NAMES: Record<CampaignWebAuthnCeremony, string> = {
registration: 'campaign-webauthn-registration',
authentication: 'campaign-webauthn-authentication',
}
const getSecret = (): string => {
const secret = process.env.PAYLOAD_SECRET
if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o desafio de biometria.')
return secret
}
const signature = (
ceremony: CampaignWebAuthnCeremony,
challenge: string,
expiresAt: number,
subject: string,
): Buffer =>
createHmac('sha256', getSecret())
.update(`${ceremony}.${challenge}.${expiresAt}.${subject}`)
.digest()
const cookieOptions = (maxAge: number) => ({
httpOnly: true,
maxAge,
path: COOKIE_PATH,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
})
/**
* Stores the signed, time-boxed form of a challenge in the ceremony's cookie.
*
* The challenge itself is **minted by `@simplewebauthn/server`** and taken from
* the generated `options.challenge`, rather than generated here and passed in:
* `generateRegistrationOptions` treats a `string` challenge as UTF-8 text and
* base64url-encodes it again before handing it to the browser, so signing our
* own raw value would store one encoding and verify against another — which
* failed every ceremony until it was measured (`Unexpected registration
* response challenge`). Storing exactly what the browser will echo removes the
* question.
*/
export const storeCampaignWebAuthnChallenge = async (
ceremony: CampaignWebAuthnCeremony,
challenge: string,
subject: string = '',
): Promise<void> => {
const expiresAt = Date.now() + CHALLENGE_TTL_MS
const sig = signature(ceremony, challenge, expiresAt, subject).toString('base64url')
const cookieStore = await cookies()
cookieStore.set(
COOKIE_NAMES[ceremony],
`${challenge}.${expiresAt}.${sig}`,
cookieOptions(Math.ceil(CHALLENGE_TTL_MS / 1000)),
)
}
export const CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE =
'A confirmação demorou demais. Tente novamente.'
/**
* Reads back the challenge the browser is answering. Throws the expired message
* for anything wrong — a missing, malformed, stale or forged cookie are the
* same event from the user's side, and naming which one leaks nothing useful.
*/
export const readCampaignWebAuthnChallenge = async (
ceremony: CampaignWebAuthnCeremony,
subject: string = '',
): Promise<string> => {
const cookieStore = await cookies()
const raw = cookieStore.get(COOKIE_NAMES[ceremony])?.value
const invalid = () => new Error(CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE)
if (!raw) throw invalid()
const [challenge, expiresAtText, sig, ...rest] = raw.split('.')
if (!challenge || !expiresAtText || !sig || rest.length > 0) throw invalid()
const expiresAt = Number(expiresAtText)
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) throw invalid()
const expected = signature(ceremony, challenge, expiresAt, subject)
const provided = Buffer.from(sig, 'base64url')
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) throw invalid()
return challenge
}
/** Burns the challenge so a captured response cannot be replayed. */
export const clearCampaignWebAuthnChallenge = async (
ceremony: CampaignWebAuthnCeremony,
): Promise<void> => {
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAMES[ceremony], '', cookieOptions(0))
}

View file

@ -0,0 +1,97 @@
import 'server-only'
import { headers } from 'next/headers'
import {
getCampaignInviteBaseURL,
type CampaignInviteOriginInput,
} from '@/utilities/campaignInviteOrigin'
export type CampaignWebAuthnRelyingParty = {
/** Effective domain the credential is scoped to (no port, no scheme). */
rpID: string
rpName: string
/** Exact origin the browser must report in `clientDataJSON`. */
origin: string
}
const RP_NAME = 'Campanha Jorge Solla'
/**
* The authority the browser is actually talking to, which is what has to agree
* with the relying party. Compared as `host` (name + port) and not as a full
* origin on purpose: a navigation carries no `Origin` header, so the scheme
* would have to be guessed, and an http request to the production host cannot
* reach a ceremony anyway — WebAuthn needs a secure context, so the browser
* never exposes the API there.
*/
const requestHostOf = ({
requestOrigin,
forwardedHost,
}: CampaignInviteOriginInput): string | undefined => {
const origin = requestOrigin?.trim()
if (origin) {
try {
return new URL(origin).host.toLowerCase()
} catch {
// Fall through to the forwarded host.
}
}
const host = forwardedHost?.trim()
return host ? host.toLowerCase() : undefined
}
/**
* WebAuthn needs one fixed domain and one exact origin, so it reuses the
* canonical-origin policy the invite links already own: production demands a
* valid HTTPS DNS `NEXT_PUBLIC_SITE_URL` and fails closed, development and
* test accept localhost.
*
* Returns `null` instead of throwing when this request cannot host a ceremony,
* and there are two such cases. The obvious one is no safe origin at all. The
* one that had to be measured is a **Vercel preview deployment**: `NODE_ENV` is
* `production` there and `NEXT_PUBLIC_SITE_URL` is set, so the invite policy
* happily returns the CANONICAL origin while the browser is on
* `teqo-git-….vercel.app` — a mismatch the browser answers with a
* `SecurityError` mid-ceremony. Hence the host comparison below: the RP is only
* offered when it describes the origin actually serving the page, and the UI
* reads `null` as "do not offer biometrics".
*/
export const getCampaignWebAuthnRelyingParty = (
input: CampaignInviteOriginInput = {},
): CampaignWebAuthnRelyingParty | null => {
let baseURL: string
try {
baseURL = getCampaignInviteBaseURL(input)
} catch {
return null
}
const url = new URL(baseURL)
// Strip IPv6 brackets: `URL.hostname` keeps them, WebAuthn's RP ID must not.
const rpID = url.hostname.replace(/^\[|\]$/g, '')
// Absent host: a non-request context (a script, a test) that cannot be
// checked. The ceremonies always pass the headers.
const requestHost = requestHostOf(input)
if (requestHost && requestHost !== url.host.toLowerCase()) return null
return { rpID, rpName: RP_NAME, origin: url.origin }
}
/**
* Same resolution, reading the request headers so a local `pnpm dev` on any
* port still works without `NEXT_PUBLIC_SITE_URL`. The forwarded values are
* only ever honored for localhost authorities (see `campaignInviteOrigin`), so
* production cannot be talked into a foreign relying party by a header.
*/
export const resolveCampaignWebAuthnRelyingParty =
async (): Promise<CampaignWebAuthnRelyingParty | null> => {
const requestHeaders = await headers()
return getCampaignWebAuthnRelyingParty({
requestOrigin: requestHeaders.get('origin'),
forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'),
forwardedProto: requestHeaders.get('x-forwarded-proto'),
})
}

View file

@ -0,0 +1,106 @@
import 'server-only'
import {
checkLoginPermission,
createLocalReq,
getFieldsToSign,
jwtSign,
type DataFromCollectionSlug,
type Payload,
type PayloadRequest,
} from 'payload'
import { addSessionToUser } from 'payload/shared'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
export const CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE =
'Não foi possível entrar com este aparelho. Use sua senha.'
export const CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE =
'Sua conta está bloqueada por tentativas de senha. Aguarde alguns minutos e tente de novo.'
/** The raw `campaign_user` row, as the database returns it. */
type CampaignUserRow = DataFromCollectionSlug<'campaignUser'>
/**
* Mints a `campaignUser` session for a passkey the server already verified.
* Payload 3.82 has no password-less login path (`loginOperation` requires one),
* so this reproduces what that operation does after the password check
* succeeds, using only public exports: lockout check → session row → signed JWT.
*
* Two details are load-bearing:
*
* 1. **The user is loaded with `payload.db.findOne`, never `payload.findByID`**
* — the raw row, which is what `loginOperation` reads. The Local API hides
* `lockUntil` and `loginAttempts` from an auth read, so with `findByID` the
* `checkLoginPermission` below would see an unlocked account every time and
* a passkey would become the way AROUND the password lockout. Measured on
* 3.82.0, and pinned by the "refuses a locked account" int test.
* (Two dangers the plan predicted are not reproducible here and should not
* be re-derived: `sessions` survives the read — `removePrivateAuthFields`
* returns early when there is no `req.user` — and the absent `hash`/`salt`
* are skipped rather than nulled by the write, so a password still works.
* The lockout hole is the real one.)
* 2. **The whole thing runs under an advisory lock**, because
* `addSessionToUser` writes the WHOLE document back, `sessions` array
* included: two devices signing in at the same instant would each persist an
* array that never saw the other's row.
*/
export const issueCampaignWebAuthnSession = async (
payload: Payload,
userID: number,
): Promise<{ token: string; tokenExpiration: number }> => {
const collection = payload.collections.campaignUser
if (!collection) throw new Error('A collection campaignUser não está disponível.')
const collectionConfig = collection.config
return withPayloadTransaction(payload, async ({ transactionID }) => {
await acquireTextAdvisoryLocks(payload, { transactionID }, [
`campaign-webauthn-session:${userID}`,
])
// `checkLoginPermission` needs `req.t`, and `addSessionToUser` needs a real
// request to keep its write inside this transaction.
const req: PayloadRequest = await createLocalReq({ req: { transactionID } }, payload)
const user = await payload.db.findOne<CampaignUserRow>({
collection: 'campaignUser',
req,
where: { id: { equals: userID } },
})
if (!user) throw new Error(CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE)
// A passkey must not be the way around a lockout. With the account already
// proven to exist, the only thing this can throw is `LockedAuth`, whose
// message is Payload's generic i18n string — so it is restated in the terms
// the login screen uses.
try {
checkLoginPermission<'campaignUser'>({ req, user })
} catch {
throw new Error(CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE)
}
// Same shape `loginOperation` hands it; `addSessionToUser` stamps
// `collection`/`_strategy` itself once the row is written.
const { sid } = await addSessionToUser({ collectionConfig, payload, req, user })
const fieldsToSign = getFieldsToSign({
collectionConfig,
// A leadership account signs in by phone and has no e-mail; the claim is
// decorative anyway, since `payload.auth` re-reads the row.
email: user.email ?? '',
sid,
user,
})
const tokenExpiration = collectionConfig.auth.tokenExpiration
const { token } = await jwtSign({
fieldsToSign,
secret: payload.secret,
tokenExpiration,
})
return { token, tokenExpiration }
})
}

View file

@ -31,7 +31,7 @@ test('logs in with a remembered fourteen-day campaign session', async ({
await page.getByLabel('Senha').fill(password)
await rememberMe.check()
const loggedInAt = Math.floor(Date.now() / 1000)
await page.getByRole('button', { name: 'Entrar' }).click()
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
await page.waitForURL(/\/campanha$/)
const tokenCookie = (await context.cookies()).find((cookie) => cookie.name === 'campaign-token')

View file

@ -0,0 +1,121 @@
import type { BrowserContext, Page } from '@playwright/test'
import { CAMPAIGN_SESSION_TTL_LONG } from '../../src/lib/campaignSessionTtl.js'
import { CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY } from '../../src/utilities/campaignBiometricsPrompt.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* Biometric login (B40) end to end, against a **virtual authenticator** driven
* over CDP: `WebAuthn.addVirtualAuthenticator` with `transport: 'internal'` +
* `hasUserVerification` is what makes
* `isUserVerifyingPlatformAuthenticatorAvailable()` answer `true` and lets the
* ceremonies complete headlessly. Without it neither island even mounts, so
* this is the only way to cover the flow at all.
*/
const addVirtualAuthenticator = async (context: BrowserContext, page: Page): Promise<void> => {
const cdp = await context.newCDPSession(page)
await cdp.send('WebAuthn.enable')
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
// Pre-verified: the virtual authenticator cannot show a fingerprint
// sheet, and every ceremony here requires user verification.
isUserVerified: true,
automaticPresenceSimulation: true,
},
})
}
/**
* The first ceremony of the run pays for compiling two route handlers in the dev
* server, which under load is well past the 5 s default. Generous only where the
* wait is a compile, never as a blanket setting.
*/
const CEREMONY_TIMEOUT = 60_000
/**
* The enrollment toast would cover the profile card it is advertising. The key
* travels as an argument rather than as a literal inside the init script, so a
* rename cannot silently stop silencing it.
*/
const silenceEnrollmentToast = (context: BrowserContext) =>
context.addInitScript((key: string) => {
window.localStorage.setItem(key, '1')
}, CAMPAIGN_BIOMETRICS_PROMPT_DISMISSED_KEY)
// Step 3 signs in with a revoked passkey, and the refusal is a handled 400 that
// the browser still logs as a failed request — declared here so the shared
// failure guard does not read it as a bug.
test.use({ expectedRequestFailurePaths: ['/campanha/webauthn/login'] })
test('enrolls a passkey, signs in with it, then revokes it', async ({
campaign,
context,
page,
}) => {
test.slow()
const { fixtures } = campaign
const email = `${fixtures.value('biometric-user')}@example.com`
const password = fixtures.value('password')
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Assessora com biometria'),
email,
password,
role: 'advisor',
},
depth: 0,
})
await silenceEnrollmentToast(context)
await addVirtualAuthenticator(context, page)
// 1. Enrollment is authenticated: the passkey card only exists behind a
// password login.
await campaign.login(page, email, password)
await page.goto('/campanha/perfil')
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
const deviceLabel = page.getByLabel('Nome deste aparelho')
await expect(deviceLabel).toBeVisible()
await deviceLabel.fill('Celular da assessora')
await page.getByRole('button', { name: 'Ativar neste aparelho' }).click()
await expect(page.getByText('Celular da assessora')).toBeVisible({ timeout: CEREMONY_TIMEOUT })
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeHidden()
// 2. Password-less sign-in issues the 14-day cookie — enrolling the device IS
// the "remember me" opt-in, so there is no checkbox in this path.
await context.clearCookies()
await page.goto('/campanha/login')
const biometricButton = page.getByRole('button', { name: 'Entrar com digital ou Face ID' })
await expect(biometricButton).toBeVisible()
const signedInAt = Math.floor(Date.now() / 1000)
await biometricButton.click()
await page.waitForURL(/\/campanha$/)
const tokenCookie = (await context.cookies()).find((cookie) => cookie.name === 'campaign-token')
expect(tokenCookie?.expires).toBeGreaterThan(signedInAt + CAMPAIGN_SESSION_TTL_LONG - 120)
// 3. Revocation is the answer to "my phone is gone", so it has to work from
// the profile and it has to actually stop the credential.
await page.goto('/campanha/perfil')
await page.getByRole('button', { name: 'Remover Celular da assessora' }).click()
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
await context.clearCookies()
await page.goto('/campanha/login')
await page.getByRole('button', { name: 'Entrar com digital ou Face ID' }).click()
// The device still holds its private key, so the ceremony itself succeeds and
// the refusal must come from the server not knowing the credential anymore.
await expect(
page.getByRole('alert').filter({ hasText: /não está mais autorizado/i }),
).toBeVisible({ timeout: CEREMONY_TIMEOUT })
await expect(page).toHaveURL(/\/campanha\/login/)
})

View file

@ -371,7 +371,10 @@ export const test = base.extend<CampaignE2ETestFixtures>({
await page.goto(`${baseURL}/campanha/login`)
await page.getByLabel('E-mail ou celular').fill(identifier)
await page.getByLabel('Senha').fill(password)
await page.getByRole('button', { name: 'Entrar' }).click()
// `exact`: the login screen also offers "Entrar com digital ou Face
// ID" (B40) whenever the device has a platform authenticator — which
// any spec adding a virtual one does.
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
await page.waitForURL(`${baseURL}/campanha`)
},
payload: fixtures.payload,

View file

@ -15,8 +15,22 @@ type BrowserFailure = {
type E2EFailureGuardFixtures = {
e2eFailureGuard: void
/**
* Same-origin paths whose failed responses this spec provokes on purpose.
*
* A `fetch` the app handles in code still makes the browser log "Failed to
* load resource", so a spec asserting a refusal (B40: signing in with a
* revoked passkey) would otherwise fail the guard for doing its job. Declaring
* the path — rather than allowing 4xx broadly — keeps an unhandled request
* failure in any other spec a failure. It is an option so a spec declares it
* once with `test.use`, instead of pushing at exactly the right moment
* mid-test.
*/
expectedRequestFailurePaths: string[]
}
const loadFailurePrefix = 'Failed to load resource'
const missingFaviconConsoleError =
'Failed to load resource: the server responded with a status of 404 (Not Found)'
@ -31,24 +45,40 @@ const consoleDetail = (message: ConsoleMessage): string => {
return `${message.text()}${location}`
}
const isAllowedConsoleError = (message: ConsoleMessage, origin: string | null): boolean => {
const isAllowedConsoleError = (
message: ConsoleMessage,
origin: string | null,
expectedRequestFailurePaths: string[],
): boolean => {
const { url } = message.location()
if (!url || origin === null || message.text() !== missingFaviconConsoleError) return false
if (!url || origin === null) return false
const location = new URL(url)
// The app intentionally has no /favicon.ico asset yet. This exact same-origin
// missing-resource message is the only accepted browser console error.
return location.origin === origin && location.pathname === '/favicon.ico'
if (location.origin !== origin) return false
// The app intentionally has no /favicon.ico asset yet.
if (message.text() === missingFaviconConsoleError) return location.pathname === '/favicon.ico'
return (
message.text().startsWith(loadFailurePrefix) &&
expectedRequestFailurePaths.includes(location.pathname)
)
}
export const test = base.extend<E2EFailureGuardFixtures>({
expectedRequestFailurePaths: [[], { option: true }],
e2eFailureGuard: [
async ({ baseURL, context, page }, use) => {
async ({ baseURL, context, expectedRequestFailurePaths, page }, use) => {
const failures: BrowserFailure[] = []
const origin = baseURL ? new URL(baseURL).origin : null
const onConsole = (message: ConsoleMessage) => {
if (message.type() !== 'error' || isAllowedConsoleError(message, origin)) return
if (
message.type() !== 'error' ||
isAllowedConsoleError(message, origin, expectedRequestFailurePaths)
) {
return
}
failures.push({ kind: 'console.error', detail: consoleDetail(message) })
}
const onPageError = (error: Error) => {

View file

@ -10,6 +10,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',
'/campanha/perfil',
'/campanha/demandas',
'/campanha/liderancas',
'/campanha/conceitos',
@ -31,6 +32,10 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/expected-votes',
'/campanha/municipios/political-trend',
'/campanha/liderancas/support-status',
'/campanha/webauthn/login-options',
'/campanha/webauthn/login',
'/campanha/webauthn/register-options',
'/campanha/webauthn/register',
]) {
await request.post(`${baseURL}${path}`, { data: {} }).catch(() => undefined)
}

View file

@ -0,0 +1,165 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const enrollPasskey = (userID: number, credentialId: string) =>
payload.create({
collection: 'campaignWebAuthnCredential',
data: {
user: userID,
credentialId,
publicKey: 'cHVibGljLWtleQ',
counter: 0,
deviceLabel: 'iPhone de teste',
},
overrideAccess: true,
})
/**
* A passkey is a login factor, so its row is the owner's business only — not
* the coordinator's, not another advisor's. Payload's default access is "any
* authenticated user", and a `campaign-token` JWT reaches
* `/api/campaignWebAuthnCredential` directly, so every deny here has to be
* explicit (roadmap B40).
*/
describe('campaignWebAuthnCredential access', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('denies anonymous reads', async () => {
await expect(
payload.find({ collection: 'campaignWebAuthnCredential', overrideAccess: false }),
).rejects.toThrow(/permissão|not allowed/i)
})
it('shows an owner only their own passkeys', async () => {
const fixtures = campaignFixtures()
const [owner, other] = await Promise.all([
fixtures.createCampaignUser('leader'),
fixtures.createCampaignUser('advisor'),
])
const ownCredential = await enrollPasskey(owner.id, `own-${owner.id}`)
await enrollPasskey(other.id, `other-${other.id}`)
const visible = await payload.find({
collection: 'campaignWebAuthnCredential',
depth: 0,
user: owner,
overrideAccess: false,
})
expect(visible.docs.map((doc) => doc.id)).toEqual([ownCredential.id])
})
it('denies the coordinator reading or deleting another account passkey', async () => {
const fixtures = campaignFixtures()
const [owner, coordinator] = await Promise.all([
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('coordinator'),
])
const credential = await enrollPasskey(owner.id, `owner-${owner.id}`)
const visible = await payload.find({
collection: 'campaignWebAuthnCredential',
depth: 0,
user: coordinator,
overrideAccess: false,
})
expect(visible.docs).toHaveLength(0)
await expect(
payload.delete({
collection: 'campaignWebAuthnCredential',
id: credential.id,
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed|not found/i)
await expect(
payload.findByID({
collection: 'campaignWebAuthnCredential',
id: credential.id,
depth: 0,
overrideAccess: true,
}),
).resolves.toMatchObject({ id: credential.id })
})
it('lets the owner delete their own passkey', async () => {
const fixtures = campaignFixtures()
const owner = await fixtures.createCampaignUser('leader')
const credential = await enrollPasskey(owner.id, `revoke-${owner.id}`)
await payload.delete({
collection: 'campaignWebAuthnCredential',
id: credential.id,
user: owner,
overrideAccess: false,
})
await expect(
payload.count({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: owner.id } },
overrideAccess: true,
}),
).resolves.toMatchObject({ totalDocs: 0 })
})
it('refuses a client-shaped enrollment even by the owner', async () => {
const fixtures = campaignFixtures()
const owner = await fixtures.createCampaignUser('leader')
await expect(
payload.create({
collection: 'campaignWebAuthnCredential',
data: {
user: owner.id,
credentialId: `forged-${owner.id}`,
publicKey: 'YXR0YWNrZXI',
counter: 0,
deviceLabel: 'Aparelho do atacante',
},
user: owner,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it('revokes the passkeys of a deleted account', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const doomed = await fixtures.createCampaignUser('advisor')
await enrollPasskey(doomed.id, `doomed-${doomed.id}`)
await payload.delete({
collection: 'campaignUser',
id: doomed.id,
user: coordinator,
overrideAccess: false,
})
await expect(
payload.count({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: doomed.id } },
overrideAccess: true,
}),
).resolves.toMatchObject({ totalDocs: 0 })
})
})

View file

@ -0,0 +1,186 @@
// @vitest-environment node
import { sql } from '@payloadcms/db-postgres'
import { getPayload, type Payload } from 'payload'
import { assert, beforeAll, describe, expect, it } from 'vitest'
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
import config from '@/payload.config'
import { authenticateCampaignToken } from '@/utilities/campaignAuth'
import {
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
issueCampaignWebAuthnSession,
} from '@/utilities/campaignWebAuthnSession'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const decodeClaims = (token: string): Record<string, unknown> => {
const segment = token.split('.')[1]
assert(segment)
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>
}
/**
* The stored session rows. `payload.findByID` runs `removePrivateAuthFields`
* and Payload strips `sessions` from an unauthenticated read anyway, so the
* assertion goes straight at the table.
*/
const readSessions = async (userID: number): Promise<unknown[]> => {
const result = await payload.db.drizzle.execute(
sql`SELECT id FROM campaign_user_sessions WHERE _parent_id = ${userID}`,
)
return result.rows
}
/**
* Pins the password-less session mint (roadmap B40). The dangerous part is not
* the crypto — `@simplewebauthn/server` owns that — it is that
* `addSessionToUser` rewrites the whole user document, so a mint built on
* `payload.findByID` would silently sign the account out everywhere else.
*/
describe('campaign WebAuthn session mint', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('mints a token that authenticates as the user, with the 14-day TTL', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const { token, tokenExpiration } = await issueCampaignWebAuthnSession(payload, advisor.id)
expect(tokenExpiration).toBe(CAMPAIGN_SESSION_TTL_LONG)
const authenticated = await authenticateCampaignToken(token, payload)
expect(authenticated).toMatchObject({ id: advisor.id, role: 'advisor' })
const claims = decodeClaims(token)
expect(claims).toMatchObject({ collection: 'campaignUser', id: advisor.id, role: 'advisor' })
expect(claims.sid).toEqual(expect.any(String))
// JWT `exp` and the cookie `maxAge` are the same 14 days, so the cookie is
// never left holding a token the server already rejects.
expect(Number(claims.exp) - Number(claims.iat)).toBe(CAMPAIGN_SESSION_TTL_LONG)
})
it('keeps every other session of the account alive', async () => {
const fixtures = campaignFixtures()
const password = fixtures.value('password')
const email = `${fixtures.value('passkey-mint')}@example.com`
const user = await payload.create({
collection: 'campaignUser',
data: { name: 'Assessoria com dois aparelhos', email, password, role: 'advisor' },
})
fixtures.own('campaignUser', user)
const passwordLogin = await payload.login({
collection: 'campaignUser',
data: { email, password },
})
assert(passwordLogin.token)
const { token: passkeyToken } = await issueCampaignWebAuthnSession(payload, user.id)
// The mine: the password session must survive the passkey login.
await expect(authenticateCampaignToken(passwordLogin.token, payload)).resolves.toMatchObject({
id: user.id,
})
await expect(authenticateCampaignToken(passkeyToken, payload)).resolves.toMatchObject({
id: user.id,
})
expect(await readSessions(user.id)).toHaveLength(2)
})
it('does not disturb the account document it writes back', async () => {
const fixtures = campaignFixtures()
const password = fixtures.value('password')
const email = `${fixtures.value('passkey-writeback')}@example.com`
const user = await payload.create({
collection: 'campaignUser',
data: { name: 'Assessoria intacta', email, password, role: 'advisor' },
})
fixtures.own('campaignUser', user)
const before = await payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
overrideAccess: true,
})
await issueCampaignWebAuthnSession(payload, user.id)
const after = await payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
overrideAccess: true,
})
expect(after).toMatchObject({
name: before.name,
role: before.role,
username: before.username,
email: before.email,
// Payload deliberately skips the `updatedAt` bump for a login.
updatedAt: before.updatedAt,
})
// The password still works: the write-back goes through `update`, which is
// the operation that could blank the hash it never reads.
await expect(
payload.login({ collection: 'campaignUser', data: { email, password } }),
).resolves.toMatchObject({ user: { id: user.id } })
})
it('refuses a locked account instead of letting biometrics bypass the lockout', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('advisor')
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { lockUntil: new Date(Date.now() + 60_000).toISOString() },
overrideAccess: true,
})
await expect(issueCampaignWebAuthnSession(payload, user.id)).rejects.toThrow(
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
)
expect(await readSessions(user.id)).toHaveLength(0)
})
it('refuses an account that no longer exists', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('leader')
await payload.delete({ collection: 'campaignUser', id: user.id, overrideAccess: true })
await expect(issueCampaignWebAuthnSession(payload, user.id)).rejects.toThrow(
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
)
})
it('serializes concurrent mints so neither loses the other session', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('advisor')
const [first, second] = await Promise.all([
issueCampaignWebAuthnSession(payload, user.id),
issueCampaignWebAuthnSession(payload, user.id),
])
await expect(authenticateCampaignToken(first.token, payload)).resolves.toMatchObject({
id: user.id,
})
await expect(authenticateCampaignToken(second.token, payload)).resolves.toMatchObject({
id: user.id,
})
expect(await readSessions(user.id)).toHaveLength(2)
})
})

View file

@ -0,0 +1,109 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* A fake cookie jar, because the challenge is deliberately NOT a database row:
* the signature and the TTL in the cookie are the whole mechanism, so they are
* what has to be pinned.
*/
const mocks = vi.hoisted(() => {
const jar = new Map<string, string>()
return {
jar,
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = jar.get(name)
return value === undefined ? undefined : { name, value }
},
set: (name: string, value: string) => {
jar.set(name, value)
},
})),
}
})
vi.mock('next/headers', () => ({ cookies: mocks.cookies }))
import {
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
clearCampaignWebAuthnChallenge,
readCampaignWebAuthnChallenge,
storeCampaignWebAuthnChallenge,
} from '@/utilities/campaignWebAuthnChallenge'
const CHALLENGE = 'Zm9vYmFyLWNoYWxsZW5nZS1iYXNlNjR1cmw'
describe('campaign WebAuthn challenge cookie', () => {
beforeEach(() => {
mocks.jar.clear()
process.env.PAYLOAD_SECRET = 'webauthn-challenge-test-secret'
})
it('reads back the exact value the browser will echo', async () => {
await storeCampaignWebAuthnChallenge('authentication', CHALLENGE)
await expect(readCampaignWebAuthnChallenge('authentication')).resolves.toBe(CHALLENGE)
})
it('refuses a challenge minted for the other ceremony', async () => {
await storeCampaignWebAuthnChallenge('registration', CHALLENGE, '7')
// Not merely a different cookie: the ceremony is inside the signature, so
// even a copied cookie value cannot be spent on the other flow.
await expect(readCampaignWebAuthnChallenge('authentication')).rejects.toThrow(
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
)
})
it('refuses a registration challenge read for a different account', async () => {
await storeCampaignWebAuthnChallenge('registration', CHALLENGE, '7')
await expect(readCampaignWebAuthnChallenge('registration', '8')).rejects.toThrow(
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
)
})
it('refuses a tampered challenge, expiry or signature', async () => {
await storeCampaignWebAuthnChallenge('authentication', CHALLENGE)
const [challenge, expiresAt, signature] = (
mocks.jar.get('campaign-webauthn-authentication') ?? ''
).split('.') as [string, string, string]
for (const forged of [
`${challenge}x.${expiresAt}.${signature}`,
// Pushing the expiry out is the attack the signature exists to stop.
`${challenge}.${Number(expiresAt) + 60_000}.${signature}`,
`${challenge}.${expiresAt}.${signature.slice(0, -2)}`,
challenge,
]) {
mocks.jar.set('campaign-webauthn-authentication', forged)
await expect(readCampaignWebAuthnChallenge('authentication')).rejects.toThrow(
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
)
}
})
it('refuses an expired challenge', async () => {
vi.useFakeTimers()
try {
await storeCampaignWebAuthnChallenge('authentication', CHALLENGE)
vi.advanceTimersByTime(6 * 60 * 1000)
await expect(readCampaignWebAuthnChallenge('authentication')).rejects.toThrow(
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
)
} finally {
vi.useRealTimers()
}
})
it('is single use once cleared', async () => {
await storeCampaignWebAuthnChallenge('authentication', CHALLENGE)
await clearCampaignWebAuthnChallenge('authentication')
await expect(readCampaignWebAuthnChallenge('authentication')).rejects.toThrow(
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
)
})
})

View file

@ -0,0 +1,54 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { getCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig'
/**
* The relying party decides whether biometric login (B40) is offered at all, and
* getting it wrong is invisible until a real device throws mid-ceremony. The
* three environments that matter are pinned here, the preview case above all.
*/
describe('getCampaignWebAuthnRelyingParty', () => {
it('derives the RP from the canonical HTTPS origin in production', () => {
expect(
getCampaignWebAuthnRelyingParty({
environment: 'production',
configuredURL: 'https://pt.jorgesolla.com.br',
forwardedHost: 'pt.jorgesolla.com.br',
forwardedProto: 'https',
}),
).toMatchObject({ rpID: 'pt.jorgesolla.com.br', origin: 'https://pt.jorgesolla.com.br' })
})
it('refuses a Vercel preview, where the served host is not the configured one', () => {
// The invite policy answers with the canonical origin here — NODE_ENV is
// production on a preview and NEXT_PUBLIC_SITE_URL is set — so without the
// host comparison the button would mount and the browser would reject the
// ceremony with a SecurityError.
expect(
getCampaignWebAuthnRelyingParty({
environment: 'production',
configuredURL: 'https://pt.jorgesolla.com.br',
forwardedHost: 'teqo-git-b40-solla.vercel.app',
forwardedProto: 'https',
}),
).toBeNull()
})
it('follows the local port in development, so any pnpm dev works unconfigured', () => {
expect(
getCampaignWebAuthnRelyingParty({
environment: 'development',
configuredURL: undefined,
forwardedHost: 'localhost:3100',
}),
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3100' })
})
it('refuses production without a configured site URL', () => {
expect(
getCampaignWebAuthnRelyingParty({ environment: 'production', configuredURL: undefined }),
).toBeNull()
})
})