Skip to content

feat(fastify): proxy the admin console from the Fastify adapter - #141

Merged
Bccorb merged 1 commit into
mainfrom
feat/fastify-console-proxy
Jul 30, 2026
Merged

feat(fastify): proxy the admin console from the Fastify adapter#141
Bccorb merged 1 commit into
mainfrom
feat/fastify-console-proxy

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

@seamless-auth/fastify gains seamlessConsoleProxy, the Fastify equivalent of the Express adapter's createSeamlessConsoleProxy. An adopter's Fastify API can now serve the Seamless admin dashboard from its own origin at /console, alongside the auth routes it already serves.

The README's "Not included" section, which said this had no Fastify equivalent, is gone. The section that replaces it documents the export and names the one shape difference from Express.

Why a plugin rather than a factory

seamlessConsoleProxy is a FastifyPluginAsync you register under a prefix, matching how seamlessAuth is registered, rather than a factory named after the Express function:

await app.register(seamlessAuth, { prefix: "/auth", ...options });

await app.register(seamlessConsoleProxy, {
  prefix: "/console",
  authServerUrl: process.env.AUTH_SERVER_URL!,
});

Fastify already owns the mount path through prefix. A factory would have to take the mount path in its options as well, giving one value two sources that can disagree. basePath keeps the meaning it has in the Express adapter, which is what to request upstream rather than where the proxy is mounted, and still defaults to /console.

Why hand-rolled on fetch

Not @fastify/http-proxy or @fastify/reply-from. The adapter already proxies to the auth server with fetch through core's proxyRequest and takes no dependency for it. The console proxy is not a general reverse proxy either: it allows exactly one subtree, permits only GET and HEAD, forwards nothing from the incoming request but the method and the path, and copies back four response headers. A general proxy would be configured back down to that, and the security-relevant part would still be hand-written.

Path handling

The upstream URL is resolved from the raw request.url, not from 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 arrives at the same place from the other direction: it normalizes dot-segments before routing, so its version reads the already-normalized req.path.

Encoded path separators (%2f, %5c) are rejected outright. They survive new URL undecoded, so ..%2fadmin would pass a prefix check here and decode to a traversal at an upstream that does decode it. Legitimate console asset paths and SPA client routes never contain one.

A subpath is only trusted when request.url starts with the mount path. With ignoreDuplicateSlashes the router matches a path the raw url does not start with, and there is nothing to strip; that request is refused rather than guessed at.

Tests

The parity suite now runs the console proxy through both adapters against the same mocked upstream and asserts the status, body, content-type, cache-control, and the upstream URL match: asset request, deep client route, query string, upstream 404, a write refused with 405, both encoded-separator traversals, and an unreachable upstream.

The two frameworks normalize dot-segments at different points, so a literal .. traversal ends in a 400 on one adapter and a 404 on the other. That case asserts the invariant both have to hold instead: neither ever requests anything outside the console subtree.

tests/consoleProxy.test.js covers what is Fastify's alone: the prefix root with and without a trailing slash, a custom prefix, a custom basePath, HEAD, that no Cookie or Authorization header reaches the upstream, and the mount-path guard.

Checks

pnpm build and pnpm test at the workspace root, on Node 24:

packages/core    Test Suites: 25 passed, 25 total   Tests: 214 passed, 214 total
packages/express Test Suites: 27 passed, 27 total   Tests: 150 passed, 150 total
packages/fastify Test Suites:  2 passed,  2 total   Tests:  40 passed,  40 total

Follow-up

Not in this PR. Once this ships, seamless-templates can add templates/api/fastify with SERVE_ADMIN_CONSOLE support and the CORS handling that mirrors templates/api/express/src/index.ts, so the CLI's "Served by your API at /console (recommended)" default works for a Fastify project too. The changeset here is a minor on a 0.x package, so that template should pin @seamless-auth/fastify at ^0.2.0, the first release that has this export.

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.
@Bccorb
Bccorb merged commit 39d71a2 into main Jul 30, 2026
2 checks passed
@Bccorb
Bccorb deleted the feat/fastify-console-proxy branch July 30, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant