From 570adf5c1e61b14143196d01ed35fc00b6817341 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 30 Jul 2026 12:49:40 -0400 Subject: [PATCH] feat(fastify): proxy the admin console from the Fastify adapter The Express adapter ships createSeamlessConsoleProxy, which reverse-proxies the admin dashboard so an adopter's API can serve it from its own origin. The Fastify adapter had no equivalent, and its README said so. seamlessConsoleProxy is a plugin registered under a prefix, the same shape as seamlessAuth, rather than a factory named after the Express function. Fastify already owns the mount path, so taking it from the options too would give two sources for one value. basePath keeps its meaning from the Express adapter: it says what to request upstream, not where the proxy is mounted. Hand-rolled on fetch rather than @fastify/http-proxy or @fastify/reply-from. The adapter already proxies to the auth server with fetch through core's proxyRequest, adding no dependency for it, and the console needs the path allowlist and the stripping of everything but the method and the path. A general proxy would have to be configured back down to that. The upstream URL is resolved from the raw request.url rather than the wildcard param. Fastify percent-decodes params, which turns %2e%2e into .. and %2f into / before any check could see the difference, and the traversal check has to look at what the client actually sent. Express reaches the same place from the other direction: it normalizes dot-segments before routing, so its version reads the already-normalized req.path. The parity suite now runs the console proxy through both adapters and asserts the status, body, caching headers and upstream URL match. The two frameworks normalize dot-segments at different points, so a traversal attempt ends in a 400 on one and a 404 on the other; that case asserts the invariant instead, that neither ever requests anything outside the console subtree. pnpm build and pnpm test pass: 214 core, 150 express, 40 fastify. --- .changeset/fastify-console-proxy.md | 15 ++ packages/fastify/README.md | 45 ++++- packages/fastify/src/consoleProxy.ts | 198 ++++++++++++++++++++ packages/fastify/src/index.ts | 2 + packages/fastify/tests/consoleProxy.test.js | 168 +++++++++++++++++ packages/fastify/tests/parity.test.js | 189 ++++++++++++++++++- 6 files changed, 608 insertions(+), 9 deletions(-) create mode 100644 .changeset/fastify-console-proxy.md create mode 100644 packages/fastify/src/consoleProxy.ts create mode 100644 packages/fastify/tests/consoleProxy.test.js diff --git a/.changeset/fastify-console-proxy.md b/.changeset/fastify-console-proxy.md new file mode 100644 index 0000000..6f8c21e --- /dev/null +++ b/.changeset/fastify-console-proxy.md @@ -0,0 +1,15 @@ +--- +"@seamless-auth/fastify": minor +--- + +Serve the Seamless admin console from a Fastify API, matching the Express adapter's `createSeamlessConsoleProxy`. + +`seamlessConsoleProxy` is a plugin you register under a prefix, the same shape as `seamlessAuth`, so the mount path comes from Fastify rather than from the options. The Express adapter mounts a router and takes its upstream subtree from `basePath`; here `basePath` only says what to request upstream, and it still defaults to `/console`. + +It proxies `GET` and `HEAD` with `fetch`, forwards nothing from the incoming request but the method and the path, and copies back `content-type`, `cache-control`, `etag`, and `last-modified`. Unknown paths under the prefix go upstream too, which is how deep links into the dashboard get the SPA shell. A path that resolves outside the console subtree, or that carries an encoded path separator, is refused with a 400 and never reaches the upstream. + +The two adapters resolve the upstream URL from the raw request path for the same reason but by different routes: Express normalizes dot-segments before routing, and Fastify percent-decodes wildcard params, so this reads `request.url` instead of `request.params` to keep the traversal check looking at what the client actually sent. + +The parity suite now runs the console proxy through both adapters and asserts the status, body, caching headers, and upstream URL match. + +New exports: `seamlessConsoleProxy`, `SeamlessConsoleProxyOptions`. diff --git a/packages/fastify/README.md b/packages/fastify/README.md index 7461bf9..0dc5d77 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -90,6 +90,41 @@ await app.register(seamlessAuth, { Delivery payloads carry one-time codes and links. They are stripped from the response before it reaches the browser. +## Serving the admin console + +`seamlessConsoleProxy` reverse-proxies the Seamless admin dashboard, so the +console loads from your own origin instead of a second one: + +```ts +import seamlessAuth, { seamlessConsoleProxy } from "@seamless-auth/fastify"; + +await app.register(seamlessAuth, { prefix: "/auth", ...options }); + +await app.register(seamlessConsoleProxy, { + prefix: "/console", + authServerUrl: process.env.AUTH_SERVER_URL!, +}); +``` + +Register it at the top-level `/console` prefix the dashboard is built against, +as a sibling of the auth prefix. The console then talks to the cookie-based +`/auth/*` endpoints on the same origin, with no cross-site request in the way. + +Only `GET` and `HEAD` are proxied, and nothing from the incoming request is +forwarded but the method and the path: the console is public static hosting, and +the browser's session cookies have no business at the auth API. Requests that +resolve outside the console subtree are refused with a 400 and never reach the +upstream. `content-type`, `cache-control`, `etag`, and `last-modified` come back +from the upstream unchanged, so the dashboard's own caching still applies. + +| Option | Default | Purpose | +| --- | --- | --- | +| `authServerUrl` | required | Base URL of your Seamless Auth instance | +| `basePath` | `/console` | Subtree requested upstream | + +Unknown paths under the prefix are forwarded too, which is what makes deep links +into the dashboard work: the upstream answers them with the SPA shell. + ## Options | Option | Default | Purpose | @@ -124,12 +159,12 @@ caller chose. Set `trustProxy` to an explicit hop count or subnet, or pass Both adapters serve the same routes and issue the same cookies. A parity suite runs the same requests through both against the same mocked auth API and asserts the status, body, and every `Set-Cookie` header match, so the two cannot drift. +The console proxy is covered by the same suite. -## Not included - -`createSeamlessConsoleProxy` from the Express adapter, which proxies the admin -console's static assets, has no Fastify equivalent yet. It is a separate concern -from the auth routes. +The Express adapter exposes the console proxy as +`createSeamlessConsoleProxy(options)`, a router you mount. Here it is a plugin +you register under a prefix, so the mount path comes from Fastify rather than +from the options. ## License diff --git a/packages/fastify/src/consoleProxy.ts b/packages/fastify/src/consoleProxy.ts new file mode 100644 index 0000000..bc910d2 --- /dev/null +++ b/packages/fastify/src/consoleProxy.ts @@ -0,0 +1,198 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; + +export type SeamlessConsoleProxyOptions = { + authServerUrl: string; + basePath?: string; +}; + +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "cache-control", + "etag", + "last-modified", +]; + +// The console is read-only static hosting, but the route has to claim the other +// methods too so they answer 405 rather than falling through to a 404 that says +// nothing about why. +const PROXIED_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] as const; + +const UPSTREAM_TIMEOUT_MS = 10000; + +function normalizeBasePath(basePath: string): string { + const withLeadingSlash = basePath.startsWith("/") ? basePath : `/${basePath}`; + return withLeadingSlash.replace(/\/+$/, "") || "/"; +} + +// Fastify percent-decodes wildcard params, which turns `%2e%2e` into `..` and +// `%2f` into `/` before any check here could see the difference. Everything +// below works off the raw `request.url` instead, so the decision is made on what +// the client actually sent. +function splitUrl(rawUrl: string): { path: string; search: string } { + const queryIndex = rawUrl.indexOf("?"); + + return queryIndex === -1 + ? { path: rawUrl, search: "" } + : { path: rawUrl.slice(0, queryIndex), search: rawUrl.slice(queryIndex) }; +} + +function stripMountPath(rawPath: string, mountPath: string): string | null { + if (mountPath === "/") { + return rawPath; + } + + // Routing matched this path under the mount, but router options that sanitize + // the path before matching (`ignoreDuplicateSlashes`) leave `request.url` + // looking different from what matched. Refuse rather than guess at the subpath. + if (!rawPath.startsWith(mountPath)) { + return null; + } + + return rawPath.slice(mountPath.length) || "/"; +} + +// Resolve the upstream URL and refuse anything that escapes the console subtree. +// `new URL` collapses literal `..` and `%2e%2e` dot-segments, so those land +// outside the prefix and are rejected below. It does NOT decode `%2f`/`%5c`, so +// `..%2fadmin` stays a single opaque segment that passes the prefix check yet +// decodes to a traversal at an upstream that does decode it. Reject encoded path +// separators outright: legitimate console asset paths and SPA client routes never +// contain one, so this has no false positives and does not depend on how the +// upstream decodes. +function resolveUpstreamUrl( + authServerUrl: string, + basePath: string, + subpath: string, + search: string, +): URL | null { + if (/%2f|%5c/i.test(subpath)) { + return null; + } + + let baseUrl: URL; + try { + baseUrl = new URL(authServerUrl); + } catch { + return null; + } + + const prefix = `${baseUrl.pathname.replace(/\/+$/, "")}${basePath}`; + const suffix = subpath === "/" ? "" : subpath; + + let resolved: URL; + try { + resolved = new URL(`${prefix}${suffix}${search}`, baseUrl.origin); + } catch { + return null; + } + + if ( + resolved.pathname !== prefix && + !resolved.pathname.startsWith(`${prefix}/`) + ) { + return null; + } + + return resolved; +} + +/** + * Fastify plugin that reverse-proxies the Seamless admin dashboard SPA. + * + * Register it under the same top-level `/console` prefix the dashboard is built + * against, as a sibling of the auth plugin's prefix, so the dashboard loads from + * the same origin that exposes the cookie-based `/auth/*` endpoints. + * + * Nothing from the incoming request is forwarded but the method and the path: + * the console is public static hosting, and the browser's session cookies have + * no business at the upstream. + * + * ### Example + * ```ts + * await app.register(seamlessAuth, { prefix: "/auth", ...opts }); + * await app.register(seamlessConsoleProxy, { + * prefix: "/console", + * authServerUrl: opts.authServerUrl, + * }); + * ``` + * + * @param options - Configuration for the console proxy: + * - `authServerUrl` - Base URL of the Seamless Auth API serving `/console` (required) + * - `basePath` - Subtree requested upstream (defaults to `/console`) + */ +export const seamlessConsoleProxy: FastifyPluginAsync< + SeamlessConsoleProxyOptions +> = async (fastify, opts) => { + const basePath = normalizeBasePath(opts.basePath ?? "/console"); + const mountPath = normalizeBasePath(fastify.prefix); + + const handler = async ( + req: FastifyRequest, + reply: FastifyReply, + ): Promise => { + if (req.method !== "GET" && req.method !== "HEAD") { + reply.status(405).send({ error: "Method not allowed" }); + return; + } + + const { path, search } = splitUrl(req.url); + const subpath = stripMountPath(path, mountPath); + const upstream = + subpath === null + ? null + : resolveUpstreamUrl(opts.authServerUrl, basePath, subpath, search); + + if (!upstream) { + reply.status(400).send({ error: "Invalid console path" }); + return; + } + + let response: globalThis.Response; + try { + response = await fetch(upstream, { + method: req.method, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } catch { + reply.status(502).send({ error: "Console upstream unreachable" }); + return; + } + + for (const header of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers.get(header); + if (value !== null) { + reply.header(header, value); + } + } + + reply.status(response.status); + + if (req.method === "HEAD" || !response.body) { + reply.send(); + return; + } + + reply.send(Buffer.from(await response.arrayBuffer())); + }; + + for (const url of ["/", "/*"]) { + fastify.route({ + method: [...PROXIED_METHODS], + url, + // HEAD is declared above; without this Fastify adds its own sibling HEAD + // route for the GET and the two collide. + exposeHeadRoute: false, + handler, + }); + } +}; + +export default seamlessConsoleProxy; diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index 9465f15..a6f15c9 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -1,6 +1,8 @@ import { seamlessAuth } from "./plugin"; export { seamlessAuth }; +export { seamlessConsoleProxy } from "./consoleProxy"; +export type { SeamlessConsoleProxyOptions } from "./consoleProxy"; export { requireAuth, requireRole } from "./guards"; export type { RequireAuthOptions } from "./guards"; export { getSeamlessUser } from "./getSeamlessUser"; diff --git a/packages/fastify/tests/consoleProxy.test.js b/packages/fastify/tests/consoleProxy.test.js new file mode 100644 index 0000000..49f5508 --- /dev/null +++ b/packages/fastify/tests/consoleProxy.test.js @@ -0,0 +1,168 @@ +// Framework-specific behaviour of the console proxy. The parity suite covers +// what it has to answer identically to the Express adapter. +import { jest } from "@jest/globals"; +import Fastify from "fastify"; + +const { seamlessConsoleProxy } = await import("../dist/index.js"); + +const AUTH_SERVER_URL = "https://auth.example.com"; + +function createUpstreamResponse(status, body, headers = {}) { + const encoded = typeof body === "string" ? Buffer.from(body) : body; + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + body: encoded ? {} : null, + arrayBuffer: async () => + encoded.buffer.slice( + encoded.byteOffset, + encoded.byteOffset + encoded.byteLength, + ), + }; +} + +function asset(body = "console.js()") { + return createUpstreamResponse(200, body, { + "content-type": "application/javascript", + }); +} + +async function createApp(options = {}) { + const app = Fastify(); + await app.register(seamlessConsoleProxy, { + prefix: "/console", + authServerUrl: AUTH_SERVER_URL, + ...options, + }); + await app.ready(); + return app; +} + +async function inject(app, request) { + try { + return await app.inject(request); + } finally { + await app.close(); + } +} + +function fetchUrls() { + return global.fetch.mock.calls.map(([url]) => url.toString()); +} + +describe("console proxy", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("serves the SPA shell at the prefix root, with or without a trailing slash", async () => { + global.fetch.mockResolvedValue( + createUpstreamResponse(200, "
", { + "content-type": "text/html; charset=utf-8", + }), + ); + + const bare = await inject(await createApp(), { + method: "GET", + url: "/console", + }); + const slashed = await inject(await createApp(), { + method: "GET", + url: "/console/", + }); + + expect(bare.statusCode).toBe(200); + expect(slashed.statusCode).toBe(200); + expect(bare.body).toContain("
"); + expect(fetchUrls()).toEqual([ + "https://auth.example.com/console", + "https://auth.example.com/console", + ]); + }); + + it("proxies from a custom prefix to the upstream console subtree", async () => { + global.fetch.mockResolvedValue(asset()); + + const app = await createApp({ prefix: "/admin-ui" }); + const res = await inject(app, { + method: "GET", + url: "/admin-ui/assets/x.js", + }); + + expect(res.statusCode).toBe(200); + expect(fetchUrls()).toEqual([ + "https://auth.example.com/console/assets/x.js", + ]); + }); + + it("requests a custom basePath upstream", async () => { + global.fetch.mockResolvedValue(asset()); + + const app = await createApp({ basePath: "dashboard/" }); + const res = await inject(app, { + method: "GET", + url: "/console/assets/x.js", + }); + + expect(res.statusCode).toBe(200); + expect(fetchUrls()).toEqual([ + "https://auth.example.com/dashboard/assets/x.js", + ]); + }); + + it("answers HEAD with the upstream headers and no body", async () => { + global.fetch.mockResolvedValue(asset()); + + const res = await inject(await createApp(), { + method: "HEAD", + url: "/console/assets/x.js", + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/javascript"); + expect(res.body).toBe(""); + }); + + it("does not forward Cookie or Authorization headers upstream", async () => { + global.fetch.mockResolvedValue(asset()); + + await inject(await createApp(), { + method: "GET", + url: "/console/assets/x.js", + headers: { + cookie: "seamless-access=secret", + authorization: "Bearer secret", + }, + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers).toBeUndefined(); + }); + + // With ignoreDuplicateSlashes the router matches a path the raw url does not + // start with, so there is no subpath to trust. + it("rejects a request whose url no longer starts with the mount path", async () => { + const app = Fastify({ routerOptions: { ignoreDuplicateSlashes: true } }); + await app.register(seamlessConsoleProxy, { + prefix: "/console", + authServerUrl: AUTH_SERVER_URL, + }); + await app.ready(); + + const res = await inject(app, { + method: "GET", + url: "//console/assets/x.js", + }); + + expect(res.statusCode).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index 5076ab9..8d417f7 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -7,10 +7,10 @@ import Fastify from "fastify"; import jwt from "jsonwebtoken"; import request from "supertest"; -const { default: seamlessAuth } = await import("../dist/index.js"); -const { default: createSeamlessAuthServer } = await import( - "../../express/dist/index.js" -); +const { default: seamlessAuth, seamlessConsoleProxy } = + await import("../dist/index.js"); +const { default: createSeamlessAuthServer, createSeamlessConsoleProxy } = + await import("../../express/dist/index.js"); const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; @@ -347,3 +347,184 @@ describe("fastify and express adapters agree", () => { } }); }); + +function consoleUpstream(status, body, headers = {}) { + const encoded = Buffer.from(body); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + body: {}, + arrayBuffer: async () => + encoded.buffer.slice( + encoded.byteOffset, + encoded.byteOffset + encoded.byteLength, + ), + }; +} + +function fetchedUrls() { + return global.fetch.mock.calls.map(([url]) => url.toString()); +} + +async function viaFastifyConsole({ method, path, headers }) { + const app = Fastify(); + await app.register(seamlessConsoleProxy, { + prefix: "/console", + authServerUrl: OPTIONS.authServerUrl, + }); + await app.ready(); + + try { + const res = await app.inject({ + method: method.toUpperCase(), + url: path, + headers: headers ?? {}, + }); + + return { + status: res.statusCode, + body: parseBody(res.body), + contentType: res.headers["content-type"], + cacheControl: res.headers["cache-control"], + }; + } finally { + await app.close(); + } +} + +async function viaExpressConsole({ method, path, headers }) { + const app = express(); + app.use( + "/console", + createSeamlessConsoleProxy({ authServerUrl: OPTIONS.authServerUrl }), + ); + + let req = request(app)[method](path); + for (const [name, value] of Object.entries(headers ?? {})) { + req = req.set(name, value); + } + + const res = await req; + + return { + status: res.status, + body: parseBody(res.text), + contentType: res.headers["content-type"], + cacheControl: res.headers["cache-control"], + }; +} + +async function bothConsoleAdapters(scenario, respondUpstream) { + global.fetch = jest.fn(respondUpstream); + const fastify = { + ...(await viaFastifyConsole(scenario)), + urls: fetchedUrls(), + }; + + global.fetch = jest.fn(respondUpstream); + const expressResult = { + ...(await viaExpressConsole(scenario)), + urls: fetchedUrls(), + }; + + return { fastify, express: expressResult }; +} + +describe("fastify and express console proxies agree", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + const ASSET = () => + consoleUpstream(200, "console.js()", { + "content-type": "application/javascript", + "cache-control": "public, max-age=31536000, immutable", + }); + const SHELL = () => + consoleUpstream(200, "
", { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + const NOT_FOUND = () => + consoleUpstream(404, "Not found", { "content-type": "text/plain" }); + const UNREACHABLE = () => { + throw new Error("network down"); + }; + + it.each([ + [ + "asset request forwards the body and the caching headers", + { method: "get", path: "/console/assets/x.js" }, + ASSET, + ], + [ + "deep client route gets the SPA shell", + { method: "get", path: "/console/settings" }, + SHELL, + ], + [ + "query string is forwarded upstream", + { method: "get", path: "/console/settings?tab=keys&tab=orgs" }, + SHELL, + ], + [ + "upstream 404 stays a 404", + { method: "get", path: "/console/missing.js" }, + NOT_FOUND, + ], + [ + "a write to the console is refused", + { method: "post", path: "/console/assets/x.js" }, + ASSET, + ], + [ + "encoded-slash traversal is refused", + { method: "get", path: "/console/..%2fadmin/users" }, + ASSET, + ], + [ + "encoded-backslash traversal is refused", + { method: "get", path: "/console/..%5cadmin" }, + ASSET, + ], + [ + "an unreachable upstream is a 502", + { method: "get", path: "/console/assets/x.js" }, + UNREACHABLE, + ], + ])("%s", async (_label, scenario, respondUpstream) => { + const { fastify, express: expressResult } = await bothConsoleAdapters( + scenario, + async () => respondUpstream(), + ); + + expect(fastify.status).toBe(expressResult.status); + expect(fastify.body).toEqual(expressResult.body); + expect(fastify.contentType).toBe(expressResult.contentType); + expect(fastify.cacheControl).toBe(expressResult.cacheControl); + expect(fastify.urls).toEqual(expressResult.urls); + }); + + // The two frameworks normalize dot-segments at different points, so the status + // they answer with differs. What has to hold on both is that nothing outside + // the console subtree is ever requested upstream. + it.each([ + ["/console/../auth/admin/users"], + ["/console/%2e%2e/auth/admin/users"], + ["/console/assets/../../auth/admin/users"], + ])("never proxies outside the console subtree (%s)", async (path) => { + const { fastify, express: expressResult } = await bothConsoleAdapters( + { method: "get", path }, + async () => NOT_FOUND(), + ); + + for (const url of [...fastify.urls, ...expressResult.urls]) { + expect(url.startsWith("https://auth.example.com/console")).toBe(true); + } + + expect(fastify.status).toBeGreaterThanOrEqual(400); + expect(expressResult.status).toBeGreaterThanOrEqual(400); + }); +});