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
36 changes: 36 additions & 0 deletions clients/passkeys-browser/test/ceremonies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
PublicKeyCredentialCreationOptionsJson,
PublicKeyCredentialRequestOptionsJson,
} from "../src/types";
import {HttpHandler, HttpServer} from "./helpers/httpServer";
import * as http from "node:http";

const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = {
rp: { id: "example.com", name: "Example" },
Expand Down Expand Up @@ -132,6 +134,40 @@ 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,
});
const credentialsContainer = noOpCredentialsContainer();
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).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration
})

const startPath = "/auth/passkeys/registration/start";
//currently not used: const finishPath = "/auth/passkeys/registration/finish"

function noOpCredentialsContainer() : CredentialsContainer {
return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()};
}

function errorHttpHandler(_: http.IncomingMessage, res: http.ServerResponse) : void {
res.writeHead(500)
res.end("unit test error from errorHttpHandler")
}

async function startHttpServer(routes: Record<string, HttpHandler>) : Promise<HttpServer> {
const server = new HttpServer(routes);
await server.listen();
return server
}
});

describe("PkAuthCeremonyClient.register (end-to-end with stubbed credentials)", () => {
it("walks start -> create -> finish, propagating the challenge id", async () => {
const startBody: PublicKeyCredentialCreationOptionsJson = CREATE_OPTIONS_JSON;
Expand Down
84 changes: 84 additions & 0 deletions clients/passkeys-browser/test/helpers/httpServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
import { describe, expect, it } from "vitest";
import {HttpServer, decodeJson, encodeJson} from "./httpServer";

describe("Httpserver", () => {
it("routes a POST request to the matching handler and returns its response", async () => {
await using server = new HttpServer({
"/hello": async (req, res) => {
const body = await decodeJson<{ msg: string }>(req);
await encodeJson({ echo: body.msg }, res);
},
});
await server.listen();

const res = await fetch(`${server.url}/hello`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msg: "world" }),
});

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ echo: "world" });
});

it("returns 404 for an unregistered path", async () => {
await using server = new HttpServer({});
await server.listen();

const res = await fetch(`${server.url}/missing`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});

expect(res.status).toBe(404);
});

it("returns 405 for a non-POST request", async () => {
await using server = new HttpServer({
"/hello": async (_req, res) => { res.writeHead(200); res.end(); },
});
await server.listen();

const res = await fetch(`${server.url}/hello`, { method: "GET" });

expect(res.status).toBe(405);
});

it("returns 500 when the handler throws", async () => {
await using server = new HttpServer({
"/boom": () => {
throw new Error("oops");
},
});
await server.listen();

const res = await fetch(`${server.url}/boom`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});

expect(res.status).toBe(500);
});

it("forwards a non-200 status from the handler", async () => {
await using server = new HttpServer({
"/fail": async (_req, res) => {
res.writeHead(403);
res.end('{ "error": "nope" }');
},
});
await server.listen();

const res = await fetch(`${server.url}/fail`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});

expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: "nope" });
});
});
156 changes: 156 additions & 0 deletions clients/passkeys-browser/test/helpers/httpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
import * as http from "node:http";
import {ServerResponse} from "node:http";

/*
HttpServer is a server used for testing purposes.
Pass in a map of path to handler and any POST request on that path
will invoke the respective handler.
- listen() sets up the server to listen on a random port
- close() shuts the server down
- async dispose() to support async using similar to auto close in java
- url to return the url the server is listening on
A common usage is:
await using server = new HttpServer({
"/hello": async (req, res) => {
w.writeHead(200, { "content-type"": "text/plain"" });
w.end("howdy");
},
});
await server.listen();
console.log(server.url)
*/
export class HttpServer {
private readonly server: http.Server;
private _url = "";

constructor(private readonly routes: Record<string, HttpHandler>) {
this.server = http.createServer((req, res) => void this.handle(req, res));
}

listen(): Promise<void> {
return new Promise((resolve, reject) => {
this.server.once("error", reject);
this.server.listen(0, "127.0.0.1", () => {
this.server.off("error", reject);
const addr = this.server.address() as { port: number };
this._url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
}

close(): Promise<void> {
return new Promise((resolve, reject) => {
this.server.close((err) => (err ? reject(err) : resolve()));
});
}

[Symbol.asyncDispose](): Promise<void> {
return this.close();
}

[Symbol.dispose](): Promise<void> {
return this.close();
}

get url(): string {
if (!this._url) throw new Error("HttpServer: call listen() before accessing url");
return this._url;
}

private async handle(
req: http.IncomingMessage,
res: http.ServerResponse,
): Promise<void> {
const path = req.url ?? "";
const handler = this.routes[path];

if (!handler) {
res.writeHead(404);
res.end();
return;
}
if (req.method !== "POST") {
res.writeHead(405);
res.end();
return;
}

try {
await handler(req, res);
} catch(err) {
errorHttp(err, res)
}
}
}

// Handler has three pieces: a decoder to decode http requests to a structured request,
// an endpoint to take a structured request and return a structured response,
// an encoder to take a structured response and encode it on the wire as an http response.
export class Handler<Req, Res> {
constructor(
readonly decoder: Decoder<Req>,
readonly endpoint: Endpoint<Req, Res>,
readonly encoder: Encoder<Res>,
) {}
}

// Decoder decodes an http request to a structured type.
export type Decoder<Req> = (msg: http.IncomingMessage) => Promise<Req> | Req;

// Encoder takes a structured type and writes it as an http response with headers and status code.
export type Encoder<Res> = (res: Res, w: http.ServerResponse) => Promise<void> | void;

// Endpoint is the business logic. It takes a structured request and returns a structured response.
// An endpoint is agnostic of the transport; it knows nothing about http requests and responses.
export type Endpoint<Req, Res> = (req: Req) => Promise<Res>;

// HttpHandler takes in an http request; and actions on the http response.
export type HttpHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void;

export class HttpError extends Error {
constructor(readonly status: number, readonly body: unknown) {
super(`HTTP ${status}`);
}
}

export function newHttpHandler<Req, Res>(handler: Handler<Req, Res>): HttpHandler {
return async (req, res) => {
try {
const request = await handler.decoder(req);
const response = await handler.endpoint(request);
await handler.encoder(response, res);
} catch (err) {
errorHttp(err, res)
}
};
}

export async function encodeJson<T>(res: T, w: http.ServerResponse): Promise<void> {
w.writeHead(200, { [contentType]: applicationJson });
w.end(JSON.stringify(res));
}

export async function decodeJson<T>(msg: http.IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of msg) chunks.push(chunk as Buffer);
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;
}

function errorHttp(err: any, res: ServerResponse) {
const headers = { [contentType]: applicationJson }
if (err instanceof HttpError) {
res.writeHead(err.status, headers);
res.end(JSON.stringify(err.body));
} else if (err instanceof Error) {
res.writeHead(500);
res.end(err.message);
} else {
res.writeHead(500);
res.end();
}
}

const contentType = 'content-type';
const applicationJson = 'application/json';
10 changes: 6 additions & 4 deletions clients/passkeys-browser/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"lib": ["ES2022", "DOM", "ESNext.Disposable"],
// tsup's .d.ts pipeline injects a `baseUrl`, which TypeScript 6.0 flags as a
// deprecation error (TS5101). Acknowledge it explicitly; remove once tsup/
// rollup-plugin-dts stops emitting baseUrl (slated to stop working in TS 7.0).
Expand All @@ -21,9 +21,11 @@
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": false,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": ["node_modules", "dist"]
}
Loading