Skip to content

Commit 6fe2e6c

Browse files
author
Andrew Bernat
committed
Add RegistrationService. This is a service.
It is used for testing to mimic the server starting and finishing a web authentication. The test code wraps the registration service with http handlers. The client invokes the test http service to as part of its start, create credential, finish protocol.
1 parent 6ccbc4d commit 6fe2e6c

3 files changed

Lines changed: 165 additions & 4 deletions

File tree

clients/passkeys-browser/test/ceremonies.test.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
} from "../src/ceremonies";
1111
import type {
1212
PublicKeyCredentialCreationOptionsJson,
13-
PublicKeyCredentialRequestOptionsJson,
13+
PublicKeyCredentialRequestOptionsJson, StartRegistrationRequest, StartRegistrationResponse,
1414
} from "../src/types";
15-
import {HttpHandler, HttpServer} from "./helpers/httpServer";
15+
import {decodeJson, encodeJson, Handler, HttpHandler, HttpServer, newHttpHandler} from "./helpers/httpServer";
1616
import * as http from "node:http";
17+
import {RegistrationService} from "./helpers/registration";
1718

1819
const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = {
1920
rp: { id: "example.com", name: "Example" },
@@ -134,6 +135,7 @@ describe("encodeAuthenticationResponse", () => {
134135
});
135136
});
136137

138+
137139
describe("PkAuthCeremonyClient http test", () => {
138140
it("when startRegistration endpoint returns 500 then the client makes no attempt to create credential", async() => {
139141
using server= await startHttpServer({
@@ -146,11 +148,59 @@ describe("PkAuthCeremonyClient http test", () => {
146148
);
147149

148150
await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler");
149-
expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration
151+
152+
expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration.ts
153+
})
154+
155+
it("when start registration succeeds but no credential is created, then the client fails with credential cancellation", async() => {
156+
const registration = new RegistrationService("localhost", "alice", "bob")
157+
using server= await startHttpServer({
158+
[startPath]: newStartHandler(registration),
159+
[finishPath]: errorHttpHandler
160+
});
161+
const credentialsContainer = noOpCredentialsContainer();
162+
const client = new PkAuthCeremonyClient(
163+
{ apiBase: server.url },
164+
{ credentials: credentialsContainer },
165+
);
166+
167+
await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow();
168+
169+
expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential
170+
})
171+
172+
it("when finish registration fails, the credential was still created", async() => {
173+
const registration = new RegistrationService("localhost", "alice", "bob")
174+
using server= await startHttpServer({
175+
[startPath]: newStartHandler(registration),
176+
[finishPath]: errorHttpHandler
177+
});
178+
const credentialsContainer = mockCredentialContainer();
179+
const client = new PkAuthCeremonyClient(
180+
{ apiBase: server.url },
181+
{ credentials: credentialsContainer },
182+
);
183+
184+
await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler");
185+
186+
expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential
187+
// TODO: wait for the finish handler to more strongly assert behavior of "finish" phase of client.register
150188
})
151189

152190
const startPath = "/auth/passkeys/registration/start";
153-
//currently not used: const finishPath = "/auth/passkeys/registration/finish"
191+
const finishPath = "/auth/passkeys/registration.ts/finish"
192+
193+
function mockCredentialContainer() : CredentialsContainer {
194+
return {
195+
create: vi.fn(async () =>
196+
fakeCredential(new Uint8Array([7]), {
197+
clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer,
198+
attestationObject: new Uint8Array([0]).buffer as ArrayBuffer,
199+
} as unknown as AuthenticatorResponse),
200+
),
201+
get: vi.fn(),
202+
} as unknown as CredentialsContainer;
203+
}
154204

155205
function noOpCredentialsContainer() : CredentialsContainer {
156206
return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()};
@@ -161,6 +211,14 @@ describe("PkAuthCeremonyClient http test", () => {
161211
res.end("unit test error from errorHttpHandler")
162212
}
163213

214+
function newStartHandler(registration: RegistrationService) : HttpHandler{
215+
const endpoint = async (req: StartRegistrationRequest) : Promise<StartRegistrationResponse> => {
216+
return registration.start(req);
217+
};
218+
const handler = new Handler(decodeJson, endpoint, encodeJson);
219+
return newHttpHandler(handler);
220+
}
221+
164222
async function startHttpServer(routes: Record<string, HttpHandler>) : Promise<HttpServer> {
165223
const server = new HttpServer(routes);
166224
await server.listen();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import {RegistrationService} from "./registration";
3+
4+
describe("RegistrationService", () => {
5+
describe("start", () => {
6+
it("returns 403 when a username is not in the allowlist", async () => {
7+
const service = new RegistrationService("localhost", "alice")
8+
expect(
9+
() => service.start({ username: "eve", displayName: null, label: null, challenge: null }),
10+
).toThrow("HTTP 403");
11+
});
12+
13+
it("returns a StartRegistrationResponse when the request is valid", async () => {
14+
const service = new RegistrationService("localhost", "alice")
15+
16+
const response = service.start({ username: "alice", displayName: null, label: null, challenge: null })
17+
18+
expect(response.challengeId).toBeTypeOf("string");
19+
expect(response.publicKey.user.id).toBeTypeOf("string");
20+
expect(response.publicKey.challenge).toBeTypeOf("string");
21+
expect(response.publicKey.rp).toEqual({id: "localhost", name: "Test"});
22+
expect(response.publicKey.user.name).toEqual("alice");
23+
expect(response.publicKey.user.displayName).toEqual("alice");
24+
expect(response.publicKey.pubKeyCredParams).toEqual([{ type: "public-key", alg: -7 }]);
25+
expect(response.publicKey.timeout).toEqual(60_000);
26+
expect(response.publicKey.attestation).toEqual("direct");
27+
});
28+
});
29+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
2+
import * as b64u from "../../src/base64url";
3+
import {StartRegistrationRequest, StartRegistrationResponse} from "../../src";
4+
import {HttpError} from "./httpServer";
5+
6+
interface PendingEntry {
7+
displayName: string;
8+
username: string;
9+
userId: string;
10+
challenge: b64u.Base64Url;
11+
challengeId: string;
12+
rpId: string;
13+
}
14+
15+
/*
16+
RegistrationService is a representation of a server side service that can start and finish
17+
client registrations.
18+
Instantiate the service with a relying party id [rpId] and a list of allowed users.
19+
A registration is started with a call to start which will produce a challenge that
20+
the client must respond to when calling finish.
21+
*/
22+
export class RegistrationService {
23+
// pending is the map of registrations which have been started; but not yet finished.
24+
// Maps challenge id to a PendingEntry structure.
25+
// A registration.ts cannot be finished unless it has been successfully started.
26+
// A successfully started registration.ts must appear on the pending map.
27+
private readonly pending : Map<string, PendingEntry>;
28+
private readonly rpId : string;
29+
private readonly allowedUsernames : string[];
30+
31+
constructor(rpId: string,...allowedUsernames: string[]) {
32+
this.pending = new Map<string, PendingEntry>();
33+
this.rpId = rpId;
34+
this.allowedUsernames = allowedUsernames ?? [];
35+
}
36+
37+
start(req : StartRegistrationRequest) : StartRegistrationResponse {
38+
if (!this.allowedUsernames.includes(req.username)) {
39+
throw new HttpError(403, { error: `username '${req.username}' not allowed` });
40+
}
41+
const challengeId = b64u.encode(crypto.getRandomValues(new Uint8Array(16)));
42+
const challenge = b64u.encode(crypto.getRandomValues(new Uint8Array(32)));
43+
const userId = b64u.encode(crypto.getRandomValues(new Uint8Array(16)));
44+
const displayName = req.displayName ?? req.username;
45+
const pendingEntry = {
46+
displayName,
47+
username: req.username,
48+
userId,
49+
challenge,
50+
challengeId,
51+
rpId: this.rpId };
52+
53+
this.pending.set(challengeId, pendingEntry); // save the requests which will be validated in finish
54+
return newStartRegistrationResponse(pendingEntry)
55+
}
56+
}
57+
58+
function newStartRegistrationResponse(pendingEntry: PendingEntry) : StartRegistrationResponse {
59+
return {
60+
challengeId: pendingEntry.challengeId,
61+
publicKey: {
62+
rp: { id: pendingEntry.rpId, name: "Test" },
63+
user: {
64+
id: pendingEntry.userId,
65+
name: pendingEntry.username,
66+
displayName: pendingEntry.displayName,
67+
},
68+
challenge: pendingEntry.challenge,
69+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
70+
timeout: 60_000,
71+
attestation: "direct",
72+
},
73+
};
74+
}

0 commit comments

Comments
 (0)