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 }],