import { describe, it, expect } from 'vitest' import { startRegistration, finishRegistration } from '../src/human/webauthn/register.js' import { startAuthentication, finishAuthentication } from '../src/human/webauthn/authenticate.js' import { WebAuthnError, type WebAuthnVerifier, type AuthenticationResponse, type RegistrationResponse, } from '../src/human/webauthn/verifier.js' import type { WebAuthnCredential } from '../src/types.js' const RP_ID = 'alice.term.example.com' const ORIGIN = 'https://alice.term.example.com' const NOW = 1_700_000_000 const verifier: WebAuthnVerifier = { verifyRegistration: async () => ({ verified: true, credentialId: 'cred-1', publicKey: new Uint8Array([1, 2, 3]), signCount: 0, transports: ['internal'], }), verifyAuthentication: async (_r, _c, _rp, _o, stored) => ({ verified: true, newSignCount: stored + 1 }), } function regResp(over: Partial = {}): RegistrationResponse { return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } } function authResp(over: Partial = {}): AuthenticationResponse { return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } } const cred: WebAuthnCredential = { credentialId: 'cred-1', accountId: 'acct-A', publicKey: new Uint8Array([1, 2, 3]), signCount: 5, transports: ['internal'], createdAt: '2026-01-01T00:00:00.000Z', } describe('WebAuthn / Passkey (T5, primary)', () => { it('startRegistration issues rpId + a challenge', async () => { const opts = await startRegistration('acct-A', RP_ID) expect(opts.rpId).toBe(RP_ID) expect(opts.challenge.length).toBeGreaterThan(0) expect(opts.userId).toBe('acct-A') }) it('finishRegistration mints a credential bound to the account', async () => { const c = await finishRegistration('acct-A', regResp(), 'chal', RP_ID, ORIGIN, verifier) expect(c.accountId).toBe('acct-A') expect(c.credentialId).toBe('cred-1') }) it('rejects a challenge mismatch (single-use / replayed challenge)', async () => { await expect(finishRegistration('acct-A', regResp({ clientChallenge: 'other' }), 'chal', RP_ID, ORIGIN, verifier)).rejects.toThrow( WebAuthnError, ) }) it('rejects an origin mismatch (assertion from evil.com)', async () => { await expect( finishAuthentication(cred, authResp({ origin: 'https://evil.com' }), 'chal', RP_ID, ORIGIN, verifier, NOW), ).rejects.toThrow('origin') }) it('happy path auth yields a principal with amr:[passkey]', async () => { const startOpts = await startAuthentication('acct-A', RP_ID) expect(startOpts.rpId).toBe(RP_ID) const p = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW) expect(p.accountId).toBe('acct-A') expect(p.amr).toEqual(['passkey']) expect(p.authAt).toBe(NOW) }) it('rejects a signCount regression (cloned authenticator signal)', async () => { const regressing: WebAuthnVerifier = { ...verifier, verifyAuthentication: async () => ({ verified: true, newSignCount: 3 }), // < stored 5 } await expect( finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, regressing, NOW), ).rejects.toThrow('signCount') }) it('rejects when the verifier reports not-verified', async () => { const bad: WebAuthnVerifier = { ...verifier, verifyAuthentication: async () => ({ verified: false, newSignCount: 99 }) } await expect(finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, bad, NOW)).rejects.toThrow( WebAuthnError, ) }) })