mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(API): Add public API endpoints for OIDC SSO configuration (#34313)
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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'
|
||||
+55
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user