diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index d868cef..f75ee6f 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -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" }, @@ -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) : Promise { + 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; diff --git a/clients/passkeys-browser/test/helpers/httpServer.test.ts b/clients/passkeys-browser/test/helpers/httpServer.test.ts new file mode 100644 index 0000000..0abf821 --- /dev/null +++ b/clients/passkeys-browser/test/helpers/httpServer.test.ts @@ -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" }); + }); +}); diff --git a/clients/passkeys-browser/test/helpers/httpServer.ts b/clients/passkeys-browser/test/helpers/httpServer.ts new file mode 100644 index 0000000..3c9ffea --- /dev/null +++ b/clients/passkeys-browser/test/helpers/httpServer.ts @@ -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) { + this.server = http.createServer((req, res) => void this.handle(req, res)); + } + + listen(): Promise { + 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 { + return new Promise((resolve, reject) => { + this.server.close((err) => (err ? reject(err) : resolve())); + }); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + [Symbol.dispose](): Promise { + 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 { + 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 { + constructor( + readonly decoder: Decoder, + readonly endpoint: Endpoint, + readonly encoder: Encoder, + ) {} +} + +// Decoder decodes an http request to a structured type. +export type Decoder = (msg: http.IncomingMessage) => Promise | Req; + +// Encoder takes a structured type and writes it as an http response with headers and status code. +export type Encoder = (res: Res, w: http.ServerResponse) => Promise | 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: Req) => Promise; + +// HttpHandler takes in an http request; and actions on the http response. +export type HttpHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void; + +export class HttpError extends Error { + constructor(readonly status: number, readonly body: unknown) { + super(`HTTP ${status}`); + } +} + +export function newHttpHandler(handler: Handler): 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(res: T, w: http.ServerResponse): Promise { + w.writeHead(200, { [contentType]: applicationJson }); + w.end(JSON.stringify(res)); +} + +export async function decodeJson(msg: http.IncomingMessage): Promise { + 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'; diff --git a/clients/passkeys-browser/tsconfig.json b/clients/passkeys-browser/tsconfig.json index 85e81bf..29ae3ce 100644 --- a/clients/passkeys-browser/tsconfig.json +++ b/clients/passkeys-browser/tsconfig.json @@ -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). @@ -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"] }