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:
parent
eab4dae430
commit
763f64080a
49 changed files with 15248 additions and 29 deletions
File diff suppressed because one or more lines are too long
11
AGENTS.md
11
AGENTS.md
|
|
@ -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/*)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
217
pnpm-lock.yaml
217
pnpm-lock.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
43
src/app/(campaign)/campanha/actions/webauthn.ts
Normal file
43
src/app/(campaign)/campanha/actions/webauthn.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
36
src/app/(campaign)/campanha/webauthn/login-options/route.ts
Normal file
36
src/app/(campaign)/campanha/webauthn/login-options/route.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
56
src/app/(campaign)/campanha/webauthn/login/route.ts
Normal file
56
src/app/(campaign)/campanha/webauthn/login/route.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
49
src/app/(campaign)/campanha/webauthn/register/route.ts
Normal file
49
src/app/(campaign)/campanha/webauthn/register/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
98
src/collections/CampaignWebAuthnCredential.ts
Normal file
98
src/collections/CampaignWebAuthnCredential.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
231
src/components/campaign/auth/CampaignPasskeysCard.tsx
Normal file
231
src/components/campaign/auth/CampaignPasskeysCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
113
src/components/campaign/shell/BiometricEnrollmentToast.tsx
Normal file
113
src/components/campaign/shell/BiometricEnrollmentToast.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
48
src/lib/campaignWebAuthn.ts
Normal file
48
src/lib/campaignWebAuthn.ts
Normal 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
|
||||
128
src/lib/campaignWebAuthnClient.ts
Normal file
128
src/lib/campaignWebAuthnClient.ts
Normal 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
|
||||
}
|
||||
40
src/lib/campaignWebAuthnSupport.ts
Normal file
40
src/lib/campaignWebAuthnSupport.ts
Normal 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
30
src/lib/deviceLabel.ts
Normal 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
|
||||
}
|
||||
65
src/lib/schemas/campaignWebAuthn.ts
Normal file
65
src/lib/schemas/campaignWebAuthn.ts
Normal 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>
|
||||
12186
src/migrations/20260728_041958_add_campaign_webauthn_credentials.json
Normal file
12186
src/migrations/20260728_041958_add_campaign_webauthn_credentials.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
29
src/utilities/access/webauthnCredentials.ts
Normal file
29
src/utilities/access/webauthnCredentials.ts
Normal 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
|
||||
|
|
@ -127,3 +127,9 @@ export {
|
|||
canManageStateDeputy,
|
||||
canReadStateDeputy,
|
||||
} from '@/utilities/access/stateDeputies'
|
||||
|
||||
export {
|
||||
canDeleteOwnWebAuthnCredentials,
|
||||
canReadOwnWebAuthnCredentials,
|
||||
canWriteWebAuthnCredentials,
|
||||
} from '@/utilities/access/webauthnCredentials'
|
||||
|
|
|
|||
65
src/utilities/campaignBiometricsPrompt.ts
Normal file
65
src/utilities/campaignBiometricsPrompt.ts
Normal 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)
|
||||
308
src/utilities/campaignWebAuthnCeremony.ts
Normal file
308
src/utilities/campaignWebAuthnCeremony.ts
Normal 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
|
||||
}
|
||||
}
|
||||
118
src/utilities/campaignWebAuthnChallenge.ts
Normal file
118
src/utilities/campaignWebAuthnChallenge.ts
Normal 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))
|
||||
}
|
||||
97
src/utilities/campaignWebAuthnConfig.ts
Normal file
97
src/utilities/campaignWebAuthnConfig.ts
Normal 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'),
|
||||
})
|
||||
}
|
||||
106
src/utilities/campaignWebAuthnSession.ts
Normal file
106
src/utilities/campaignWebAuthnSession.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
121
tests/e2e/campaignWebAuthn.e2e.spec.ts
Normal file
121
tests/e2e/campaignWebAuthn.e2e.spec.ts
Normal 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/)
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
165
tests/int/campaignWebAuthnAccess.int.spec.ts
Normal file
165
tests/int/campaignWebAuthnAccess.int.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
186
tests/int/campaignWebAuthnSession.int.spec.ts
Normal file
186
tests/int/campaignWebAuthnSession.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
109
tests/unit/campaignWebAuthnChallenge.unit.spec.ts
Normal file
109
tests/unit/campaignWebAuthnChallenge.unit.spec.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
54
tests/unit/campaignWebAuthnConfig.unit.spec.ts
Normal file
54
tests/unit/campaignWebAuthnConfig.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue