Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions clients/passkeys-browser/test/ceremonies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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({
Expand All @@ -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()};
Expand All @@ -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<StartRegistrationResponse> => {
return registration.start(req);
};
const handler = new Handler(decodeJson, endpoint, encodeJson);
return newHttpHandler(handler);
}

async function startHttpServer(routes: Record<string, HttpHandler>) : Promise<HttpServer> {
const server = new HttpServer(routes);
await server.listen();
Expand Down
29 changes: 29 additions & 0 deletions clients/passkeys-browser/test/helpers/registration.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
74 changes: 74 additions & 0 deletions clients/passkeys-browser/test/helpers/registration.ts
Original file line number Diff line number Diff line change
@@ -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<string, PendingEntry>;
private readonly rpId : string;
private readonly allowedUsernames : string[];

constructor(rpId: string,...allowedUsernames: string[]) {
this.pending = new Map<string, PendingEntry>();
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",
},
};
}
Loading