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
20 changes: 20 additions & 0 deletions .changeset/public-system-config-route.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions packages/core/src/handlers/systemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SystemConfigOptions, "authServerUrl" | "forwardedClientIp">,
): Promise<SystemConfigResult> {
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<SystemConfigResult> {
Expand Down
6 changes: 6 additions & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from "./internal/buildForwardedClientIp";
import {
getAvailableRoles,
getPublicSystemConfig,
getSystemConfigAdmin,
updateSystemConfig,
} from "./handlers/systemConfig";
Expand Down Expand Up @@ -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),
);
Expand Down
17 changes: 17 additions & 0 deletions packages/express/src/handlers/systemConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from "express";
import {
getAvailableRolesHandler,
getPublicSystemConfigHandler,
getSystemConfigAdminHandler,
updateSystemConfigHandler,
} from "@seamless-auth/core/handlers/systemConfig";
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/fastify/src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
finishLoginHandler,
finishOAuthLoginHandler,
finishRegisterHandler,
getPublicSystemConfigHandler,
listOAuthProvidersHandler,
loginHandler,
logoutHandler,
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions packages/fastify/tests/parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }],
Expand Down
Loading