From f73d4a80d363852b9130bfd638066ab445fdb109 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 09:00:30 -0500 Subject: [PATCH 1/4] test(system-config): pin the public OpenID Provider advertisement Covers each case where oidcProvider must be absent, refusing an ineligible oidc_cli_client_id, the advertisement disappearing when the application is disabled, deleted or has its exchange turned off, and clearing the key with null. Fails until the route and key exist. --- .../systemConfig/systemConfig.spec.ts | 113 +++++ tests/postgres/publicOidcProvider.spec.ts | 405 ++++++++++++++++++ tests/setup/mocks.ts | 1 + tests/unit/config/systemConfigEnvMap.spec.ts | 1 + 4 files changed, 520 insertions(+) create mode 100644 tests/postgres/publicOidcProvider.spec.ts diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index 8d59a8c8..dcfac5e3 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -9,10 +9,25 @@ import { getSystemConfig, invalidateSystemConfigCache, } from '../../../src/config/getSystemConfig.js'; +import { OidcClient } from '../../../src/models/oidcClients.js'; import { Session } from '../../../src/models/sessions.js'; import { buildFreshStepUpSession } from '../../factories/sessionFactory.js'; import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; +vi.mock('../../../src/models/oidcClients.js', () => ({ + OidcClient: { findByPk: vi.fn() }, +})); + +const CLI_CLIENT = { + id: 'seamless-cli', + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + disabledAt: null, +}; + let app: Application; beforeAll(async () => { @@ -342,4 +357,102 @@ describe('GET /system-config/public', () => { ); expect((await request(app).get('/system-config/public')).body.accountRecovery).toBe(true); }); + + describe('oidcProvider', () => { + const advertising = { + oidc_provider_enabled: true, + oidc_issuer: 'https://login.example.com', + oidc_cli_client_id: CLI_CLIENT.id, + }; + + it('advertises the issuer and client when every condition holds', async () => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig(advertising)); + (OidcClient.findByPk as any).mockResolvedValue(CLI_CLIENT); + + const res = await request(app).get('/system-config/public'); + + expect(res.body.oidcProvider).toEqual({ + issuer: 'https://login.example.com', + cliClientId: CLI_CLIENT.id, + }); + expect(res.headers['cache-control']).toBe('no-store'); + }); + + it.each([ + ['the provider is off', { oidc_provider_enabled: false }], + ['there is no issuer', { oidc_issuer: null }], + ['there is no client', { oidc_cli_client_id: null }], + ])('is absent, and reads no application, when %s', async (_name, overrides) => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ ...advertising, ...overrides }), + ); + + const res = await request(app).get('/system-config/public'); + + expect(res.body).not.toHaveProperty('oidcProvider'); + expect(OidcClient.findByPk).not.toHaveBeenCalled(); + }); + + it.each([ + ['does not exist', null], + ['is disabled', { ...CLI_CLIENT, disabledAt: new Date() }], + ['has exchange off', { ...CLI_CLIENT, sessionExchangeEnabled: false }], + ])('is absent when the application %s', async (_name, client) => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig(advertising)); + (OidcClient.findByPk as any).mockResolvedValue(client); + + const res = await request(app).get('/system-config/public'); + + expect(res.body).not.toHaveProperty('oidcProvider'); + }); + }); +}); + +describe('PATCH /system-config/admin oidc_cli_client_id', () => { + beforeEach(() => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); + (User.findAll as any).mockResolvedValue([]); + (SystemConfig.findAll as any).mockResolvedValue([]); + }); + + it('stores an eligible application', async () => { + (OidcClient.findByPk as any).mockResolvedValue(CLI_CLIENT); + + const res = await request(app) + .patch('/system-config/admin') + .send({ oidc_cli_client_id: CLI_CLIENT.id }); + + expect(res.status).toBe(200); + expect(SystemConfig.upsert).toHaveBeenCalledWith( + expect.objectContaining({ key: 'oidc_cli_client_id', value: CLI_CLIENT.id }), + expect.anything(), + ); + }); + + it.each([ + ['unknown', null], + ['ineligible', { ...CLI_CLIENT, sessionExchangeEnabled: false }], + ])('refuses an %s application', async (_name, client) => { + (OidcClient.findByPk as any).mockResolvedValue(client); + + const res = await request(app) + .patch('/system-config/admin') + .send({ oidc_cli_client_id: 'nope' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid OpenID Provider config'); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + // system_config.value is NOT NULL, so a null is stored as no row at all. + it('removes the row to clear the key, without looking up an application', async () => { + const res = await request(app).patch('/system-config/admin').send({ oidc_cli_client_id: null }); + + expect(res.status).toBe(200); + expect(OidcClient.findByPk).not.toHaveBeenCalled(); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + expect(SystemConfig.destroy).toHaveBeenCalledWith( + expect.objectContaining({ where: { key: 'oidc_cli_client_id' } }), + ); + }); }); diff --git a/tests/postgres/publicOidcProvider.spec.ts b/tests/postgres/publicOidcProvider.spec.ts new file mode 100644 index 00000000..9ad0e15f --- /dev/null +++ b/tests/postgres/publicOidcProvider.spec.ts @@ -0,0 +1,405 @@ +import { generateKeyPairSync, randomUUID } from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; +import { AuthEvent } from '../../src/models/authEvents.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { OidcClient, type OidcClientAttributes } from '../../src/models/oidcClients.js'; +import { Session } from '../../src/models/sessions.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; +import { User } from '../../src/models/users.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +const API_ISSUER = 'http://localhost:5312'; +const PROVIDER_ISSUER = 'https://login.public-oidc-provider.test'; +const KEYS = ['oidc_provider_enabled', 'oidc_issuer', 'oidc_cli_client_id']; +const WITHOUT_ADVERTISEMENT = ['loginMethods', 'recoveryCodes', 'accountRecovery']; + +const CONFIDENTIAL: Partial = { + clientType: 'confidential', + tokenEndpointAuthMethod: 'private_key_jwt', + jwksUri: 'https://app.example.com/jwks', +}; + +const providerKey = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + +type App = Awaited>; +type Tokens = typeof import('../../src/lib/token.js'); + +let app: App; +let tokens: Tokens; +const savedEnv: Record = {}; + +function setEnv(values: Record) { + for (const [key, value] of Object.entries(values)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } +} + +async function setConfig(key: string, value: unknown) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + { replacements: { key, value: JSON.stringify(value) } }, + ); +} + +function nativeClient(overrides: Partial = {}) { + return OidcClient.create({ + id: `cli-${randomUUID()}`, + name: 'Seamless CLI', + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + ...overrides, + }); +} + +async function configure({ + enabled = true, + issuer = PROVIDER_ISSUER as string | null, + clientId, +}: { + enabled?: boolean; + issuer?: string | null; + clientId: string | null; +}) { + await SystemConfig.destroy({ where: { key: KEYS } }); + await setConfig('oidc_provider_enabled', enabled); + if (issuer) await setConfig('oidc_issuer', issuer); + if (clientId) await setConfig('oidc_cli_client_id', clientId); + invalidateSystemConfigCache(); +} + +async function admin(roles = ['admin:write', 'admin:read'], stepUp = true) { + const user = await User.create({ + email: `${randomUUID()}@public-oidc-provider.test`, + phone: null, + roles, + } as never); + const now = Date.now(); + const session = await Session.create({ + userId: user.id, + mode: 'server', + refreshTokenLookup: randomUUID().replaceAll('-', ''), + expiresAt: new Date(now + 3_600_000), + idleExpiresAt: new Date(now + 3_600_000), + lastUsedAt: new Date(now), + chainStartedAt: new Date(now), + ...(stepUp ? { stepUpVerifiedAt: new Date(now) } : {}), + }); + return { + user, + token: await tokens.signAccessToken(session.id, session.userId, roles, null), + }; +} + +function patchConfig(token: string, body: Record) { + return request(app) + .patch('/system-config/admin') + .set('Authorization', `Bearer ${token}`) + .send(body); +} + +function readPublic() { + return request(app).get('/system-config/public'); +} + +beforeAll(async () => { + setEnv({ + ISSUER: API_ISSUER, + ALLOW_UNCREDENTIALED_DELIVERY_SECRETS: 'true', + DISABLE_AUTH_RATE_LIMITS: 'true', + SEAMLESS_DEV_KEYS_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'seamless-keys-')), + OIDC_JWKS_ACTIVE_KID: 'public-provider-key', + OIDC_JWKS_KEYS: JSON.stringify({ + keys: [{ ...providerKey.export({ format: 'jwk' }), kid: 'public-provider-key' }], + }), + }); + + await initializeModels(); + + for (const [key, value] of Object.entries(buildSystemConfig())) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + + tokens = await import('../../src/lib/token.js'); + const { initDevSigningKey } = await import('../../src/utils/signingKeyStore.js'); + const { createApp } = await import('../../src/app.js'); + initDevSigningKey(); + app = await createApp(); +}); + +afterAll(async () => { + await SystemConfig.destroy({ where: { key: KEYS } }); + await getSequelize().query(`DELETE FROM users WHERE email LIKE :pattern`, { + replacements: { pattern: '%@public-oidc-provider.test' }, + }); + invalidateSystemConfigCache(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await getSequelize().close(); +}); + +beforeEach(async () => { + await SystemConfig.destroy({ where: { key: KEYS } }); + invalidateSystemConfigCache(); +}); + +describe('GET /system-config/public oidcProvider', () => { + it('advertises the issuer and client when every condition holds', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + + const res = await readPublic(); + + expect(res.status).toBe(200); + expect(res.body.oidcProvider).toEqual({ issuer: PROVIDER_ISSUER, cliClientId: client.id }); + expect(Object.keys(res.body.oidcProvider)).toEqual(['issuer', 'cliClientId']); + }); + + it('is never stored by a cache between the caller and the API', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + + expect((await readPublic()).headers['cache-control']).toBe('no-store'); + }); + + async function expectAbsent() { + const res = await readPublic(); + + expect(res.status).toBe(200); + expect(Object.keys(res.body)).toEqual(WITHOUT_ADVERTISEMENT); + } + + it('is absent on an instance with none of the keys', async () => { + await expectAbsent(); + }); + + it('is absent while the provider is off', async () => { + const client = await nativeClient(); + await configure({ enabled: false, clientId: client.id }); + + await expectAbsent(); + }); + + it('is absent with no issuer, rather than sending the client alone', async () => { + const client = await nativeClient(); + await configure({ issuer: null, clientId: client.id }); + + await expectAbsent(); + }); + + it('is absent with no client, rather than sending the issuer alone', async () => { + await configure({ clientId: null }); + + await expectAbsent(); + }); + + it('is absent when the application does not exist', async () => { + await configure({ clientId: `missing-${randomUUID()}` }); + + await expectAbsent(); + }); + + it('is absent when the application is disabled', async () => { + const client = await nativeClient({ disabledAt: new Date() }); + await configure({ clientId: client.id }); + + await expectAbsent(); + }); + + it.each<[string, Partial]>([ + ['has exchange off', { sessionExchangeEnabled: false }], + ['is third-party', { firstParty: false }], + ['is confidential', CONFIDENTIAL], + ['has no native redirect', { redirectUris: ['https://app.example.com/callback'] }], + ])('is absent when the application %s', async (_name, overrides) => { + const client = await nativeClient(overrides); + await configure({ clientId: client.id }); + + await expectAbsent(); + }); +}); + +describe('the application changing after it was named', () => { + it('disappears when the application is disabled, and returns when re-enabled', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + const { token } = await admin(); + const update = (body: Record) => + request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${token}`) + .send(body); + + expect((await readPublic()).body.oidcProvider).toBeDefined(); + + expect((await update({ disabled: true })).status).toBe(200); + expect((await readPublic()).body).not.toHaveProperty('oidcProvider'); + + expect((await update({ disabled: false })).status).toBe(200); + expect((await readPublic()).body.oidcProvider?.cliClientId).toBe(client.id); + }); + + it('disappears when its exchange is turned off', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + const { token } = await admin(); + + const res = await request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ sessionExchangeEnabled: false }); + + expect(res.status).toBe(200); + expect((await readPublic()).body).not.toHaveProperty('oidcProvider'); + }); + + it('disappears when the application is deleted', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + const { token } = await admin(); + + const res = await request(app) + .delete(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect((await readPublic()).body).not.toHaveProperty('oidcProvider'); + }); + + it('leaves the stored key in place, so other config can still be patched', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + await client.update({ disabledAt: new Date() }); + const { token } = await admin(); + + const res = await patchConfig(token, { app_name: 'Still Patchable' }); + + expect(res.status).toBe(200); + expect((await SystemConfig.findByPk('oidc_cli_client_id'))?.value).toBe(client.id); + }); +}); + +describe('PATCH /system-config/admin oidc_cli_client_id', () => { + it('stores an eligible application, audits it, and advertises it at once', async () => { + const client = await nativeClient(); + await configure({ clientId: null }); + await readPublic(); + const { user, token } = await admin(); + + const res = await patchConfig(token, { oidc_cli_client_id: client.id }); + + expect(res.status).toBe(200); + expect(res.body.updatedKeys).toEqual(['oidc_cli_client_id']); + expect((await SystemConfig.findByPk('oidc_cli_client_id'))?.value).toBe(client.id); + expect((await readPublic()).body.oidcProvider).toEqual({ + issuer: PROVIDER_ISSUER, + cliClientId: client.id, + }); + + const event = await AuthEvent.findOne({ + where: { type: 'system_config_updated', user_id: user.id }, + }); + expect(event?.metadata).toMatchObject({ after: { oidc_cli_client_id: client.id } }); + }); + + it('is readable back through the admin route', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + const { token } = await admin(['admin:read']); + + const res = await request(app) + .get('/system-config/admin') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.oidc_cli_client_id).toBe(client.id); + }); + + it('needs a fresh step-up', async () => { + const client = await nativeClient(); + const { token } = await admin(['admin:write', 'admin:read'], false); + + const res = await patchConfig(token, { oidc_cli_client_id: client.id }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('step_up_required'); + expect(await SystemConfig.findByPk('oidc_cli_client_id')).toBeNull(); + }); + + it('refuses an unknown application', async () => { + const { token } = await admin(); + + const res = await patchConfig(token, { oidc_cli_client_id: `missing-${randomUUID()}` }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid OpenID Provider config'); + expect(await SystemConfig.findByPk('oidc_cli_client_id')).toBeNull(); + }); + + it.each<[string, Partial]>([ + ['is disabled', { disabledAt: new Date() }], + ['has exchange off', { sessionExchangeEnabled: false }], + ['is third-party', { firstParty: false }], + ['is confidential', CONFIDENTIAL], + ['has no native redirect', { redirectUris: ['https://app.example.com/callback'] }], + ])('refuses an application that %s', async (_name, overrides) => { + const client = await nativeClient(overrides); + const { token } = await admin(); + + const res = await patchConfig(token, { oidc_cli_client_id: client.id }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid OpenID Provider config'); + expect(await SystemConfig.findByPk('oidc_cli_client_id')).toBeNull(); + }); + + it('refuses an empty id', async () => { + const { token } = await admin(); + + expect((await patchConfig(token, { oidc_cli_client_id: '' })).status).toBe(400); + }); + + it('clears the key with null, on the NOT NULL value column', async () => { + const client = await nativeClient(); + await configure({ clientId: client.id }); + const { token } = await admin(); + + const res = await patchConfig(token, { oidc_cli_client_id: null }); + + expect(res.status).toBe(200); + expect(await SystemConfig.findByPk('oidc_cli_client_id')).toBeNull(); + expect((await readPublic()).body).not.toHaveProperty('oidcProvider'); + + const read = await request(app) + .get('/system-config/admin') + .set('Authorization', `Bearer ${token}`); + expect(read.status).toBe(200); + expect(read.body.oidc_cli_client_id).toBeNull(); + }); + + it('clears a key naming an application that no longer exists', async () => { + await configure({ clientId: `gone-${randomUUID()}` }); + const { token } = await admin(); + + expect((await patchConfig(token, { oidc_cli_client_id: null })).status).toBe(200); + expect(await SystemConfig.findByPk('oidc_cli_client_id')).toBeNull(); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index fbe41216..013c5bdb 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -30,6 +30,7 @@ vi.mock('../../src/models/systemConfig.js', () => ({ findAll: vi.fn(), findByPk: vi.fn(), upsert: vi.fn(), + destroy: vi.fn(), sequelize: { // The transaction object carries LOCK because the provider edits read the row // FOR UPDATE, so the double has to offer what the real one does. diff --git a/tests/unit/config/systemConfigEnvMap.spec.ts b/tests/unit/config/systemConfigEnvMap.spec.ts index 33c61e7b..be4bffe0 100644 --- a/tests/unit/config/systemConfigEnvMap.spec.ts +++ b/tests/unit/config/systemConfigEnvMap.spec.ts @@ -38,6 +38,7 @@ describe('SYSTEM_CONFIG_ENV_MAP', () => { app_name: 'APP_NAME', oidc_provider_enabled: 'OIDC_PROVIDER_ENABLED', oidc_issuer: 'OIDC_ISSUER', + oidc_cli_client_id: 'OIDC_CLI_CLIENT_ID', allow_client_secrets: 'OIDC_ALLOW_CLIENT_SECRETS', third_party_clients_enabled: 'OIDC_THIRD_PARTY_CLIENTS_ENABLED', hosted_ui: 'OIDC_HOSTED_UI', From fb9e805c0b67fa9a2f36bab9fe1c07d082291f92 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 09:05:59 -0500 Subject: [PATCH 2/4] feat(system-config): advertise the provider on the public config Adds the oidc_cli_client_id key, refused unless it names an active application eligible for session exchange, and an optional oidcProvider on GET /system-config/public, sent whole or not at all (ADR 0003). The application is read on every request, so disabling it ends the advertisement at once. PATCH /system-config/admin answered 500 for any nullable key set to null, because system_config.value is NOT NULL. It now removes the row, which reads back as the schema's null. --- .changeset/public-oidc-provider.md | 5 +++ openapi.json | 49 ++++++++++++++--------- src/config/systemConfig.defaults.ts | 1 + src/config/systemConfig.envMap.ts | 1 + src/controllers/systemConfig.ts | 37 ++++++++++++++++- src/generated/api.ts | 31 +++++++++++--- src/routes/systemConfig.routes.ts | 5 ++- src/services/oidcClientRegistry.ts | 6 +++ src/utils/parseEnvConfigs.ts | 1 + tests/postgres/publicOidcProvider.spec.ts | 15 +++---- 10 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 .changeset/public-oidc-provider.md diff --git a/.changeset/public-oidc-provider.md b/.changeset/public-oidc-provider.md new file mode 100644 index 00000000..e49ff9c0 --- /dev/null +++ b/.changeset/public-oidc-provider.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +`GET /system-config/public` can now tell a native admin tool how to sign in through the OpenID Provider (ADR 0003). A new system config key, `oidc_cli_client_id` (seeded from `OIDC_CLI_CLIENT_ID`, or set through `PATCH /system-config/admin`), names the application. The response gains an optional `oidcProvider: { issuer, cliClientId }`, present only when the provider is on, the issuer and key are set, and that application is active and eligible for session exchange with it turned on. Setting the key to an unknown or ineligible application answers 400; later disabling the application or turning its exchange off makes the advertisement disappear. Also fixes `PATCH /system-config/admin` answering 500 when it set a nullable key such as `oidc_issuer`, `hosted_ui` or `max_concurrent_sessions` to `null`: the row is now removed, which reads back as `null`. No migration is needed. Needs the `@seamless-auth/types` release that adds these fields. diff --git a/openapi.json b/openapi.json index 2efa795a..b848e9f3 100644 --- a/openapi.json +++ b/openapi.json @@ -14847,7 +14847,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14898,7 +14898,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15106,7 +15105,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, - "sessionExchangeEnabled": true, + "sessionExchangeEnabled": null, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15159,7 +15158,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15210,7 +15209,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15369,7 +15367,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, - "sessionExchangeEnabled": true, + "sessionExchangeEnabled": null, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15421,7 +15419,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15472,7 +15470,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15713,7 +15710,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, - "sessionExchangeEnabled": true, + "sessionExchangeEnabled": null, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15765,7 +15762,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15816,7 +15813,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -16233,7 +16229,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, - "sessionExchangeEnabled": true, + "sessionExchangeEnabled": null, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -16286,7 +16282,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -16337,7 +16333,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -16557,7 +16552,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, - "sessionExchangeEnabled": true, + "sessionExchangeEnabled": null, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -16609,7 +16604,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, - "sessionExchangeEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -16660,7 +16655,6 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", - "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -24520,7 +24514,8 @@ "example": { "loginMethods": [null], "recoveryCodes": true, - "accountRecovery": true + "accountRecovery": true, + "oidcProvider": { "issuer": null, "cliClientId": "string" } }, "schema": { "type": "object", @@ -24534,7 +24529,15 @@ "minItems": 1 }, "recoveryCodes": { "type": "boolean" }, - "accountRecovery": { "type": "boolean" } + "accountRecovery": { "type": "boolean" }, + "oidcProvider": { + "type": "object", + "properties": { + "issuer": { "type": "string", "format": "uri" }, + "cliClientId": { "type": "string", "minLength": 1 } + }, + "required": ["issuer", "cliClientId"] + } }, "required": ["loginMethods"] } @@ -24687,6 +24690,7 @@ "magic_link_redirect_uris": null, "oidc_provider_enabled": null, "oidc_issuer": null, + "oidc_cli_client_id": null, "allow_client_secrets": null, "third_party_clients_enabled": null, "hosted_ui": null @@ -24975,6 +24979,12 @@ "format": "uri", "default": null }, + "oidc_cli_client_id": { + "type": "string", + "nullable": true, + "minLength": 1, + "default": null + }, "allow_client_secrets": { "type": "boolean", "default": false }, "third_party_clients_enabled": { "type": "boolean", "default": false }, "hosted_ui": { @@ -25378,6 +25388,7 @@ }, "oidc_provider_enabled": { "type": "boolean" }, "oidc_issuer": { "type": "string", "nullable": true, "format": "uri" }, + "oidc_cli_client_id": { "type": "string", "nullable": true, "minLength": 1 }, "allow_client_secrets": { "type": "boolean" }, "third_party_clients_enabled": { "type": "boolean" }, "hosted_ui": { diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index 35a0709e..0db59e1e 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -48,6 +48,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { // The provider is opt-in per deployment (ADR 0001, D13). oidc_provider_enabled: false, oidc_issuer: null, + oidc_cli_client_id: null, allow_client_secrets: false, third_party_clients_enabled: false, hosted_ui: null, diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index e1e3fd51..47657c69 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -30,6 +30,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { app_name: 'APP_NAME', oidc_provider_enabled: 'OIDC_PROVIDER_ENABLED', oidc_issuer: 'OIDC_ISSUER', + oidc_cli_client_id: 'OIDC_CLI_CLIENT_ID', allow_client_secrets: 'OIDC_ALLOW_CLIENT_SECRETS', third_party_clients_enabled: 'OIDC_THIRD_PARTY_CLIENTS_ENABLED', hosted_ui: 'OIDC_HOSTED_UI', diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 552fd082..351dadb1 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -17,10 +17,14 @@ import { resolveSystemConfigUpdatedBy } from '../lib/systemConfigActor.js'; import { SystemConfig } from '../models/systemConfig.js'; import { User } from '../models/users.js'; import { createPatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; -import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; +import { + type SystemConfig as SystemConfigValues, + SystemConfigSchema, +} from '../schemas/systemConfig.schema.js'; import { isAccountRecoveryAvailable } from '../services/accountRecoveryService.js'; import { AuthEventService } from '../services/authEventService.js'; import { getLoginPolicy } from '../services/loginPolicyService.js'; +import { findSessionExchangeClient } from '../services/oidcClientRegistry.js'; import { isPolicyKey, lockPolicyForConfigPatch, @@ -80,6 +84,16 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { } } + if ( + parsed.data.oidc_cli_client_id && + !(await findSessionExchangeClient(parsed.data.oidc_cli_client_id)) + ) { + return res.status(400).json({ + error: 'Invalid OpenID Provider config', + details: ['oidc_cli_client_id must name an active application with session exchange on'], + }); + } + const rolesInUse = await getRolesInUse(); if (parsed.data.available_roles) { @@ -119,6 +133,12 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { } for (const [key, value] of Object.entries(updates)) { + // system_config.value is NOT NULL, and no row reads back as the schema's null. + if (value === null) { + await SystemConfig.destroy({ where: { key }, transaction: tx }); + continue; + } + await SystemConfig.upsert( { key, @@ -188,6 +208,19 @@ export const getAvailableRoles = async (_req: Request, res: Response) => { }); }; +/** + * ADR 0003. Whole or not at all. The application is read on every request rather than + * through the config cache, so disabling it ends the advertisement at once. + */ +async function advertisedOidcProvider(config: SystemConfigValues) { + const { oidc_provider_enabled, oidc_issuer, oidc_cli_client_id } = config; + + if (!oidc_provider_enabled || !oidc_issuer || !oidc_cli_client_id) return null; + if (!(await findSessionExchangeClient(oidc_cli_client_id))) return null; + + return { issuer: oidc_issuer, cliClientId: oidc_cli_client_id }; +} + /** * The configuration a signed-out client is allowed to read. * @@ -202,10 +235,12 @@ export const getPublicSystemConfig = async (_req: Request, res: Response) => { const { loginMethods } = await getLoginPolicy(); const config = await getSystemConfig(); const { recovery_codes } = config; + const oidcProvider = await advertisedOidcProvider(config); return res.json({ loginMethods, recoveryCodes: recovery_codes?.enabled !== false, accountRecovery: isAccountRecoveryAvailable(config), + ...(oidcProvider ? { oidcProvider } : {}), }); }; diff --git a/src/generated/api.ts b/src/generated/api.ts index dbb551df..fe7cfa42 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -14702,6 +14702,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -14893,7 +14894,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, - * "sessionExchangeEnabled": true, + * "sessionExchangeEnabled": null, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14940,6 +14941,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -15119,7 +15121,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, - * "sessionExchangeEnabled": true, + * "sessionExchangeEnabled": null, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15165,6 +15167,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -15596,7 +15599,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, - * "sessionExchangeEnabled": true, + * "sessionExchangeEnabled": null, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15642,6 +15645,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -15839,7 +15843,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, - * "sessionExchangeEnabled": true, + * "sessionExchangeEnabled": null, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15886,6 +15890,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -16108,7 +16113,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, - * "sessionExchangeEnabled": true, + * "sessionExchangeEnabled": null, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -16154,6 +16159,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** @default false */ sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; @@ -23939,13 +23945,22 @@ export interface paths { * null * ], * "recoveryCodes": true, - * "accountRecovery": true + * "accountRecovery": true, + * "oidcProvider": { + * "issuer": null, + * "cliClientId": "string" + * } * } */ 'application/json': { loginMethods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; recoveryCodes?: boolean; accountRecovery?: boolean; + oidcProvider?: { + /** Format: uri */ + issuer: string; + cliClientId: string; + }; }; }; }; @@ -24163,6 +24178,7 @@ export interface paths { * "magic_link_redirect_uris": null, * "oidc_provider_enabled": null, * "oidc_issuer": null, + * "oidc_cli_client_id": null, * "allow_client_secrets": null, * "third_party_clients_enabled": null, * "hosted_ui": null @@ -24383,6 +24399,8 @@ export interface paths { * @default null */ oidc_issuer: string | null; + /** @default null */ + oidc_cli_client_id: string | null; /** @default false */ allow_client_secrets: boolean; /** @default false */ @@ -24659,6 +24677,7 @@ export interface paths { oidc_provider_enabled?: boolean; /** Format: uri */ oidc_issuer?: string | null; + oidc_cli_client_id?: string | null; allow_client_secrets?: boolean; third_party_clients_enabled?: boolean; hosted_ui?: { diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index b5faefe7..5e5a736a 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -70,8 +70,9 @@ 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. +// actually has enabled instead of a hardcoded guess. It exposes only what a sign-in +// already reveals: the login method list, which the screens show by their behaviour, +// and the provider's issuer and a public client's id, both in every authorization URL. systemConfigRouter.get( '/public', { diff --git a/src/services/oidcClientRegistry.ts b/src/services/oidcClientRegistry.ts index 1d7968ff..a3bd4b52 100644 --- a/src/services/oidcClientRegistry.ts +++ b/src/services/oidcClientRegistry.ts @@ -73,6 +73,12 @@ export function isSessionExchangeClient(client: OidcClient) { ); } +/** ADR 0003. The application a native admin tool can be told to sign in with, if this one is. */ +export async function findSessionExchangeClient(id: string) { + const client = await findActiveOidcClient(id); + return client && isSessionExchangeClient(client) ? client : null; +} + export async function getOidcClientMetadata(id: string): Promise { const client = await findActiveOidcClient(id); diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 2d8ded45..066fa993 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -62,6 +62,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA return raw.trim().toLowerCase() === 'true'; case 'oidc_issuer': + case 'oidc_cli_client_id': return raw.trim(); case 'hosted_ui': diff --git a/tests/postgres/publicOidcProvider.spec.ts b/tests/postgres/publicOidcProvider.spec.ts index 9ad0e15f..a589d959 100644 --- a/tests/postgres/publicOidcProvider.spec.ts +++ b/tests/postgres/publicOidcProvider.spec.ts @@ -96,10 +96,7 @@ async function admin(roles = ['admin:write', 'admin:read'], stepUp = true) { chainStartedAt: new Date(now), ...(stepUp ? { stepUpVerifiedAt: new Date(now) } : {}), }); - return { - user, - token: await tokens.signAccessToken(session.id, session.userId, roles, null), - }; + return { token: await tokens.signAccessToken(session.id, session.userId, roles, null) }; } function patchConfig(token: string, body: Record) { @@ -302,7 +299,7 @@ describe('PATCH /system-config/admin oidc_cli_client_id', () => { const client = await nativeClient(); await configure({ clientId: null }); await readPublic(); - const { user, token } = await admin(); + const { token } = await admin(); const res = await patchConfig(token, { oidc_cli_client_id: client.id }); @@ -314,10 +311,10 @@ describe('PATCH /system-config/admin oidc_cli_client_id', () => { cliClientId: client.id, }); - const event = await AuthEvent.findOne({ - where: { type: 'system_config_updated', user_id: user.id }, - }); - expect(event?.metadata).toMatchObject({ after: { oidc_cli_client_id: client.id } }); + const events = await AuthEvent.findAll({ where: { type: 'system_config_updated' } }); + expect(events.some((event) => event.metadata?.after?.oidc_cli_client_id === client.id)).toBe( + true, + ); }); it('is readable back through the admin route', async () => { From 72f857f140dec11f0cac8e8b1496be32599e14de Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 09:06:16 -0500 Subject: [PATCH 3/4] docs(adr): record provider discovery and max_age=0 step-up in ADR 0003 The CLI learns the issuer and client from /system-config/public, and a loopback passkey step-up sends max_age=0, both confirmed by Brandon on 2026-11-05. Updates the API contract and configuration docs. --- docs/admin-operations.md | 2 ++ docs/adr/0003-native-admin-access.md | 38 ++++++++++++++++++---------- docs/api-contract.md | 25 ++++++++++++++++++ docs/configuration.md | 22 ++++++++-------- 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 86536b3d..6642081d 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -321,6 +321,8 @@ the API log. The applications (clients) of the OpenID Provider service ([ADR 0001](./adr/0001-openid-provider.md), D12) are managed under `/admin/applications`. Reads need `admin:read`. Every write needs `admin:write` and a fresh step-up, and writes an `admin_oidc_application_*` auth event. The routes work whether or not `oidc_provider_enabled` is on, so applications can be set up before the provider service is deployed. +To offer provider sign-in to a native admin tool, turn `sessionExchangeEnabled` on for its application, then name it with `oidc_cli_client_id` through `PATCH /system-config/admin`. `GET /system-config/public` advertises it while the provider is on and the application stays eligible ([ADR 0003](./adr/0003-native-admin-access.md)). + | Route | What it does | | ------------------------------------- | -------------------------------------------------------------------------- | | `GET /admin/applications` | List applications. | diff --git a/docs/adr/0003-native-admin-access.md b/docs/adr/0003-native-admin-access.md index 3182361b..e0411085 100644 --- a/docs/adr/0003-native-admin-access.md +++ b/docs/adr/0003-native-admin-access.md @@ -76,7 +76,7 @@ Restricting the subject token to the built-in resource and a dedicated scope mat - **At the exchange, for an authorization code grant,** the child copies the parent's `authMethods`, `authTime`, `stepUpVerifiedAt` and `stepUpMethod` as they are at that instant. The API reads them from the parent row, the same source `/oidc/sign` already treats as the truth (D4, D9), so it does not depend on oidc#34. The five minute window still runs from the original step-up time, so a stale step-up stays stale. Later step-ups on either session never flow to the other. - **For a device grant,** the child copies nothing. It starts with no auth methods and no step-up, as D10 requires: the passkey proved who approved, not who holds the token (RFC 8628 section 5.4). -- **A loopback CLI steps up with a passkey** by running a new loopback authorization with `acr_values=phr` and `max_age=300`. The hosted page runs `/step-up/*` on the parent (D3), the CLI exchanges the fresh subject token for a new child session with a fresh step-up, and logs out the old child. It can also use TOTP. +- **A loopback CLI steps up with a passkey** by running a new loopback authorization with `acr_values=phr` and `max_age=0`. The hosted page runs `/step-up/*` on the parent (D3), the CLI exchanges the fresh subject token for a new child session with a fresh step-up, and logs out the old child. With no browser session, the same request records a sign-in and not a step-up, so the user runs the command a second time (see "Decisions confirmed"). It can also use TOTP. - **A device sign-in steps up with the user's own factor,** on its own child session: TOTP through the existing `/totp/verify-mfa`, or a real passkey assertion on the relying party's domain through `/step-up/webauthn/*`. A phisher can produce neither. The CLI itself uses TOTP, since a terminal cannot run a WebAuthn ceremony. So from the CLI, a user with no TOTP enrolled cannot do admin writes from a device sign-in, and cannot enroll TOTP from one either: they enroll from a browser, then sign in on the device again. That is a deliberate limit, not a gap: copying the approving browser's step-up to a headless machine would hand its assurance to whoever holds the device code, which is the attack D10 exists to stop. - **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up stops that attacker from changing anything only while they cannot produce a TOTP code, which is why X7 also covers every route that changes how the account signs in or recovers: an attacker able to enroll their own authenticator could then step up with it. Without X7 they could also list every user on the instance. @@ -105,17 +105,17 @@ B has no usable revocation story: every event above that should end the CLI's ac ## Decision summary -| # | Decision | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | -| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | -| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | -| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | -| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | -| X6 | A device-grant child steps up with the user's own factor, TOTP or a real passkey assertion on the relying party's domain, and the CLI uses TOTP. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | -| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route and any organization route except switching, reads included, and before any route that changes how the account signs in or recovers. From the CLI it cannot enroll a first factor. | -| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | -| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | +| # | Decision | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | +| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | +| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | +| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | +| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | +| X6 | A device-grant child steps up with the user's own factor, TOTP or a real passkey assertion on the relying party's domain, and the CLI uses TOTP. A loopback child steps up with a passkey by signing in again with `acr_values=phr` and `max_age=0` and exchanging again, or with TOTP. | +| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route and any organization route except switching, reads included, and before any route that changes how the account signs in or recovers. From the CLI it cannot enroll a first factor. | +| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | +| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | ## X2. The subject token @@ -172,13 +172,13 @@ So for an exchange-enabled client, the edge always shows a confirmation that nam ## What seamless-cli#316 builds against -1. **Discover.** Read `oidc_issuer` from the instance and fetch `/.well-known/openid-configuration`. With no provider, keep email code sign-in. +1. **Discover.** `GET /system-config/public`. Its optional `oidcProvider` names the instance's `issuer` and `cliClientId`, and `--oidc-issuer` and `--client-id` override them. Then fetch `/.well-known/openid-configuration`, the OIDC discovery document, which stays the source for endpoints. An instance that does not advertise, including an API that predates the field, gets email code sign-in. 2. **Sign in, loopback.** Authorization code with PKCE `S256` (RFC 8252 section 8.1), redirect to `http://127.0.0.1:/callback` (section 7.3), `resource=/seamless`, `scope=openid session:exchange`, no `offline_access`. The user confirms in the browser (X8). 3. **Sign in, `--device`.** The device authorization request with the same `resource` and `scope` (RFC 8628 section 3.1), then poll the token endpoint (sections 3.4 and 3.5). 4. **Exchange at once.** `POST /sessions/exchange` with the access token, within its 60 seconds. Store the returned first-party tokens in the OS keychain, as today. Keep no provider token. 5. **Use the API as today.** `/refresh`, `/logout`, `/users/me`, `/admin/*` and `/totp/verify-mfa` are unchanged. 6. **Step up on `403 step_up_required`.** - - Loopback session: offer a passkey (repeat step 2 with `acr_values=phr` and `max_age=300`, exchange, swap the keychain tokens, `/logout` the old child) or the authenticator app. + - Loopback session: offer a passkey (repeat step 2 with `acr_values=phr` and `max_age=0`, exchange, swap the keychain tokens, `/logout` the old child) or the authenticator app. With no browser session the passkey records a sign-in and not a step-up, the new child still answers `403 step_up_required`, and the user runs the command a second time. - Device session: offer the authenticator app. When TOTP is not enrolled, say so and send the user to a browser to enroll it, since a device session cannot, then sign in on the device again. `seamless org` commands need this step-up too, except switching. 7. **On `invalid_request` from the exchange,** sign in again. If the description is `token_already_used`, tell the user their sign-in may have been intercepted. @@ -195,6 +195,7 @@ The CLI's e2e covers both paths against a v0.23 API and edge. 7. **Session lists.** `/sessions` and the admin session views show a child's client and grant. A types change. 8. **Docs.** An amendment to ADR 0001 D11 naming `/sessions/exchange` as the one place a provider token is accepted, and only to mint a first-party session. `docs/architecture.md`, `docs/api-contract.md` and the native apps guide. 9. **Tests.** Replay (sequential and concurrent), wrong `aud`, extra scope, expired record, disabled client, recovery parent, device child step-up, and each revoke path ending the child. +10. **Advertisement.** The `oidc_cli_client_id` key and the optional `oidcProvider` on `GET /system-config/public`. A types change. Edge work: X8, and DPoP on the exchange hop with #512 (X9). @@ -210,6 +211,14 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re - **Signing out other sessions stays open.** `DELETE /sessions/:id`, `DELETE /sessions` and `DELETE /logout/all` take no step-up on a device child. They gain no access, and revoking the parent or everything also ends the child. The known cost is that someone holding a phished device session could sign the owner out of other sessions, which could slow the owner's response. - **A passkey step-up is allowed.** `/step-up/webauthn/*` accepts a device child, because a real assertion on the relying party's domain is the user's own factor, which a phisher cannot produce. An assertion from another origin or another key is refused. The CLI itself uses TOTP. +**Confirmed by Brandon on 2026-11-05: how the CLI finds the provider.** Nothing public told a CLI the instance's issuer or client id, and the API's own `/.well-known/openid-configuration` answers 404. So `GET /system-config/public` gains an optional `oidcProvider: { issuer, cliClientId }`, and a new system config key, `oidc_cli_client_id`, names the application a native admin tool signs in with. + +- **Whole or not at all.** It is present only when `oidc_provider_enabled` is on, `oidc_issuer` and `oidc_cli_client_id` are set, and that application exists, is active and passes `isSessionExchangeClient`. Setting the key to an unknown or ineligible application is refused. Later disabling that application or turning its exchange off makes the advertisement disappear, and the key stays as it was. +- **Nothing secret.** The issuer and client id are in the URL of every sign-in, and the issuer is the address of the public discovery document. `isSessionExchangeClient` holds the application to a public client with no secret. The client id was never a credential, and X8 is what handles a local program presenting it. +- **Staleness.** The response is `Cache-Control: no-store`, and the application is read on every request, so a change to it shows at once. The config keys come through the in-process config cache: the replica that takes a write shows it at once, and any other replica within five minutes. + +**Confirmed by Brandon on 2026-11-05: a loopback passkey step-up sends `max_age=0`.** It was `max_age=300`. `acr_values=phr&max_age=300` usually records no step-up, because the hosted page writes `stepUpVerifiedAt` only when the requirements are unmet. With no browser session, even `max_age=0` records a sign-in and not a step-up, so the user runs the command a second time. That is a known cost. Treating a fresh passkey sign-in as a step-up was not adopted, because it changes what a step-up means. + ## Security properties | Property | Provided by | @@ -240,6 +249,7 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re - Until first-party DPoP exists, the CLI's refresh token is a bearer credential, as it is today. - Device sign-ins without TOTP can read nothing on `/admin/*` or `/organizations`, except switching, and cannot enroll TOTP (X7). The user enrolls from a browser, or uses the loopback flow or email code sign-in. - A phished device session can sign the owner out of other sessions, which could slow their response. It gains no access by doing so. +- A loopback passkey step-up with no browser session takes two runs of the command: the first signs in, the second steps up. ## Issue map diff --git a/docs/api-contract.md b/docs/api-contract.md index 554acf94..ecbc2718 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -136,6 +136,26 @@ A first-party public native client signed in through the OpenID Provider reaches for the client, with `PATCH /admin/applications/:id`. It is off by default, and accepted only on a first-party public client with a loopback or private-use redirect URI. +**Finding the provider.** An admin names the application with the `oidc_cli_client_id` system +config key, through `PATCH /system-config/admin`. `GET /system-config/public` then answers with: + +```json +{ + "loginMethods": ["passkey", "email_otp"], + "recoveryCodes": true, + "accountRecovery": false, + "oidcProvider": { "issuer": "https://login.example.com", "cliClientId": "seamless-cli" } +} +``` + +`oidcProvider` is sent whole or not at all. It is present only when `oidc_provider_enabled` is on, +`oidc_issuer` and `oidc_cli_client_id` are set, and that application exists, is active and is +eligible for exchange with it turned on. An API that predates the field does not send it either, +so a client must read its absence as "this instance does not advertise" and offer email code +sign-in. Endpoints come from the OIDC discovery document at +`/.well-known/openid-configuration`, not from this response. The route is +`Cache-Control: no-store`. + The client runs authorization code with PKCE, or the device grant, with `resource=/seamless` and `scope=openid session:exchange`, and without `offline_access`. It then has 60 seconds to send the access token here, directly rather than @@ -179,6 +199,11 @@ recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /reco stay open: they gain no access, though they could slow the owner's response. As for any session, a refresh does not carry a step-up to the new row. +A session from an authorization code grant steps up with a passkey by signing in again with +`acr_values=phr` and `max_age=0`, then exchanging again and logging out the old session. With no +browser session, that records a sign-in and not a step-up, so the command has to run a second +time. It can also use `POST /totp/verify-mfa`. + **A device sign-in with no TOTP cannot enroll one.** The user enrolls from a browser, then signs in on the device again. Without this, someone who phished a device approval could enroll their own authenticator and step up with it. A session from an authorization code grant, and every other diff --git a/docs/configuration.md b/docs/configuration.md index 8a424286..9217514e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -364,16 +364,17 @@ See [docs/production-operations.md](./production-operations.md) for key rotation The API's side of the OpenID Provider service ([ADR 0001](./adr/0001-openid-provider.md)). All of it is off until a deployment runs the provider service and turns it on. -| Variable | Required | Default | Notes | -| ---------------------------------- | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `OIDC_PROVIDER_ENABLED` | No | `false` | Seeds `oidc_provider_enabled`. While off, every `/oidc/*` route answers 404. The server refuses to start with it on unless the checks below pass. | -| `OIDC_ISSUER` | When enabled | - | Seeds `oidc_issuer`, the provider service's public URL. Must be https, must differ from `ISSUER`, and its host must be `rpid` or a subdomain of it. | -| `OIDC_ALLOW_CLIENT_SECRETS` | No | `false` | Seeds `allow_client_secrets`. Lets an admin opt individual provider clients in to shared secrets. `private_key_jwt` is the default without it. | -| `OIDC_THIRD_PARTY_CLIENTS_ENABLED` | No | `false` | Seeds `third_party_clients_enabled`. Third-party clients always show a consent page. While it is off, existing third-party clients are inactive: the API does not serve them to the edge, verify their secrets, sign for them or accept their notice tokens. | -| `SEAMLESS_OIDC_SERVICE_TOKEN` | When enabled | - | The provider service's own service token secret. Must differ from `SEAMLESS_SERVICE_TOKEN`, or the server refuses to start. `/oidc/*` accepts only this, and external delivery refuses it. | -| `OIDC_JWKS_ACTIVE_KID` | When enabled | - | Key ID of the provider signing key. Only this key signs. | -| `OIDC_JWKS_KEYS` | When enabled | - | JSON JWK set of private RS256 keys, each with a unique `kid`. Separate from the `SEAMLESS_JWKS_*` keys. Every key in it is published at `/oidc/jwks`. | -| `OIDC_HOSTED_UI` | No | - | Seeds `hosted_ui` as JSON: branding for the hosted pages (`appName`, an https `logoUrl`, hex `colors`, `fontFamily`). Admins change it through `PATCH /system-config/admin`. | +| Variable | Required | Default | Notes | +| ---------------------------------- | ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_PROVIDER_ENABLED` | No | `false` | Seeds `oidc_provider_enabled`. While off, every `/oidc/*` route answers 404. The server refuses to start with it on unless the checks below pass. | +| `OIDC_ISSUER` | When enabled | - | Seeds `oidc_issuer`, the provider service's public URL. Must be https, must differ from `ISSUER`, and its host must be `rpid` or a subdomain of it. | +| `OIDC_CLI_CLIENT_ID` | No | - | Seeds `oidc_cli_client_id`, the application a native admin tool signs in with, advertised on `GET /system-config/public` ([ADR 0003](./adr/0003-native-admin-access.md)). The application has to be active and eligible for session exchange, or nothing is advertised. Admins change it through `PATCH /system-config/admin`, which refuses an ineligible one. An admin clearing it while this variable is set gets it back at the next start. | +| `OIDC_ALLOW_CLIENT_SECRETS` | No | `false` | Seeds `allow_client_secrets`. Lets an admin opt individual provider clients in to shared secrets. `private_key_jwt` is the default without it. | +| `OIDC_THIRD_PARTY_CLIENTS_ENABLED` | No | `false` | Seeds `third_party_clients_enabled`. Third-party clients always show a consent page. While it is off, existing third-party clients are inactive: the API does not serve them to the edge, verify their secrets, sign for them or accept their notice tokens. | +| `SEAMLESS_OIDC_SERVICE_TOKEN` | When enabled | - | The provider service's own service token secret. Must differ from `SEAMLESS_SERVICE_TOKEN`, or the server refuses to start. `/oidc/*` accepts only this, and external delivery refuses it. | +| `OIDC_JWKS_ACTIVE_KID` | When enabled | - | Key ID of the provider signing key. Only this key signs. | +| `OIDC_JWKS_KEYS` | When enabled | - | JSON JWK set of private RS256 keys, each with a unique `kid`. Separate from the `SEAMLESS_JWKS_*` keys. Every key in it is published at `/oidc/jwks`. | +| `OIDC_HOSTED_UI` | No | - | Seeds `hosted_ui` as JSON: branding for the hosted pages (`appName`, an https `logoUrl`, hex `colors`, `fontFamily`). Admins change it through `PATCH /system-config/admin`. | The issuer has to sit under `rpid` because the hosted sign-in pages run on it and passkeys are bound to `rpid`. Changing `rpid` to fit a new hostname orphans every existing passkey, so pick @@ -469,6 +470,7 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig | `origins` | url[] | `ORIGINS` | - | | `frontend_url` | url | `FRONTEND_URL` | - | | `magic_link_redirect_uris` | string[] | `MAGIC_LINK_REDIRECT_URIS` | `[]` | +| `oidc_cli_client_id` | string or null | `OIDC_CLI_CLIENT_ID` | `null` | ### Flow rate limits From 15e640587fce02051cf7a765d17af325243353ce Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 17:59:24 -0500 Subject: [PATCH 4/4] chore: depend on @seamless-auth/types 0.37.0 Refs #544 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc0c52f0..4dfddd31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.37.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -2927,9 +2927,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.36.0.tgz", - "integrity": "sha512-AxIPhwRegsKEtqi92BnOFv2TVpLsv/xGaJcQx4BUV7JlQSBMa0UUa8xyjJyTQP9m8YlI5O1gM09N2DRo9Ck/sQ==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.37.0.tgz", + "integrity": "sha512-4rd7iEJPCKWPOIP5YKx+pyC9MgmsxOnnP/KR9DMYfCk5qLmAwMXBSRD9AazVYy0L6uvkBbRs3/o7+G/LlxXMxA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 7977dd59..4e745301 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.37.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2",