feat(API): Add public API endpoints for OIDC SSO configuration (#34313)

This commit is contained in:
Dmitrii
2026-07-28 13:58:55 +03:00
committed by GitHub
parent e76fd96069
commit 02e736ef4e
23 changed files with 703 additions and 43 deletions
+1 -1
View File
@@ -208,7 +208,7 @@ export {
type RoleMembersResponse,
} from './roles/role-members-response.dto';
export { OidcConfigDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
export { OidcConfigDto, UpdateOidcConfigurationDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto';
export { CreateDataTableDto } from './data-table/create-data-table.dto';
@@ -0,0 +1,32 @@
import { OidcConfigDto, UpdateOidcConfigurationDto } from '../config.dto';
describe('UpdateOidcConfigurationDto', () => {
it('requires every OidcConfigDto field (stays strict)', () => {
const baseKeys = Object.keys(OidcConfigDto.schema.shape).sort();
const strictKeys = Object.keys(UpdateOidcConfigurationDto.schema.shape).sort();
expect(strictKeys).toEqual(baseKeys);
const result = UpdateOidcConfigurationDto.safeParse({});
expect(result.success).toBe(false);
// An empty body must report every field as missing. A field that carries a
// default would parse successfully instead of erroring.
const missing = result.success
? []
: [...new Set(result.error.issues.map((issue) => String(issue.path[0])))].sort();
expect(missing).toEqual(baseKeys);
});
it('accepts a full body', () => {
const result = UpdateOidcConfigurationDto.safeParse({
clientId: 'n8n-client',
clientSecret: 'super-secret',
discoveryEndpoint: 'https://accounts.example.com/.well-known/openid-configuration',
loginEnabled: false,
prompt: 'consent',
authenticationContextClassReference: ['mfa'],
additionalScopes: 'groups',
});
expect(result.success).toBe(true);
});
});
@@ -13,3 +13,15 @@ export class OidcConfigDto extends Z.class({
authenticationContextClassReference: z.array(z.string()).default([]),
additionalScopes: z.string().default(''),
}) {}
/**
* Public API PUT body for the OIDC SSO configuration: the same shape as
* {@link OidcConfigDto} but with every writable field required (no defaults),
* so a partial body is rejected instead of silently resetting omitted fields.
*/
export class UpdateOidcConfigurationDto extends OidcConfigDto.extend({
loginEnabled: z.boolean(),
prompt: z.enum(OIDC_PROMPT_VALUES),
authenticationContextClassReference: z.array(z.string()),
additionalScopes: z.string(),
}) {}
@@ -93,6 +93,7 @@ export const API_KEY_RESOURCES = {
securityAudit: ['generate'] as const,
securitySettings: ['manage'] as const,
saml: ['manage'] as const,
oidc: ['manage'] as const,
otel: ['manage'] as const,
ldap: ['manage', 'sync'] as const,
project: ['create', 'update', 'delete', 'list', 'export'] as const,
@@ -16,6 +16,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'securityAudit:generate',
'securitySettings:manage',
'saml:manage',
'oidc:manage',
'otel:manage',
'ldap:manage',
'ldap:sync',
@@ -319,7 +319,7 @@ describe('LdapService', () => {
const ldapService = createDefaultLdapService(ldapConfig);
await expect(ldapService.updateConfig(ldapConfig)).rejects.toThrowError(
'LDAP cannot be enabled while another authentication method is active',
'Cannot switch ldap login enabled state when an authentication method other than email or ldap is active (current: saml)',
);
});
@@ -329,7 +329,7 @@ describe('LdapService', () => {
const ldapService = createDefaultLdapService(ldapConfig);
await expect(ldapService.updateConfig(ldapConfig)).rejects.toThrowError(
'LDAP cannot be enabled while another authentication method is active',
'Cannot switch ldap login enabled state when an authentication method other than email or ldap is active (current: token-exchange)',
);
});
@@ -14,12 +14,10 @@ import { jsonParse, UnexpectedError } from 'n8n-workflow';
import type { ConnectionOptions } from 'tls';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service';
import {
assertAuthenticationMethodCanBeEnabled,
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
@@ -123,14 +121,8 @@ export class LdapService implements IPasswordAuthHandler<User> {
throw new BadRequestError(message);
}
if (
ldapConfig.loginEnabled &&
!isEmailCurrentAuthenticationMethod() &&
!isLdapCurrentAuthenticationMethod()
) {
throw new BadRequestError(
`LDAP cannot be enabled while another authentication method is active (current: ${getCurrentAuthenticationMethod()})`,
);
if (ldapConfig.loginEnabled) {
assertAuthenticationMethodCanBeEnabled('ldap');
}
this.setConfig({ ...ldapConfig });
@@ -181,10 +173,8 @@ export class LdapService implements IPasswordAuthHandler<User> {
/** Set the LDAP login enabled to the configuration object */
private async setLdapLoginEnabled(enabled: boolean): Promise<void> {
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
if (enabled && !isEmailCurrentAuthenticationMethod() && !isLdapCurrentAuthenticationMethod()) {
throw new InternalServerError(
`Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${currentAuthenticationMethod})`,
);
if (enabled) {
assertAuthenticationMethodCanBeEnabled('ldap');
}
Container.get(GlobalConfig).sso.ldap.loginEnabled = enabled;
@@ -21,14 +21,13 @@ import { inspect } from 'util';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { buildOidcClaimsContext } from '@/modules/provisioning.ee/claims-context.builder';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { JwtService } from '@/services/jwt.service';
import { UrlService } from '@/services/url.service';
import {
assertAuthenticationMethodCanBeEnabled,
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod,
isOidcCurrentAuthenticationMethod,
reloadAuthenticationMethod,
setCurrentAuthenticationMethod,
@@ -628,14 +627,8 @@ export class OidcService {
}
async updateConfig(newConfig: OidcConfigDto) {
const isEnablingOidcWhileOtherSsoProtocolIsAlreadyEnabled =
newConfig.loginEnabled &&
!isEmailCurrentAuthenticationMethod() &&
!isOidcCurrentAuthenticationMethod();
if (isEnablingOidcWhileOtherSsoProtocolIsAlreadyEnabled) {
throw new InternalServerError(
`Cannot switch OIDC login enabled state when an authentication method other than email or OIDC is active (current: ${getCurrentAuthenticationMethod()})`,
);
if (newConfig.loginEnabled) {
assertAuthenticationMethodCanBeEnabled('oidc');
}
let discoveryEndpoint: URL;
@@ -692,12 +685,8 @@ export class OidcService {
private async setOidcLoginEnabled(enabled: boolean): Promise<void> {
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
const isEnablingOidcWhileOtherSsoProtocolIsAlreadyEnabled =
enabled && !isEmailCurrentAuthenticationMethod() && !isOidcCurrentAuthenticationMethod();
if (isEnablingOidcWhileOtherSsoProtocolIsAlreadyEnabled) {
throw new InternalServerError(
`Cannot switch OIDC login enabled state when an authentication method other than email or OIDC is active (current: ${currentAuthenticationMethod})`,
);
if (enabled) {
assertAuthenticationMethodCanBeEnabled('oidc');
}
const targetAuthenticationMethod =
@@ -7,12 +7,10 @@ import { randomString } from 'n8n-workflow';
import type { FlowResult } from 'samlify/types/src/flow';
import { AuthError } from '@/errors/response-errors/auth.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { PasswordUtility } from '@/services/password.utility';
import {
assertAuthenticationMethodCanBeEnabled,
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod,
isSamlCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
@@ -22,10 +20,8 @@ import type { SamlAttributeMapping, SamlUserAttributes } from './types';
// can only toggle between email and saml, not directly to e.g. ldap
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
if (enabled && !isEmailCurrentAuthenticationMethod() && !isSamlCurrentAuthenticationMethod()) {
throw new InternalServerError(
`Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${currentAuthenticationMethod})`,
);
if (enabled) {
assertAuthenticationMethodCanBeEnabled('saml');
}
const targetAuthenticationMethod =
+10
View File
@@ -9,6 +9,7 @@ import type {
UpsertDataTableRowDto,
UpdateSecurityPolicyDto,
PublicCreateDestination,
UpdateOidcConfigurationDto,
UpdateOtelSettingsDto,
TestOtelTraceDto,
UpdateSamlConfigurationDto,
@@ -452,3 +453,12 @@ export declare namespace LdapRequest {
type GetSync = PaginatedRequest;
type RunSync = AuthenticatedRequest<{}, {}, LdapSyncDto>;
}
// ----------------------------------
// /settings/sso/oidc
// ----------------------------------
export declare namespace SsoOidcRequest {
type Get = AuthenticatedRequest;
type Set = AuthenticatedRequest<{}, {}, UpdateOidcConfigurationDto>;
}
@@ -0,0 +1,58 @@
get:
x-eov-operation-id: getOidcConfiguration
x-required-scope: oidc:manage
x-eov-operation-handler: v1/handlers/sso-oidc/sso-oidc.handler
tags:
- SettingsSsoOidc
summary: Retrieve the OIDC SSO configuration
description: >
Retrieve the current OIDC SSO configuration, including every field exposed in the UI.
The client secret is redacted on read and is never echoed back in plaintext. Requires the
`oidc:manage` scope and the OIDC feature to be licensed.
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/oidc-configuration.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
put:
x-eov-operation-id: setOidcConfiguration
x-required-scope: oidc:manage
x-eov-operation-handler: v1/handlers/sso-oidc/sso-oidc.handler
tags:
- SettingsSsoOidc
summary: Set the OIDC SSO configuration
description: >
Set the OIDC SSO configuration. The update takes effect exactly as it would from the UI,
using the same validation. `clientId`, `clientSecret` and `discoveryEndpoint` are required;
submit the redacted client secret sentinel to keep the stored secret unchanged. Requires the
`oidc:manage` scope and the OIDC feature to be licensed. The client secret is redacted in the
response. When the configuration is managed declaratively (via environment variables), the
write is rejected with 409 and no changes are made.
requestBody:
description: The OIDC SSO configuration to set.
required: true
content:
application/json:
schema:
$ref: '../schemas/oidc-configuration.update.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/oidc-configuration.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'
@@ -0,0 +1,55 @@
type: object
additionalProperties: false
description: >
Full OIDC SSO configuration to set. This is a full replacement: every writable field must be
provided. Partial updates are rejected. Submit the redacted secret sentinel
for `clientSecret` to keep the stored secret unchanged.
required:
- clientId
- clientSecret
- discoveryEndpoint
- loginEnabled
- prompt
- authenticationContextClassReference
- additionalScopes
properties:
clientId:
type: string
minLength: 1
description: The client ID issued when registering n8n with the OIDC provider.
example: n8n-client
clientSecret:
type: string
minLength: 1
description: >
The client secret issued when registering n8n with the OIDC provider. Submit the redacted
sentinel value returned on read to keep the stored secret unchanged.
example: my-client-secret
discoveryEndpoint:
type: string
format: uri
description: The OIDC provider's well-known discovery endpoint.
example: https://accounts.google.com/.well-known/openid-configuration
loginEnabled:
type: boolean
description: Whether OIDC single sign-on is enabled.
example: false
prompt:
type: string
enum: [none, login, consent, select_account, create]
description: The prompt parameter to use when authenticating.
example: select_account
authenticationContextClassReference:
type: array
items:
type: string
description: >
ACR values to include in the authorization request (acr_values parameter), in order of
preference. Use an empty array when unused.
example: [mfa, pwd]
additionalScopes:
type: string
description: >
Additional scopes to request, space separated. n8n always requests `openid`, `profile`
and `email`. Use an empty string when unused.
example: groups roles
@@ -0,0 +1,49 @@
type: object
additionalProperties: false
required:
- clientId
- clientSecret
- discoveryEndpoint
- loginEnabled
- prompt
- authenticationContextClassReference
- additionalScopes
properties:
clientId:
type: string
description: The client ID issued when registering n8n with the OIDC provider.
example: n8n-client
clientSecret:
type: string
description: >
The client secret issued when registering n8n with the OIDC provider. Redacted on read
when set; never echoed back in plaintext.
example: '**hidden**'
discoveryEndpoint:
type: string
format: uri
description: The OIDC provider's well-known discovery endpoint.
example: https://accounts.google.com/.well-known/openid-configuration
loginEnabled:
type: boolean
description: Whether OIDC single sign-on is enabled.
example: false
prompt:
type: string
enum: [none, login, consent, select_account, create]
description: The prompt parameter to use when authenticating with the OIDC provider.
example: select_account
authenticationContextClassReference:
type: array
items:
type: string
description: >
ACR values to include in the authorization request (acr_values parameter), in order of
preference.
example: [mfa, pwd]
additionalScopes:
type: string
description: >
Additional scopes to request, space separated. n8n always requests `openid`, `profile`
and `email`.
example: groups roles
@@ -0,0 +1,57 @@
import { UpdateOidcConfigurationDto } from '@n8n/api-types';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
import { toOidcConfigurationResponse } from './sso-oidc.mapper';
import type { SsoOidcRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
} from '../../shared/middlewares/global.middleware';
type SsoOidcHandlers = {
getOidcConfiguration: PublicAPIEndpoint<SsoOidcRequest.Get>;
setOidcConfiguration: PublicAPIEndpoint<SsoOidcRequest.Set>;
};
const ssoOidcHandlers: SsoOidcHandlers = {
getOidcConfiguration: [
isLicensed('feat:oidc'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'oidc:manage' }),
async (_req, res) => {
const config = await Container.get(OidcService).loadConfig();
return res.json(toOidcConfigurationResponse(config));
},
],
setOidcConfiguration: [
isLicensed('feat:oidc'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'oidc:manage' }),
async (req, res) => {
const payload = UpdateOidcConfigurationDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message ?? 'Invalid request body');
}
const { ssoManagedByEnv } = Container.get(InstanceSettingsLoaderConfig);
if (ssoManagedByEnv) {
throw new ConflictError(
'SSO configuration is managed declaratively and cannot be modified through the API',
);
}
const oidcService = Container.get(OidcService);
await oidcService.updateConfig(payload.data);
return res.json(toOidcConfigurationResponse(await oidcService.loadConfig()));
},
],
};
export = ssoOidcHandlers;
@@ -0,0 +1,14 @@
import type { OidcConfigDto } from '@n8n/api-types';
import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/modules/sso-oidc/constants';
import type { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
type OidcRuntimeConfig = Awaited<ReturnType<OidcService['loadConfig']>>;
export function toOidcConfigurationResponse(config: OidcRuntimeConfig): OidcConfigDto {
return {
...config,
discoveryEndpoint: config.discoveryEndpoint.toString(),
clientSecret: config.clientSecret ? OIDC_CLIENT_SECRET_REDACTED_VALUE : config.clientSecret,
};
}
@@ -53,6 +53,8 @@ tags:
description: Operations about LDAP settings
- name: SettingsOtel
description: Operations about OpenTelemetry settings
- name: SettingsSsoOidc
description: Operations about OIDC SSO settings
- name: SettingsSsoSaml
description: Operations about SAML SSO settings
- name: SourceControl
@@ -79,6 +81,8 @@ paths:
$ref: './handlers/otel/spec/paths/settings.otel.yml'
/settings/otel/test-trace:
$ref: './handlers/otel/spec/paths/settings.otel.test-trace.yml'
/settings/sso/oidc:
$ref: './handlers/sso-oidc/spec/paths/settings.sso.oidc.yml'
/settings/sso/saml:
$ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml'
/credentials:
+13
View File
@@ -4,6 +4,7 @@ import { isAuthProviderType, SettingsRepository, type AuthProviderType } from '@
import { Container } from '@n8n/di';
import config from '@/config';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
/**
* Only one authentication method can be active at a time. This function sets
@@ -91,6 +92,18 @@ export function isEmailCurrentAuthenticationMethod(): boolean {
return getCurrentAuthenticationMethod() === 'email';
}
/** Only one authentication method (`email` or a single SSO method) can be active at a time. */
export function assertAuthenticationMethodCanBeEnabled(
method: Extract<AuthProviderType, 'ldap' | 'saml' | 'oidc'>,
): void {
const currentAuthenticationMethod = getCurrentAuthenticationMethod();
if (currentAuthenticationMethod !== 'email' && currentAuthenticationMethod !== method) {
throw new BadRequestError(
`Cannot switch ${method} login enabled state when an authentication method other than email or ${method} is active (current: ${currentAuthenticationMethod})`,
);
}
}
export function isSsoJustInTimeProvisioningEnabled(): boolean {
return Container.get(GlobalConfig).sso.justInTimeProvisioning;
}
@@ -265,7 +265,7 @@ describe('LDAP configuration in Public API', () => {
expect(response.status).toBe(400);
expect((response.body as ErrorBody).message).toContain(
'LDAP cannot be enabled while another authentication method is active',
'Cannot switch ldap login enabled state when an authentication method other than email or ldap is active',
);
},
);
@@ -0,0 +1,365 @@
import { testDb } from '@n8n/backend-test-utils';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { vi } from 'vitest';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import {
OIDC_CLIENT_SECRET_REDACTED_VALUE,
OIDC_PREFERENCES_DB_KEY,
} from '@/modules/sso-oidc/constants';
import { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
import { SamlService } from '@/modules/sso-saml/saml.service.ee';
import { setCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
import { createOwnerWithApiKey } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { sampleConfig as sampleSamlConfig } from '../saml/sample-metadata';
// OIDC config writes validate the provider by running discovery against the discovery
// endpoint. Stub it so the tests exercise our handler without hitting the network.
vi.mock('openid-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('openid-client')>();
return {
...actual,
discovery: vi.fn().mockResolvedValue({}),
};
});
const validConfig = {
clientId: 'n8n-client',
clientSecret: 'super-secret',
discoveryEndpoint: 'https://accounts.example.com/.well-known/openid-configuration',
loginEnabled: false,
prompt: 'consent' as const,
authenticationContextClassReference: ['mfa'],
additionalScopes: 'groups',
};
describe('OIDC SSO configuration in Public API', () => {
let owner: User;
const testServer = setupTestServer({
endpointGroups: ['publicApi', 'oidc'],
});
const licenseErrorMessage = new FeatureNotLicensedError('feat:oidc').message;
const setManagedByEnv = (value: boolean) => {
Container.get(InstanceSettingsLoaderConfig).ssoManagedByEnv = value;
};
// Reset both the persisted and in-memory OIDC config to defaults between tests.
const resetOidcConfig = async () => {
await Container.get(SettingsRepository).delete({ key: OIDC_PREFERENCES_DB_KEY });
await Container.get(OidcService).init();
};
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['User']);
setManagedByEnv(false);
await resetOidcConfig();
owner = await createOwnerWithApiKey();
});
afterEach(async () => {
setManagedByEnv(false);
await Container.get(SamlService).reset();
await setCurrentAuthenticationMethod('email');
});
describe('GET /settings/sso/oidc', () => {
it('returns the current OIDC configuration when licensed', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
clientId: expect.any(String),
loginEnabled: false,
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
});
expect(typeof response.body.discoveryEndpoint).toBe('string');
});
it('exposes exactly the fields the UI configures, and nothing more', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(response.status).toBe(200);
expect(Object.keys(response.body).sort()).toEqual(
[
'additionalScopes',
'authenticationContextClassReference',
'clientId',
'clientSecret',
'discoveryEndpoint',
'loginEnabled',
'prompt',
].sort(),
);
});
it('redacts the client secret on read', async () => {
testServer.license.enable('feat:oidc');
await testServer.publicApiAgentFor(owner).put('/settings/sso/oidc').send(validConfig);
const response = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(response.status).toBe(200);
expect(response.body.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
expect(response.body.clientSecret).not.toBe(validConfig.clientSecret);
});
it('rejects with 403 when not licensed', async () => {
const response = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
it('rejects with 403 when the API key lacks the oidc:manage scope', async () => {
testServer.license.enable('feat:oidc');
const scopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] });
const response = await testServer.publicApiAgentFor(scopedOwner).get('/settings/sso/oidc');
expect(response.status).toBe(403);
});
it('rejects with 401 without a valid API key', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer.publicApiAgentWithoutApiKey().get('/settings/sso/oidc');
expect(response.status).toBe(401);
});
});
describe('PUT /settings/sso/oidc', () => {
it('sets the configuration and returns the updated values with the secret redacted', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send(validConfig);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
clientId: validConfig.clientId,
discoveryEndpoint: validConfig.discoveryEndpoint,
prompt: 'consent',
authenticationContextClassReference: ['mfa'],
additionalScopes: 'groups',
});
expect(response.body.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
});
it('takes effect the same way as the UI (write via public API, read via internal API)', async () => {
testServer.license.enable('feat:oidc');
await testServer.publicApiAgentFor(owner).put('/settings/sso/oidc').send(validConfig);
// internal REST responses are wrapped in `{ data }`; the UI client unwraps it.
const internal = await testServer.authAgentFor(owner).get('/sso/oidc/config');
expect(internal.status).toBe(200);
expect(internal.body.data.clientId).toBe(validConfig.clientId);
expect(internal.body.data.discoveryEndpoint).toBe(validConfig.discoveryEndpoint);
expect(internal.body.data.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
});
it('reads back a configuration written through the internal API (public API is a faithful stand-in)', async () => {
testServer.license.enable('feat:oidc');
await testServer.authAgentFor(owner).post('/sso/oidc/config').send(validConfig);
const publicRead = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(publicRead.status).toBe(200);
expect(publicRead.body.clientId).toBe(validConfig.clientId);
expect(publicRead.body.discoveryEndpoint).toBe(validConfig.discoveryEndpoint);
expect(publicRead.body.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
});
it('toggles loginEnabled both ways', async () => {
testServer.license.enable('feat:oidc');
const enabled = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...validConfig, loginEnabled: true });
expect(enabled.status).toBe(200);
expect(enabled.body.loginEnabled).toBe(true);
const disabled = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...validConfig, loginEnabled: false });
expect(disabled.status).toBe(200);
expect(disabled.body.loginEnabled).toBe(false);
});
it('keeps the stored secret when the redacted sentinel is submitted', async () => {
testServer.license.enable('feat:oidc');
await testServer.publicApiAgentFor(owner).put('/settings/sso/oidc').send(validConfig);
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({
...validConfig,
clientId: 'updated-client',
clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE,
});
expect(response.status).toBe(200);
expect(response.body.clientId).toBe('updated-client');
const stored = await Container.get(OidcService).loadConfig(true);
expect(stored.clientSecret).toBe(validConfig.clientSecret);
});
it('rejects a malformed body with 400', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ clientId: 'only-id' });
expect(response.status).toBe(400);
});
it('rejects a partial body missing loginEnabled with 400', async () => {
testServer.license.enable('feat:oidc');
const { loginEnabled: _loginEnabled, ...partial } = validConfig;
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send(partial);
expect(response.status).toBe(400);
});
it('rejects a partial body missing additionalScopes with 400', async () => {
testServer.license.enable('feat:oidc');
const { additionalScopes: _additionalScopes, ...partial } = validConfig;
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send(partial);
expect(response.status).toBe(400);
});
it('accepts a GET response body as a PUT body', async () => {
testServer.license.enable('feat:oidc');
await testServer.publicApiAgentFor(owner).put('/settings/sso/oidc').send(validConfig);
const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(getResponse.status).toBe(200);
expect(getResponse.body.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
const putResponse = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...getResponse.body, loginEnabled: true });
expect(putResponse.status).toBe(200);
expect(putResponse.body.loginEnabled).toBe(true);
expect(putResponse.body.clientSecret).toBe(OIDC_CLIENT_SECRET_REDACTED_VALUE);
// The redacted sentinel round-tripped without clobbering the stored secret.
const stored = await Container.get(OidcService).loadConfig(true);
expect(stored.clientSecret).toBe(validConfig.clientSecret);
});
it('rejects a well-formed body with invalid values with 400', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...validConfig, discoveryEndpoint: 'not-a-url' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message');
});
it('rejects with 401 without a valid API key', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer
.publicApiAgentWithoutApiKey()
.put('/settings/sso/oidc')
.send(validConfig);
expect(response.status).toBe(401);
});
it('rejects with 403 when the API key lacks the oidc:manage scope', async () => {
testServer.license.enable('feat:oidc');
const scopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] });
const response = await testServer
.publicApiAgentFor(scopedOwner)
.put('/settings/sso/oidc')
.send(validConfig);
expect(response.status).toBe(403);
});
it('rejects with 403 when not licensed', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send(validConfig);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
it('refuses the write with 409 when managed declaratively, and still allows reads', async () => {
testServer.license.enable('feat:oidc');
setManagedByEnv(true);
const write = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send(validConfig);
expect(write.status).toBe(409);
expect(write.body).toHaveProperty('message');
const read = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(read.status).toBe(200);
});
it('rejects with 400 instead of 500 when SAML is already the active authentication method', async () => {
testServer.license.enable('feat:oidc');
await Container.get(SamlService).setSamlPreferences({
...sampleSamlConfig,
loginEnabled: true,
});
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...validConfig, loginEnabled: true });
expect(response.status).toBe(400);
expect(response.body.message).toContain('saml');
});
});
});
@@ -252,7 +252,7 @@ describe('Instance owner', () => {
.send({
loginEnabled: true,
})
.expect(500);
.expect(400);
expect(getCurrentAuthenticationMethod()).toBe('ldap');
await setCurrentAuthenticationMethod('saml');
@@ -22,6 +22,7 @@ type EndpointGroup =
| 'community-packages'
| 'ldap'
| 'saml'
| 'oidc'
| 'otel'
| 'sourceControl'
| 'eventBus'
@@ -255,6 +255,13 @@ export const setupTestServer = ({
break;
}
case 'oidc': {
const { OidcService } = await import('@/modules/sso-oidc/oidc.service.ee.js');
await Container.get(OidcService).init();
await import('@/modules/sso-oidc/oidc.controller.ee.js');
break;
}
case 'otel': {
const { OtelService } = await import('@/modules/otel/otel.service.js');
await Container.get(OtelService).init();
@@ -223,6 +223,12 @@
"POST /settings/log-streaming/destinations/{id}/test": {
"status": "gap"
},
"GET /settings/sso/oidc": {
"status": "gap"
},
"PUT /settings/sso/oidc": {
"status": "gap"
},
"POST /variables": {
"status": "gap"
},