From 02e736ef4eabfbeec649ff4e370f68bed66785e5 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Tue, 28 Jul 2026 13:58:55 +0300 Subject: [PATCH] feat(API): Add public API endpoints for OIDC SSO configuration (#34313) --- packages/@n8n/api-types/src/dto/index.ts | 2 +- .../src/dto/oidc/__tests__/config.dto.test.ts | 32 ++ .../@n8n/api-types/src/dto/oidc/config.dto.ts | 12 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/public-api-permissions.ee.ts | 1 + .../ldap.ee/__tests__/ldap.service.test.ts | 4 +- .../src/modules/ldap.ee/ldap.service.ee.ts | 20 +- .../src/modules/sso-oidc/oidc.service.ee.ts | 21 +- .../cli/src/modules/sso-saml/saml-helpers.ts | 10 +- packages/cli/src/public-api/types.ts | 10 + .../sso-oidc/spec/paths/settings.sso.oidc.yml | 58 +++ .../schemas/oidc-configuration.update.yml | 55 +++ .../spec/schemas/oidc-configuration.yml | 49 +++ .../v1/handlers/sso-oidc/sso-oidc.handler.ts | 57 +++ .../v1/handlers/sso-oidc/sso-oidc.mapper.ts | 14 + packages/cli/src/public-api/v1/openapi.yml | 4 + packages/cli/src/sso.ee/sso-helpers.ts | 13 + .../test/integration/public-api/ldap.test.ts | 2 +- .../integration/public-api/sso-oidc.test.ts | 365 ++++++++++++++++++ .../test/integration/saml/saml.api.test.ts | 2 +- packages/cli/test/integration/shared/types.ts | 1 + .../integration/shared/utils/test-server.ts | 7 + .../nodes/N8n/n8n-api-coverage.json | 6 + 23 files changed, 703 insertions(+), 43 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/oidc/__tests__/config.dto.test.ts create mode 100644 packages/cli/src/public-api/v1/handlers/sso-oidc/spec/paths/settings.sso.oidc.yml create mode 100644 packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.update.yml create mode 100644 packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.yml create mode 100644 packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts create mode 100644 packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.mapper.ts create mode 100644 packages/cli/test/integration/public-api/sso-oidc.test.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index d774ddaad82..f580bed84e2 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/oidc/__tests__/config.dto.test.ts b/packages/@n8n/api-types/src/dto/oidc/__tests__/config.dto.test.ts new file mode 100644 index 00000000000..a859e2ac10d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/oidc/__tests__/config.dto.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts index 1323cd5008e..1be723168f3 100644 --- a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts +++ b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 241c7b9cefa..5fa4e40b7be 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 7f420b35bc1..77e76a25df7 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -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', diff --git a/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts b/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts index c318e27a20a..292bb9d6812 100644 --- a/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts +++ b/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts @@ -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)', ); }); diff --git a/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts b/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts index af8fcbd4648..f32592960af 100644 --- a/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts +++ b/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts @@ -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 { 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 { /** Set the LDAP login enabled to the configuration object */ private async setLdapLoginEnabled(enabled: boolean): Promise { 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; diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index 4a44b794e1d..3e468073077 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -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 { 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 = diff --git a/packages/cli/src/modules/sso-saml/saml-helpers.ts b/packages/cli/src/modules/sso-saml/saml-helpers.ts index 66ed77b534d..c3bed4ffc65 100644 --- a/packages/cli/src/modules/sso-saml/saml-helpers.ts +++ b/packages/cli/src/modules/sso-saml/saml-helpers.ts @@ -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 { 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 = diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 105efd43650..64c41683868 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -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>; +} diff --git a/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/paths/settings.sso.oidc.yml b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/paths/settings.sso.oidc.yml new file mode 100644 index 00000000000..c7244a39449 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/paths/settings.sso.oidc.yml @@ -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' diff --git a/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.update.yml b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.update.yml new file mode 100644 index 00000000000..e6f34d7ed43 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.update.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 diff --git a/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.yml b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.yml new file mode 100644 index 00000000000..736c7641b73 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/sso-oidc/spec/schemas/oidc-configuration.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts b/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts new file mode 100644 index 00000000000..b4f0f2c0d09 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts @@ -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; + setOidcConfiguration: PublicAPIEndpoint; +}; + +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; diff --git a/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.mapper.ts b/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.mapper.ts new file mode 100644 index 00000000000..b301fec831e --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/sso-oidc/sso-oidc.mapper.ts @@ -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>; + +export function toOidcConfigurationResponse(config: OidcRuntimeConfig): OidcConfigDto { + return { + ...config, + discoveryEndpoint: config.discoveryEndpoint.toString(), + clientSecret: config.clientSecret ? OIDC_CLIENT_SECRET_REDACTED_VALUE : config.clientSecret, + }; +} diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index c551b6a59c3..27e4287c172 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -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: diff --git a/packages/cli/src/sso.ee/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts index 31d5f96d9a9..2b146a462ee 100644 --- a/packages/cli/src/sso.ee/sso-helpers.ts +++ b/packages/cli/src/sso.ee/sso-helpers.ts @@ -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, +): 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; } diff --git a/packages/cli/test/integration/public-api/ldap.test.ts b/packages/cli/test/integration/public-api/ldap.test.ts index f691e26b9f7..87cb624f2ce 100644 --- a/packages/cli/test/integration/public-api/ldap.test.ts +++ b/packages/cli/test/integration/public-api/ldap.test.ts @@ -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', ); }, ); diff --git a/packages/cli/test/integration/public-api/sso-oidc.test.ts b/packages/cli/test/integration/public-api/sso-oidc.test.ts new file mode 100644 index 00000000000..800fd574254 --- /dev/null +++ b/packages/cli/test/integration/public-api/sso-oidc.test.ts @@ -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(); + 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'); + }); + }); +}); diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index 45d2811815d..5754dd9e295 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -252,7 +252,7 @@ describe('Instance owner', () => { .send({ loginEnabled: true, }) - .expect(500); + .expect(400); expect(getCurrentAuthenticationMethod()).toBe('ldap'); await setCurrentAuthenticationMethod('saml'); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index da1ffdfa112..ae19c10d123 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -22,6 +22,7 @@ type EndpointGroup = | 'community-packages' | 'ldap' | 'saml' + | 'oidc' | 'otel' | 'sourceControl' | 'eventBus' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 048df3e8ae4..085817e7df1 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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(); diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index dd028dcfff8..36aa83e23cd 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -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" },