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); + }); +});