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
19 changes: 19 additions & 0 deletions .changeset/public-system-config-endpoint.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 31 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
@@ -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": {},
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 19 additions & 1 deletion src/controllers/systemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 });
};
45 changes: 45 additions & 0 deletions src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/routes/systemConfig.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../controllers/oauthProviders.js';
import {
getAvailableRoles,
getPublicSystemConfig,
getSystemConfigHandler,
updateSystemConfig,
} from '../controllers/systemConfig.js';
Expand All @@ -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',
{
Expand Down
1 change: 1 addition & 0 deletions src/schemas/systemConfig.responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from 'zod';
export {
AvailableRolesResponseSchema,
GetSystemConfigResponseSchema,
PublicSystemConfigResponseSchema,
UpdateSystemConfigResponseSchema,
} from '@seamless-auth/types';

Expand Down
32 changes: 32 additions & 0 deletions tests/integration/systemConfig/systemConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
Loading