diff --git a/.changeset/public-system-config-endpoint.md b/.changeset/public-system-config-endpoint.md new file mode 100644 index 0000000..8c1e398 --- /dev/null +++ b/.changeset/public-system-config-endpoint.md @@ -0,0 +1,19 @@ +--- +'seamless-auth-api': minor +--- + +Serve the publicly visible system configuration from an unauthenticated `GET /system-config/public`. + +It returns the configured `loginMethods` and nothing else. The bundled sign-in screens in the SDKs +render before anyone has a session, so they cannot read the configuration and today fall back to a +hardcoded list of methods. That list can advertise a method an instance has turned off. This lets a +client ask instead. + +It also unblocks offering a skip on passkey registration, which is only safe when another login +method is enabled. A client that cannot see the method list cannot make that call safely. + +The handler reads through `getLoginPolicy`, so a tainted or partially written config answers with +the defaults rather than failing. A signed-out client with no methods has nothing to render, and a +500 here would take the sign-in screen down with it. + +Every other key in the system configuration stays behind the admin routes. diff --git a/openapi.json b/openapi.json index 501c63b..8461913 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,6 @@ { "openapi": "3.0.3", - "info": { "title": "Seamless Auth API", "version": "0.5.0" }, + "info": { "title": "Seamless Auth API", "version": "0.6.0" }, "components": { "schemas": {}, "parameters": {}, @@ -6125,6 +6125,36 @@ } } }, + "/system-config/public": { + "get": { + "summary": "Read the publicly visible system configuration", + "tags": ["SystemConfig"], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "loginMethods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + } + }, + "required": ["loginMethods"] + }, + "example": { "loginMethods": [null] } + } + } + } + } + } + }, "/system-config/roles": { "get": { "summary": "Get available roles", diff --git a/package-lock.json b/package-lock.json index 118652f..241cfd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "seamless-auth-api", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-auth-api", - "version": "0.5.0", + "version": "0.6.0", "license": "AGPL-3.0-only", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.4.0", + "@seamless-auth/types": "^0.5.0", "@simplewebauthn/server": "^13.1.1", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", @@ -3393,9 +3393,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.4.0.tgz", - "integrity": "sha512-1WhLjgyCN9UdX6TEzKbbASROeLMxZmo2LiAYOlYE2sGIPojnbAE/yFbp5Ydt13GibiZ6MwYlHMLcsBVQMeelWA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.5.0.tgz", + "integrity": "sha512-T566YQu8FqUmqgte0A0iJpjTDXUKq/d/7uvLlXGXKtdl52Edo0r3iafL3kx4OM0cRJM63tBGe9s4MTjRUth6yA==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 71ace4c..55970c4 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@seamless-auth/messaging": "^0.1.0", "@seamless-auth/messaging-aws": "^0.1.0", "@seamless-auth/messaging-twilio": "^0.1.0", - "@seamless-auth/types": "^0.4.0", + "@seamless-auth/types": "^0.5.0", "@simplewebauthn/server": "^13.1.1", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 81d479a..fc052ce 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -4,7 +4,7 @@ * See LICENSE file in the project root for full license information */ -import { Response } from 'express'; +import { Request, Response } from 'express'; import { getSystemConfig, invalidateSystemConfigCache } from '../config/getSystemConfig.js'; import { resolveSystemConfigUpdatedBy } from '../lib/systemConfigActor.js'; @@ -13,6 +13,7 @@ import { User } from '../models/users.js'; import { createPatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; import { AuthEventService } from '../services/authEventService.js'; +import { getLoginPolicy } from '../services/loginPolicyService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -141,3 +142,20 @@ export const getAvailableRoles = async (_req: Request, res: Response) => { roles: config.available_roles ?? [], }); }; + +/** + * The configuration a signed-out client is allowed to read. + * + * Served through getLoginPolicy rather than off the raw config so a tainted or + * partially written config still answers with the defaults. The sign-in screens + * call this before anyone has a session, so failing here would leave a client + * with no methods to offer at all. + * + * Only add a key to this response when a signed-out client genuinely cannot work + * without it. Everything else belongs on the admin routes. + */ +export const getPublicSystemConfig = async (_req: Request, res: Response) => { + const { loginMethods } = await getLoginPolicy(); + + return res.json({ loginMethods }); +}; diff --git a/src/generated/api.ts b/src/generated/api.ts index 460f0e5..6b027d1 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -6628,6 +6628,51 @@ export interface paths { patch?: never; trace?: never; }; + '/system-config/public': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read the publicly visible system configuration */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "loginMethods": [ + * null + * ] + * } + */ + 'application/json': { + loginMethods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/system-config/roles': { parameters: { query?: never; diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 2e46a96..1db3dfa 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -12,6 +12,7 @@ import { } from '../controllers/oauthProviders.js'; import { getAvailableRoles, + getPublicSystemConfig, getSystemConfigHandler, updateSystemConfig, } from '../controllers/systemConfig.js'; @@ -33,11 +34,31 @@ import { AvailableRolesResponseSchema, GetSystemConfigResponseSchema, InvalidPayloadSchema, + PublicSystemConfigResponseSchema, UpdateSystemConfigResponseSchema, } from '../schemas/systemConfig.responses.js'; const systemConfigRouter = createRouter('/system-config'); +// Unauthenticated on purpose. The bundled sign-in screens render before anyone +// has a session, and this is what lets them offer the methods an instance +// actually has enabled instead of a hardcoded guess. It exposes only the login +// method list, which the sign-in screens already reveal by their behaviour. +systemConfigRouter.get( + '/public', + { + summary: 'Read the publicly visible system configuration', + tags: ['SystemConfig'], + + schemas: { + response: { + 200: PublicSystemConfigResponseSchema, + }, + }, + }, + getPublicSystemConfig, +); + systemConfigRouter.get( '/roles', { diff --git a/src/schemas/systemConfig.responses.ts b/src/schemas/systemConfig.responses.ts index 3489068..2109655 100644 --- a/src/schemas/systemConfig.responses.ts +++ b/src/schemas/systemConfig.responses.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; export { AvailableRolesResponseSchema, GetSystemConfigResponseSchema, + PublicSystemConfigResponseSchema, UpdateSystemConfigResponseSchema, } from '@seamless-auth/types'; diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index 613db6f..9087038 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -174,3 +174,35 @@ describe('GET /system-config/roles (additional branches)', () => { expect(res.body.roles).toEqual([]); }); }); + +describe('GET /system-config/public', () => { + it('returns the configured login methods', async () => { + (getSystemConfig as any).mockResolvedValue({ + login_methods: ['passkey', 'phone_otp'], + passkey_login_fallback_enabled: true, + }); + + const res = await request(app).get('/system-config/public'); + + expect(res.status).toBe(200); + expect(res.body.loginMethods).toEqual(['passkey', 'phone_otp']); + }); + + // A client with no methods has nothing to render, so a config that never had + // login_methods written answers with the defaults rather than an empty list. + it('falls back to the defaults when the stored config has no login methods', async () => { + const res = await request(app).get('/system-config/public'); + + expect(res.status).toBe(200); + expect(res.body.loginMethods).toEqual(['passkey', 'magic_link']); + }); + + it('exposes nothing but the login methods', async () => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); + + const res = await request(app).get('/system-config/public'); + + expect(res.status).toBe(200); + expect(Object.keys(res.body)).toEqual(['loginMethods']); + }); +});