diff --git a/README.md b/README.md index 96d58bc..d163d03 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ It is also the natural initializer boundary for adopter-supplied auth messaging - custom auth-message handlers - optional auth template overrides +For WebAuthn PRF flows, the adapter proxies PRF registration query flags and assertion request bodies to the Seamless Auth API. PRF outputs remain browser-only and are never handled by the server adapter. + Location: ``` diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index d4c9a9b..597458c 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -76,6 +76,28 @@ export interface SeamlessAuthUser { iat?: number; exp?: number; } + +function buildProxyQueryString(queryInput: Request["query"]): string { + const query = new URLSearchParams(); + + for (const [key, value] of Object.entries(queryInput)) { + if (typeof value === "string") { + query.append(key, value); + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === "string") { + query.append(key, item); + } + } + } + } + + return query.toString(); +} + /** * Creates an Express Router that proxies all authentication traffic to a Seamless Auth server. * @@ -197,8 +219,9 @@ export function createSeamlessAuthServer( ? { method, authorization, forwardedClientIp } : { method, authorization, forwardedClientIp, body: req.body }; + const queryString = buildProxyQueryString(req.query); const upstream = await authFetch( - `${resolvedOpts.authServerUrl}/${path}`, + `${resolvedOpts.authServerUrl}/${path}${queryString ? `?${queryString}` : ""}`, options as any, ); diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js index 669ebf6..c2e05e7 100644 --- a/packages/express/tests/stepUpProxy.test.js +++ b/packages/express/tests/stepUpProxy.test.js @@ -22,6 +22,15 @@ function createAccessCookie(subject = "user-123") { return `seamless-access=${token}`; } +function createRegistrationCookie(subject = "user-123") { + const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { + algorithm: "HS256", + expiresIn: "300s", + }); + + return `seamless-ephemeral=${token}`; +} + function createApp() { const app = express(); @@ -128,4 +137,59 @@ describe("step-up proxy routes", () => { }), ); }); + + it("proxies step-up start with PRF request body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + challenge: "challenge", + extensions: { + prf: { + eval: { + first: "salt", + }, + }, + }, + }), + ); + + const body = { prf: { salt: "salt" }, credentialId: "credential-id" }; + + const res = await request(createApp()) + .post("/auth/step-up/webauthn/start") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/step-up/webauthn/start", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); + + it("proxies passkey registration start with PRF query options", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + challenge: "challenge", + extensions: { + prf: {}, + }, + }), + ); + + const res = await request(createApp()) + .get("/auth/webAuthn/register/start") + .query({ requirePrf: "true" }) + .set("Cookie", createRegistrationCookie()); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/webAuthn/register/start?requirePrf=true", + expect.objectContaining({ + method: "GET", + }), + ); + }); });