From 0c6458538dd1c65b4d431dee9964f432edb13738 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 30 Jul 2026 17:07:19 -0400 Subject: [PATCH] feat(system-config): proxy the public system config from both adapters Both adapters serve routes from an explicit list, so the auth API's new GET /system-config/public is unreachable through them until it is added here. The route returns the configured loginMethods to a signed-out caller. That is what lets the bundled sign-in screens offer the methods an instance actually has enabled rather than a hardcoded guess, and lets them tell whether declining a passkey during registration would leave a user with no way back in. getPublicSystemConfigHandler forwards no identity: no authorization header and no service token, matching how upstream serves the route and how the existing public GET /oauth/providers already behaves. A signed-out browser is the expected caller, so attaching a session would only put a stale cookie in the path of the one call that client has to make. The parity suite covers the new route in both adapters, including an upstream failure passing through, and asserts neither adapter sends an identity even when a valid access cookie is present. Verified with the core, express, and fastify suites: 214, 150, and 43 passing. --- .changeset/public-system-config-route.md | 20 +++++++ packages/core/src/handlers/systemConfig.ts | 31 +++++++++++ packages/express/src/createServer.ts | 6 +++ packages/express/src/handlers/systemConfig.ts | 17 ++++++ packages/fastify/src/routes/authRoutes.ts | 14 +++++ packages/fastify/tests/parity.test.js | 53 +++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 .changeset/public-system-config-route.md diff --git a/.changeset/public-system-config-route.md b/.changeset/public-system-config-route.md new file mode 100644 index 0000000..5fe6e99 --- /dev/null +++ b/.changeset/public-system-config-route.md @@ -0,0 +1,20 @@ +--- +'@seamless-auth/core': minor +'@seamless-auth/express': minor +'@seamless-auth/fastify': minor +--- + +Proxy the public system configuration at `GET /system-config/public`. + +Both adapters serve routes from an explicit list, so a new upstream route is not reachable until it +is added here. This one returns the configured `loginMethods` to a signed-out caller, which is what +lets the bundled sign-in screens offer the methods an instance actually has enabled instead of a +hardcoded guess, and lets them tell whether declining a passkey during registration would leave a +user with no way back in. + +`getPublicSystemConfigHandler` forwards no identity: no authorization header and no service token, +matching how the upstream route is served and how `GET /oauth/providers` already behaves here. A +signed-out browser is the expected caller, so attaching a session would only put a stale cookie in +the path of the one call that client has to make. + +Requires the `GET /system-config/public` route on the auth API. diff --git a/packages/core/src/handlers/systemConfig.ts b/packages/core/src/handlers/systemConfig.ts index ab23bbc..2153d00 100644 --- a/packages/core/src/handlers/systemConfig.ts +++ b/packages/core/src/handlers/systemConfig.ts @@ -14,6 +14,37 @@ export interface SystemConfigResult extends ResultFailure { body?: any; } +/** + * The configuration a signed-out client may read. + * + * Unlike the other handlers here it forwards no identity at all. The sign-in + * screens call this before anyone has a session, so attaching an authorization + * header would make the call fail exactly when it is needed. Upstream serves it + * unauthenticated for the same reason. + */ +export async function getPublicSystemConfigHandler( + opts: Pick, +): Promise { + const up = await authFetch(`${opts.authServerUrl}/system-config/public`, { + method: "GET", + forwardedClientIp: opts.forwardedClientIp, + }); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + ...readUpstreamFailure(data, "failed_to_fetch_public_system_config"), + }; + } + + return { + status: up.status, + body: data, + }; +} + export async function getAvailableRolesHandler( opts: SystemConfigOptions, ): Promise { diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 5228c69..349d1d5 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -51,6 +51,7 @@ import { } from "./internal/buildForwardedClientIp"; import { getAvailableRoles, + getPublicSystemConfig, getSystemConfigAdmin, updateSystemConfig, } from "./handlers/systemConfig"; @@ -472,6 +473,11 @@ export function createSeamlessAuthServer( r.get("/magic-link/check", (req, res) => pollMagicLinkConfirmation(req, res, resolvedOpts), ); + // Public: the sign-in screens read this before there is a session. + r.get("/system-config/public", (req, res) => + getPublicSystemConfig(req, res, resolvedOpts), + ); + r.get("/system-config/roles", (req, res) => getAvailableRoles(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/systemConfig.ts b/packages/express/src/handlers/systemConfig.ts index 697acbb..75bce81 100644 --- a/packages/express/src/handlers/systemConfig.ts +++ b/packages/express/src/handlers/systemConfig.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { getAvailableRolesHandler, + getPublicSystemConfigHandler, getSystemConfigAdminHandler, updateSystemConfigHandler, } from "@seamless-auth/core/handlers/systemConfig"; @@ -13,6 +14,22 @@ import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { respond } from "../internal/respond"; import { SeamlessAuthServerOptions } from "../createServer"; +// No identity is built for this one. It is called by a signed-out browser, so +// there is no session to forward, and buildServiceAuthorization would only add a +// header upstream ignores on a public route. +export async function getPublicSystemConfig( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const result = await getPublicSystemConfigHandler({ + authServerUrl: opts.authServerUrl, + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }); + + respond(res, result, opts); +} + export async function getAvailableRoles( req: Request, res: Response, diff --git a/packages/fastify/src/routes/authRoutes.ts b/packages/fastify/src/routes/authRoutes.ts index a363131..d637fe2 100644 --- a/packages/fastify/src/routes/authRoutes.ts +++ b/packages/fastify/src/routes/authRoutes.ts @@ -4,6 +4,7 @@ import { finishLoginHandler, finishOAuthLoginHandler, finishRegisterHandler, + getPublicSystemConfigHandler, listOAuthProvidersHandler, loginHandler, logoutHandler, @@ -184,6 +185,19 @@ export function registerAuthRoutes( }); } + // Public: the sign-in screens read this before there is a session, so no + // identity is forwarded and none is expected upstream. + fastify.get("/system-config/public", async (req, reply) => { + respond( + reply, + await getPublicSystemConfigHandler({ + authServerUrl: opts.authServerUrl, + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }), + opts, + ); + }); + fastify.get("/oauth/providers", async (_req, reply) => { respond( reply, diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index 8d417f7..0ba58f7 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -242,6 +242,16 @@ describe("fastify and express adapters agree", () => { { method: "get", path: "/oauth/providers" }, upstream(200, { providers: [] }), ], + [ + "public system config with no cookie", + { method: "get", path: "/system-config/public" }, + upstream(200, { loginMethods: ["passkey", "magic_link"] }), + ], + [ + "public system config passes an upstream failure through", + { method: "get", path: "/system-config/public" }, + upstream(503, { error: "upstream_unavailable" }), + ], ])("%s", async (_label, scenario, upstreamResponse) => { const { fastify, express: expressResult } = await bothAdapters( scenario, @@ -253,6 +263,49 @@ describe("fastify and express adapters agree", () => { expect(fastify.cookies).toEqual(expressResult.cookies); }); + // The sign-in screens call this with no session at all. Forwarding an identity + // would be pointless on a route upstream serves publicly, and it would put a + // stale cookie in the path of the one call a signed-out client has to make. + // Asserted with a valid cookie present so a future refactor cannot quietly + // start attaching one. + it("sends no identity upstream for the public system config", async () => { + const scenario = { + method: "get", + path: "/system-config/public", + cookie: accessCookie(), + }; + const upstreamResponse = upstream(200, { loginMethods: ["passkey"] }); + + const headersFor = async (runner) => { + global.fetch = jest.fn(async () => upstreamResponse); + await runner(scenario); + + const [, init] = global.fetch.mock.calls[0]; + + return Object.fromEntries( + Object.entries(init?.headers ?? {}).map(([key, value]) => [ + key.toLowerCase(), + value, + ]), + ); + }; + + const fastifyHeaders = await headersFor(viaFastify); + const expressHeaders = await headersFor(viaExpress); + + for (const headers of [fastifyHeaders, expressHeaders]) { + expect(headers.authorization).toBeUndefined(); + expect(headers["x-seamless-service-token"]).toBeUndefined(); + } + + // Not a full header comparison: the two frameworks report the loopback + // address differently (127.0.0.1 against ::ffff:127.0.0.1), which is the + // test socket rather than anything either adapter decides. + expect(Object.keys(fastifyHeaders).sort()).toEqual( + Object.keys(expressHeaders).sort(), + ); + }); + it.each([ ["default policy", {}], ["insecure dev", { cookieSecure: false }],