teqo-backup/src/collections/CampaignWebAuthnCredential.ts
Francisco Ignácio Fontoura Solla 763f64080a 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.
2026-07-28 10:59:23 -03:00

98 lines
2.7 KiB
TypeScript

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 },
},
],
}