From 777943c1f71b7a450bcc0449cd921470c7ca6347 Mon Sep 17 00:00:00 2001 From: Andrew Bernat Date: Tue, 7 Jul 2026 22:40:46 -0700 Subject: [PATCH] Add more tests to authenticate path. These tests use the fake ceremony service that mimics a real web server --- .../passkeys-browser/test/ceremonies.test.ts | 221 +++++++++--------- .../passkeys-browser/test/helpers/ceremony.ts | 146 ++++++++++-- .../test/helpers/fakeAuthenticator.ts | 14 +- .../passkeys-browser/test/helpers/types.ts | 1 + 4 files changed, 259 insertions(+), 123 deletions(-) create mode 100644 clients/passkeys-browser/test/helpers/types.ts diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index 2ffc738..564492f 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -25,6 +25,12 @@ import { import * as http from "node:http"; import {CeremonyService} from "./helpers/ceremony"; import {FakeAuthenticator} from "./helpers/fakeAuthenticator"; +import { + FinishAuthenticationRequest, + FinishAuthenticationResponse, + StartAuthenticationRequest, + StartAuthenticationResponse +} from "../dist/src"; const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = { rp: { id: "example.com", name: "Example" }, @@ -148,7 +154,7 @@ describe("encodeAuthenticationResponse", () => { describe("PkAuthCeremonyClient http test", () => { it("when startRegistration endpoint returns 500 then the client makes no attempt to create credential", async() => { using server= await startHttpServer({ - [startPath]: errorHttpHandler, + [registrationStartPath]: errorHttpHandler, }); const credentialsContainer = noOpCredentialsContainer(); const client = new PkAuthCeremonyClient( @@ -164,8 +170,8 @@ describe("PkAuthCeremonyClient http test", () => { it("when start registration succeeds but no credential is created, then the client fails with credential cancellation", async() => { const registration = new CeremonyService("localhost", "alice", "bob") using server= await startHttpServer({ - [startPath]: newStartHandler(registration), - [finishPath]: newFinishHandler(registration) + [registrationStartPath]: newStartHandler(registration), + [registrationFinishPath]: newFinishHandler(registration) }); const credentialsContainer = noOpCredentialsContainer(); const client = new PkAuthCeremonyClient( @@ -180,8 +186,8 @@ describe("PkAuthCeremonyClient http test", () => { it("when finish registration fails, the credential was still created", async() => { const registration = new CeremonyService("localhost", "alice", "bob") using server= await startHttpServer({ - [startPath]: newStartHandler(registration), - [finishPath]: errorHttpHandler + [registrationStartPath]: newStartHandler(registration), + [registrationFinishPath]: errorHttpHandler }); const credentialsContainer = new FakeAuthenticator(); credentialsContainer.create = vi.fn(credentialsContainer.create) @@ -198,8 +204,8 @@ describe("PkAuthCeremonyClient http test", () => { it("when registering a credential, then the server returns a finished response", async() => { const registration = new CeremonyService("localhost", "alice", "bob") using server = await startHttpServer({ - [startPath]: newStartHandler(registration), - [finishPath]: newFinishHandler(registration) + [registrationStartPath]: newStartHandler(registration), + [registrationFinishPath]: newFinishHandler(registration) }); const authenticator = new FakeAuthenticator(); const client = new PkAuthCeremonyClient( @@ -225,8 +231,87 @@ describe("PkAuthCeremonyClient http test", () => { expect(retrievedCredential?.type).toEqual("public-key"); }) - const startPath = "/auth/passkeys/registration/start"; - const finishPath = "/auth/passkeys/registration/finish" + it("when registering a credential, it can be used for authentication", async () => { + const server = await testServer("alice", "bob"); + const authenticator = new FakeAuthenticator(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: authenticator }, + ); + + await client.register({ username: "alice", label: "key" }); + const authenticated = await client.authenticate({ username: "alice"}) + + expect(authenticated.token).to.match(/\S+/); //at least one non-whitespace char + }); + + it("when failing to authenticate on start, the client fails gracefully", async () => { + const server = await testServer("alice"); + const authenticator = new FakeAuthenticator(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: authenticator }, + ); + + await client.register({ username: "alice", label: "key" }); + + await expect(client.authenticate({ username: "not_alice"})).rejects.toThrow('HTTP 403: {"error":"username \'not_alice\' not allowed"}'); + }); + + it("when authenticating, omit username supports any user that can respond to challenge to be authenticated", async () => { + const server = await testServer("alice"); + const authenticator = new FakeAuthenticator(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: authenticator }, + ); + + await client.register({ username: "alice", label: "key" }); + const authenticated = await client.authenticate({ }) // no username means any user + + expect(authenticated.token).to.match(/\S+/); //at least one non-whitespace char + }); + + it("when authenticating, conditional mediation is honored by client", async () => { + const server = await testServer("alice"); + const authenticator = new FakeAuthenticator({requireConditionalMediationOnGet: true}); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: authenticator }, + ); + + await client.register({ username: "alice", label: "key" }); + const authenticated = await client.authenticate({ conditional: true}) + + expect(authenticated.token).to.match(/\S+/); //at least one non-whitespace char + }); + + it("when authenticating, then failure to get a credential results in authentication cancelled", async () => { + const server = await testServer("alice", "bob"); + const authenticator = new FakeAuthenticatorThatFailsGet(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: authenticator }, + ); + + await client.register({ username: "alice", label: "key" }); + await expect(client.authenticate({ username: "alice"})).rejects.toThrow("pk-auth: authentication was cancelled"); + }); + + const registrationStartPath = "/auth/passkeys/registration/start"; + const registrationFinishPath = "/auth/passkeys/registration/finish"; + const authenticationStartPath = "/auth/passkeys/authentication/start"; + const authenticationFinishPath = "/auth/passkeys/authentication/finish"; + + async function testServer(...allowedUsernames: string[]) : Promise { + const registration = new CeremonyService("localhost", ...allowedUsernames) + return startHttpServer({ + [registrationStartPath]: newStartHandler(registration), + [registrationFinishPath]: newFinishHandler(registration), + [authenticationStartPath]: newAuthenticationStartHandler(registration), + [authenticationFinishPath]: newAuthenticationFinishHandler(registration) + }); + } function noOpCredentialsContainer() : CredentialsContainer { return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()}; @@ -245,6 +330,22 @@ describe("PkAuthCeremonyClient http test", () => { return newHttpHandler(handler); } + function newAuthenticationStartHandler(registration: CeremonyService) : HttpHandler { + const endpoint = async (req: StartAuthenticationRequest) : Promise => { + return registration.startAuthentication(req); + }; + const handler = new Handler(decodePost, endpoint, encodeJson); + return newHttpHandler(handler); + } + + function newAuthenticationFinishHandler(registration: CeremonyService) : HttpHandler { + const endpoint = async (req: FinishAuthenticationRequest) : Promise => { + return registration.finishAuthentication(req); + }; + const handler = new Handler(decodePost, endpoint, encodeJson); + return newHttpHandler(handler); + } + function newFinishHandler(registration: CeremonyService) : HttpHandler{ const endpoint = async (req: FinishRegistrationRequest) : Promise => { return registration.finishRegistration(req); @@ -283,89 +384,6 @@ describe("PkAuthCeremonyClient.register (start-body contract)", () => { }); }); -describe("PkAuthCeremonyClient.authenticate (end-to-end with stubbed credentials)", () => { - it("walks start -> get -> finish and returns the token", async () => { - const { fetchImpl, bodies, methods, urls } = ceremonyFetch({ - "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, - "/authentication/finish": { token: "jwt-token" }, - }); - const get = vi.fn(async () => fakeAssertion(new Uint8Array([42]))); - const fakeCreds = { create: vi.fn(), get } as unknown as CredentialsContainer; - const client = new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, - { credentials: fakeCreds }, - ); - - const result = await client.authenticate({ username: "alice" }); - - expect(result.token).toBe("jwt-token"); - expect(get).toHaveBeenCalledOnce(); - expect(bodies["/authentication/start"]).toMatchObject({ username: "alice", challenge: null }); - expect(bodies["/authentication/finish"]).toMatchObject({ challengeId: "ch-9" }); - expect(methods["/authentication/start"]).toBe("POST"); - expect(methods["/authentication/finish"]).toBe("POST"); - expect(urls.some((u) => u.endsWith("/authentication/start"))).toBe(true); - }); - - it("defaults username to null when omitted", async () => { - const { fetchImpl, bodies } = ceremonyFetch({ - "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, - "/authentication/finish": { token: "t" }, - }); - const fakeCreds = { - create: vi.fn(), - get: vi.fn(async () => fakeAssertion(new Uint8Array([1]))), - } as unknown as CredentialsContainer; - const client = new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, - { credentials: fakeCreds }, - ); - await client.authenticate(); - // username ?? null (kills the `&&` mutant): no username -> explicit null. - expect(bodies["/authentication/start"]).toMatchObject({ username: null }); - }); - - it("requests conditional mediation only when conditional=true", async () => { - const responses = { - "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, - "/authentication/finish": { token: "t" }, - }; - // conditional = true -> mediation set - const getCond = vi.fn(async (_options?: CredentialRequestOptions) => fakeAssertion(new Uint8Array([1]))); - const a = ceremonyFetch(responses); - await new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: a.fetchImpl as unknown as typeof fetch }, - { credentials: { create: vi.fn(), get: getCond } as unknown as CredentialsContainer }, - ).authenticate({ conditional: true }); - expect( - (getCond.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation, - ).toBe("conditional"); - - // conditional defaults to false -> no mediation - const getPlain = vi.fn(async (_options?: CredentialRequestOptions) => fakeAssertion(new Uint8Array([1]))); - const b = ceremonyFetch(responses); - await new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: b.fetchImpl as unknown as typeof fetch }, - { credentials: { create: vi.fn(), get: getPlain } as unknown as CredentialsContainer }, - ).authenticate(); - expect( - (getPlain.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation, - ).toBeUndefined(); - }); - - it("rejects when the authenticator returns no credential (get cancelled)", async () => { - const { fetchImpl } = ceremonyFetch({ - "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, - }); - const fakeCreds = { create: vi.fn(), get: vi.fn(async () => null) } as unknown as CredentialsContainer; - const client = new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, - { credentials: fakeCreds }, - ); - await expect(client.authenticate()).rejects.toThrow(/authentication was cancelled/); - }); -}); - describe("PkAuthCeremonyClient credentials guard", () => { it("throws a clear error when no credentials are injected and navigator lacks them", async () => { // jsdom does not implement navigator.credentials, so the fallback guard @@ -383,21 +401,10 @@ describe("PkAuthCeremonyClient credentials guard", () => { }); }); - -// Additional ceremony coverage driven by the StrykerJS report (PR #39, @bernata): -// the suite previously exercised only the register() happy path, leaving -// authenticate(), the credential create/get helpers, the cancellation branches, -// and the navigator.credentials guard as 0%-covered (every mutant survived). -// These ceremonies ARE unit-testable because CeremonyOptions.credentials injects -// a fake CredentialsContainer — so we drive the full flows here. - -function fakeAssertion(rawId: Uint8Array): PublicKeyCredential { - return fakeCredential(rawId, { - clientDataJSON: new Uint8Array([0x7b]).buffer as ArrayBuffer, - authenticatorData: new Uint8Array([0x55]).buffer as ArrayBuffer, - signature: new Uint8Array([0x99]).buffer as ArrayBuffer, - userHandle: null, - } as unknown as AuthenticatorResponse); +class FakeAuthenticatorThatFailsGet extends FakeAuthenticator { + override async get(options: CredentialRequestOptions): Promise { + return null; + } } /** Records every request body keyed by the URL suffix, and serves canned responses. */ diff --git a/clients/passkeys-browser/test/helpers/ceremony.ts b/clients/passkeys-browser/test/helpers/ceremony.ts index 144110c..3e15438 100644 --- a/clients/passkeys-browser/test/helpers/ceremony.ts +++ b/clients/passkeys-browser/test/helpers/ceremony.ts @@ -1,15 +1,20 @@ import * as b64u from "../../src/base64url"; import { + FinishAuthenticationRequest, + FinishAuthenticationResponse, FinishRegistrationRequest, FinishRegistrationResponse, + StartAuthenticationRequest, + StartAuthenticationResponse, StartRegistrationRequest, StartRegistrationResponse } from "../../src"; import {HttpError} from "./httpServer"; import {Encoder} from "cbor-x"; +import {WebAuthnType} from "./types"; -interface PendingEntry { +interface PendingRegistrationEntry { displayName: string; username: string; userId: string; @@ -18,6 +23,18 @@ interface PendingEntry { rpId: string; } +// PendingAuthenticationEntry tracks an authentication ceremony between start and finish. Unlike +// PendingRegistrationEntry, the username is optional: a caller may start authentication +// without knowing who is signing in yet (e.g. a resident/discoverable-credential flow), in +// which case the credential itself — looked up by id in `credentials` — is what identifies +// the user at finish time. +interface PendingAuthenticationEntry { + challenge: b64u.Base64Url; + challengeId: string; + rpId: string; + username: string | null; +} + // Credential is what finishRegistration persists so a later authentication ceremony can // verify an assertion against it. Keyed by the base64url-encoded credential id. interface Credential { @@ -40,8 +57,11 @@ the client must respond to with a call to finishRegistration. */ export class CeremonyService { // pendingRegistrations is the map of registrations which have been started; but not yet - // finished. Maps challenge id to a PendingEntry structure. - private readonly pendingRegistrations : Map; + // finished. Maps challenge id to a PendingRegistrationEntry structure. + private readonly pendingRegistrations : Map; + // pendingAuthentications is the analogous map for authentication ceremonies: + // challenge id -> PendingAuthenticationEntry. + private readonly pendingAuthentications : Map; // credentials holds every credential that has completed registration, keyed by the // base64url-encoded credential id. This is what finishAuthentication verifies assertions // against — without it there would be no public key to check a signature with. @@ -51,7 +71,8 @@ export class CeremonyService { private readonly allowedUsernames : string[]; constructor(rpId: string,...allowedUsernames: string[]) { - this.pendingRegistrations = new Map(); + this.pendingRegistrations = new Map(); + this.pendingAuthentications = new Map(); this.credentials = new Map(); this.rpId = rpId; this.allowedUsernames = allowedUsernames ?? []; @@ -97,7 +118,11 @@ export class CeremonyService { const attestation = decodeAttestation(req.response.response.attestationObject) const publicKey = await parsePublicKey(attestation.publicKeyBytes) - const isValid = await validateChallenge(publicKey, attestation, req.response.response.clientDataJSON) + const isValid = await verifySignature( + publicKey, + attestation.authData, + attestation.signature, + req.response.response.clientDataJSON); if (!isValid) { throw new HttpError(400, { error: "invalid signature" }); } @@ -131,25 +156,120 @@ export class CeremonyService { }, }; } + + + async startAuthentication(req: StartAuthenticationRequest): Promise { + if (req.username) { + if (!this.isUserCredentialExist(req.username)) { + throw new HttpError(403, { error: `username '${req.username}' not allowed` }); + } + } + + const challengeId = b64u.encode(crypto.getRandomValues(new Uint8Array(16))); + const challenge = b64u.encode(crypto.getRandomValues(new Uint8Array(32))); + + this.pendingAuthentications.set(challengeId, { + challenge, + challengeId, + rpId: this.rpId, + username: req.username ?? null, + }); + + return { + challengeId, + publicKey: { + challenge, + rpId: this.rpId, + userVerification: "preferred", + }, + }; + } + + async finishAuthentication(req: FinishAuthenticationRequest): Promise { + const entry = this.pendingAuthentications.get(req.challengeId); + if (!entry) { + throw new HttpError(404, { error: `unknown challengeId: ${req.challengeId}` }); + } + + const clientData = parseClientData(req.response.response.clientDataJSON); + if (clientData.type !== "webauthn.get") { + throw new HttpError(400, { error: `wrong clientDataJSON type: ${clientData.type}` }); + } + if (clientData.challenge !== entry.challenge) { + throw new HttpError(400, { error: "challenge mismatch" }); + } + + // The credential id in the response — not the (optional) username from startAuth — is the + // authority on who's signing in; this also covers resident-credential flows where the + // username was never supplied to startAuthentication. + const stored = this.credentials.get(req.response.rawId); + if (!stored) { + throw new HttpError(404, { error: `unknown credential: ${req.response.rawId}` }); + } + if (entry.username && stored.username !== entry.username) { + throw new HttpError(400, { error: `username mismatch: ${entry.username}` }); + } + + const authData = b64u.decode(req.response.response.authenticatorData); + const signature = b64u.decode(req.response.response.signature); + const isValid = await verifySignature(stored.publicKey, authData, signature, req.response.response.clientDataJSON); + if (!isValid) { + throw new HttpError(400, { error: "invalid signature" }); + } + + const flags = authData[32]!; + if ((flags & 0x01) === 0) { + throw new HttpError(400, { error: "user presence flag not set" }); + } + + const counter = new DataView(authData.buffer, authData.byteOffset).getUint32(33); + // Clone/replay detection: a nonzero counter must strictly increase. Authenticators that + // don't support counters (like this test suite's FakeAuthenticator) always report 0 — + // per the WebAuthn spec that case is exempt from the check. + if ((counter !== 0 || stored.counter !== 0) && counter <= stored.counter) { + throw new HttpError(400, { error: "authenticator counter did not increase" }); + } + stored.counter = counter; + + this.pendingAuthentications.delete(req.challengeId); + + return { + token: b64u.encode(crypto.getRandomValues(new Uint8Array(32))), + }; + } + + private isUserCredentialExist(username: string) : boolean { + const credentials = [...this.credentials.entries()]; + const filtered = credentials.filter(([, cred]) => cred.username === username); + return filtered.length > 0; + } } -async function validateChallenge(publicKey: CryptoKey, attestation: Attestation, clientDataJson: string) : Promise { +// verifySignature checks a WebAuthn signature over authData || SHA-256(clientDataJSON) — the +// exact bytes both attestation (registration) and assertion (authentication) responses sign. +async function verifySignature( + publicKey: CryptoKey, + authData: Uint8Array, + signature: Uint8Array, + clientDataJson: string, +) : Promise { const data = b64u.decode(clientDataJson); const clientDataHash = new Uint8Array( await crypto.subtle.digest("SHA-256", data), ); - const verificationData = new Uint8Array(attestation.authData.length + clientDataHash.length); - verificationData.set(attestation.authData); - verificationData.set(clientDataHash, attestation.authData.length); + const verificationData = new Uint8Array(authData.length + clientDataHash.length); + verificationData.set(authData); + verificationData.set(clientDataHash, authData.length); return crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, publicKey, - attestation.signature, + signature, verificationData, ); } + function parsePublicKey(publicKeyBytes : Uint8Array) : Promise { try { return crypto.subtle.importKey( @@ -206,14 +326,14 @@ function decodeAttestation(attestation: string) : Attestation { } } -function parseClientData(clientDataJson: string) : { type:string, challenge: string } { +function parseClientData(clientDataJson: string) : { type: WebAuthnType, challenge: string } { // Decode and validate clientDataJSON const data = b64u.decode(clientDataJson); try { return JSON.parse(new TextDecoder().decode(data)) as { type: string; challenge: string; - }; + } as {type:WebAuthnType, challenge: string}; } catch(e){ const eMessage = e as {message: string} const message = eMessage && eMessage.message ? eMessage.message : "unknown error"; @@ -221,7 +341,7 @@ function parseClientData(clientDataJson: string) : { type:string, challenge: str } } -function newStartRegistrationResponse(pendingEntry: PendingEntry) : StartRegistrationResponse { +function newStartRegistrationResponse(pendingEntry: PendingRegistrationEntry) : StartRegistrationResponse { return { challengeId: pendingEntry.challengeId, publicKey: { diff --git a/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts index 0b17b83..185b984 100644 --- a/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts +++ b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts @@ -2,18 +2,25 @@ import {Encoder} from "cbor-x"; import * as b64u from "../../src/base64url"; import {BufferUint8} from "./buffer"; +import {WebAuthnType} from "./types"; type StoredCredential = { keyPair: CryptoKeyPair; rawId: Uint8Array }; +export interface FakeAuthenticatorOptions { + requireConditionalMediationOnGet: boolean +} + export class FakeAuthenticator implements CredentialsContainer { // map id to keypair. The id is the base64 encoding of the randomly generated id [rawId]. private readonly stored : Map; + private readonly requireConditionalMediationOnGet: boolean; // lastId is used when get method is invoked w/o specifying a specific id. private lastId: string | null; - constructor() { + constructor(options?: FakeAuthenticatorOptions) { this.stored = new Map(); this.lastId = null; + this.requireConditionalMediationOnGet = options?.requireConditionalMediationOnGet || false; } async create(options: CredentialCreationOptions): Promise { @@ -50,6 +57,9 @@ export class FakeAuthenticator implements CredentialsContainer { } async get(options: CredentialRequestOptions): Promise { + if (this.requireConditionalMediationOnGet && options?.mediation !== "conditional") { + throw new Error("FakeAuthenticator.get: conditional mediation required to get credential") + } const pk = options.publicKey; if (!pk) throw new Error("FakeAuthenticator.get: publicKey options are required"); const rpId = pk.rpId; @@ -209,8 +219,6 @@ function curveValue(publicKey : CryptoKey) : number { throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`) } -type WebAuthnType = "webauthn.create" | "webauthn.get"; - function encodeClientData(challenge: Uint8Array, rpId: string, webAuthnType: WebAuthnType): Uint8Array { return new TextEncoder().encode( JSON.stringify({ diff --git a/clients/passkeys-browser/test/helpers/types.ts b/clients/passkeys-browser/test/helpers/types.ts new file mode 100644 index 0000000..8ce374c --- /dev/null +++ b/clients/passkeys-browser/test/helpers/types.ts @@ -0,0 +1 @@ +export type WebAuthnType = "webauthn.create" | "webauthn.get";