diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index f75ee6f..81420cd 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -10,10 +10,11 @@ import { } from "../src/ceremonies"; import type { PublicKeyCredentialCreationOptionsJson, - PublicKeyCredentialRequestOptionsJson, + PublicKeyCredentialRequestOptionsJson, StartRegistrationRequest, StartRegistrationResponse, } from "../src/types"; -import {HttpHandler, HttpServer} from "./helpers/httpServer"; +import {decodeJson, encodeJson, Handler, HttpHandler, HttpServer, newHttpHandler} from "./helpers/httpServer"; import * as http from "node:http"; +import {RegistrationService} from "./helpers/registration"; const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = { rp: { id: "example.com", name: "Example" }, @@ -134,6 +135,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({ @@ -146,11 +148,59 @@ describe("PkAuthCeremonyClient http test", () => { ); await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler"); - expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration + + expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration.ts + }) + + it("when start registration succeeds but no credential is created, then the client fails with credential cancellation", async() => { + const registration = new RegistrationService("localhost", "alice", "bob") + using server= await startHttpServer({ + [startPath]: newStartHandler(registration), + [finishPath]: errorHttpHandler + }); + const credentialsContainer = noOpCredentialsContainer(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: credentialsContainer }, + ); + + await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow(); + + expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential + }) + + it("when finish registration fails, the credential was still created", async() => { + const registration = new RegistrationService("localhost", "alice", "bob") + using server= await startHttpServer({ + [startPath]: newStartHandler(registration), + [finishPath]: errorHttpHandler + }); + const credentialsContainer = mockCredentialContainer(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: credentialsContainer }, + ); + + await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler"); + + expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential + // TODO: wait for the finish handler to more strongly assert behavior of "finish" phase of client.register }) const startPath = "/auth/passkeys/registration/start"; - //currently not used: const finishPath = "/auth/passkeys/registration/finish" + const finishPath = "/auth/passkeys/registration/finish" + + function mockCredentialContainer() : CredentialsContainer { + return { + create: vi.fn(async () => + fakeCredential(new Uint8Array([7]), { + clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer, + attestationObject: new Uint8Array([0]).buffer as ArrayBuffer, + } as unknown as AuthenticatorResponse), + ), + get: vi.fn(), + } as unknown as CredentialsContainer; + } function noOpCredentialsContainer() : CredentialsContainer { return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()}; @@ -161,6 +211,14 @@ describe("PkAuthCeremonyClient http test", () => { res.end("unit test error from errorHttpHandler") } + function newStartHandler(registration: RegistrationService) : HttpHandler{ + const endpoint = async (req: StartRegistrationRequest) : Promise => { + return registration.start(req); + }; + const handler = new Handler(decodeJson, endpoint, encodeJson); + return newHttpHandler(handler); + } + async function startHttpServer(routes: Record) : Promise { const server = new HttpServer(routes); await server.listen(); diff --git a/clients/passkeys-browser/test/helpers/registration.test.ts b/clients/passkeys-browser/test/helpers/registration.test.ts new file mode 100644 index 0000000..7c50064 --- /dev/null +++ b/clients/passkeys-browser/test/helpers/registration.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import {RegistrationService} from "./registration"; + +describe("RegistrationService", () => { + describe("start", () => { + it("returns 403 when a username is not in the allowlist", async () => { + const service = new RegistrationService("localhost", "alice") + expect( + () => service.start({ username: "eve", displayName: null, label: null, challenge: null }), + ).toThrow("HTTP 403"); + }); + + it("returns a StartRegistrationResponse when the request is valid", async () => { + const service = new RegistrationService("localhost", "alice") + + const response = service.start({ username: "alice", displayName: null, label: null, challenge: null }) + + expect(response.challengeId).toBeTypeOf("string"); + expect(response.publicKey.user.id).toBeTypeOf("string"); + expect(response.publicKey.challenge).toBeTypeOf("string"); + expect(response.publicKey.rp).toEqual({id: "localhost", name: "Test"}); + expect(response.publicKey.user.name).toEqual("alice"); + expect(response.publicKey.user.displayName).toEqual("alice"); + expect(response.publicKey.pubKeyCredParams).toEqual([{ type: "public-key", alg: -7 }]); + expect(response.publicKey.timeout).toEqual(60_000); + expect(response.publicKey.attestation).toEqual("direct"); + }); + }); +}); diff --git a/clients/passkeys-browser/test/helpers/registration.ts b/clients/passkeys-browser/test/helpers/registration.ts new file mode 100644 index 0000000..fb1c77a --- /dev/null +++ b/clients/passkeys-browser/test/helpers/registration.ts @@ -0,0 +1,74 @@ + +import * as b64u from "../../src/base64url"; +import {StartRegistrationRequest, StartRegistrationResponse} from "../../src"; +import {HttpError} from "./httpServer"; + +interface PendingEntry { + displayName: string; + username: string; + userId: string; + challenge: b64u.Base64Url; + challengeId: string; + rpId: string; +} + +/* +RegistrationService is a representation of a server side service that can start and finish +client registrations. +Instantiate the service with a relying party id [rpId] and a list of allowed users. +A registration is started with a call to start which will produce a challenge that +the client must respond to when calling finish. + */ +export class RegistrationService { + // pending is the map of registrations which have been started; but not yet finished. + // Maps challenge id to a PendingEntry structure. + // A registration.ts cannot be finished unless it has been successfully started. + // A successfully started registration.ts must appear on the pending map. + private readonly pending : Map; + private readonly rpId : string; + private readonly allowedUsernames : string[]; + + constructor(rpId: string,...allowedUsernames: string[]) { + this.pending = new Map(); + this.rpId = rpId; + this.allowedUsernames = allowedUsernames ?? []; + } + + start(req : StartRegistrationRequest) : StartRegistrationResponse { + if (!this.allowedUsernames.includes(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))); + const userId = b64u.encode(crypto.getRandomValues(new Uint8Array(16))); + const displayName = req.displayName ?? req.username; + const pendingEntry = { + displayName, + username: req.username, + userId, + challenge, + challengeId, + rpId: this.rpId }; + + this.pending.set(challengeId, pendingEntry); // save the requests which will be validated in finish + return newStartRegistrationResponse(pendingEntry) + } +} + +function newStartRegistrationResponse(pendingEntry: PendingEntry) : StartRegistrationResponse { + return { + challengeId: pendingEntry.challengeId, + publicKey: { + rp: { id: pendingEntry.rpId, name: "Test" }, + user: { + id: pendingEntry.userId, + name: pendingEntry.username, + displayName: pendingEntry.displayName, + }, + challenge: pendingEntry.challenge, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + timeout: 60_000, + attestation: "direct", + }, + }; +}