Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/fastify-console-proxy.md
Original file line number Diff line number Diff line change
@@ -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`.
45 changes: 40 additions & 5 deletions packages/fastify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
198 changes: 198 additions & 0 deletions packages/fastify/src/consoleProxy.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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;
2 changes: 2 additions & 0 deletions packages/fastify/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading