feat(core): Configure OIDC settings via env vars (#28185)

Signed-off-by: James Gee <1285296+geemanjs@users.noreply.github.com>
Co-authored-by: Irénée <irenee.ajeneza@n8n.io>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
This commit is contained in:
James Gee 2026-04-14 15:06:22 +02:00 committed by GitHub
parent e849041c11
commit 36261fbe7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 428 additions and 16 deletions

View file

@ -163,7 +163,7 @@ export {
type RoleProjectMembersResponse,
} from './roles/role-project-members-response.dto';
export { OidcConfigDto } from './oidc/config.dto';
export { OidcConfigDto, 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';

View file

@ -2,14 +2,13 @@ import { z } from 'zod';
import { Z } from '../../zod-class';
export const OIDC_PROMPT_VALUES = ['none', 'login', 'consent', 'select_account', 'create'] as const;
export class OidcConfigDto extends Z.class({
clientId: z.string().min(1),
clientSecret: z.string().min(1),
discoveryEndpoint: z.string().url(),
loginEnabled: z.boolean().optional().default(false),
prompt: z
.enum(['none', 'login', 'consent', 'select_account', 'create'])
.optional()
.default('select_account'),
prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'),
authenticationContextClassReference: z.array(z.string()).default([]),
}) {}

View file

@ -131,6 +131,7 @@ export interface FrontendSettings {
defaultLocale: string;
userManagement: IUserManagementSettings;
sso: {
managedByEnv: boolean;
saml: {
loginLabel: string;
loginEnabled: boolean;

View file

@ -27,6 +27,38 @@ export class InstanceSettingsLoaderConfig {
@Env('N8N_INSTANCE_OWNER_PASSWORD_HASH')
ownerPasswordHash: string = '';
// --- SSO ---
/** When true, SSO connection config is read from env vars on every startup and the UI is locked. */
@Env('N8N_SSO_MANAGED_BY_ENV')
ssoManagedByEnv: boolean = false;
/** User role provisioning mode: disabled, instance_role, or instance_and_project_roles. */
@Env('N8N_SSO_USER_ROLE_PROVISIONING')
ssoUserRoleProvisioning: string = 'disabled';
// --- OIDC ---
@Env('N8N_SSO_OIDC_CLIENT_ID')
oidcClientId: string = '';
@Env('N8N_SSO_OIDC_CLIENT_SECRET')
oidcClientSecret: string = '';
@Env('N8N_SSO_OIDC_DISCOVERY_ENDPOINT')
oidcDiscoveryEndpoint: string = '';
@Env('N8N_SSO_OIDC_LOGIN_ENABLED')
oidcLoginEnabled: boolean = false;
/** Values can be found in packages/@n8n/api-types/src/dto/oidc/config.dto.ts */
@Env('N8N_SSO_OIDC_PROMPT')
oidcPrompt: string = 'select_account';
/** Comma-separated ACR values */
@Env('N8N_SSO_OIDC_ACR_VALUES')
oidcAcrValues: string = '';
/**
* When true, security policy settings are managed via environment variables.
* On every startup the security policy will be overridden by env vars.

View file

@ -503,6 +503,14 @@ describe('GlobalConfig', () => {
ownerFirstName: 'Instance',
ownerLastName: 'Owner',
ownerPasswordHash: '',
ssoManagedByEnv: false,
oidcClientId: '',
oidcClientSecret: '',
oidcDiscoveryEndpoint: '',
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
ssoUserRoleProvisioning: 'disabled',
securityPolicyManagedByEnv: false,
mfaEnforcedEnabled: false,
personalSpacePublishingEnabled: true,

View file

@ -0,0 +1,103 @@
import { mock } from 'jest-mock-extended';
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import type { SettingsRepository } from '@n8n/db';
import type { Cipher } from 'n8n-core';
import { OidcInstanceSettingsLoader } from '../loaders/oidc.instance-settings-loader';
describe('OidcInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const settingsRepository = mock<SettingsRepository>();
const cipher = mock<Cipher>();
const validConfig: Partial<InstanceSettingsLoaderConfig> = {
ssoManagedByEnv: true,
oidcClientId: 'my-client-id',
oidcClientSecret: 'my-client-secret',
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
ssoUserRoleProvisioning: 'disabled',
};
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = {
ssoManagedByEnv: false,
oidcClientId: '',
oidcClientSecret: '',
oidcDiscoveryEndpoint: '',
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
ssoUserRoleProvisioning: 'disabled',
...configOverrides,
} as InstanceSettingsLoaderConfig;
return new OidcInstanceSettingsLoader(config, settingsRepository, cipher, logger);
};
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
cipher.encrypt.mockReturnValue('encrypted-secret');
});
it('should skip when ssoManagedByEnv is false', async () => {
const loader = createLoader({ ssoManagedByEnv: false });
const result = await loader.run();
expect(result).toBe('skipped');
expect(settingsRepository.upsert).not.toHaveBeenCalled();
});
it('should throw when clientId is missing', async () => {
const loader = createLoader({ ...validConfig, oidcClientId: '' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_ID is required');
});
it('should throw when clientSecret is missing', async () => {
const loader = createLoader({ ...validConfig, oidcClientSecret: '' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_SECRET is required');
});
it('should throw when discoveryEndpoint is not a valid URL', async () => {
const loader = createLoader({ ...validConfig, oidcDiscoveryEndpoint: 'not-a-url' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_DISCOVERY_ENDPOINT');
});
it('should throw when oidcPrompt is an invalid value', async () => {
const loader = createLoader({ ...validConfig, oidcPrompt: 'invalid' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_PROMPT');
});
it('should throw when ssoUserRoleProvisioning is an invalid value', async () => {
const loader = createLoader({ ...validConfig, ssoUserRoleProvisioning: 'invalid' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_USER_ROLE_PROVISIONING must be one of');
});
it('should handle messy ACR values with extra commas and whitespace', async () => {
const loader = createLoader({ ...validConfig, oidcAcrValues: ',mfa,, phrh ,,' });
await loader.run();
const savedValue = JSON.parse(
(settingsRepository.upsert.mock.calls[0][0] as { value: string }).value,
);
expect(savedValue.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
});
describe('isConfiguredByEnv', () => {
it('should return false when ssoManagedByEnv is false', () => {
const loader = createLoader({ ssoManagedByEnv: false });
expect(loader.isConfiguredByEnv()).toBe(false);
});
it('should return true when ssoManagedByEnv is true', () => {
const loader = createLoader({ ssoManagedByEnv: true });
expect(loader.isConfiguredByEnv()).toBe(true);
});
});
});

View file

@ -0,0 +1,8 @@
import { UnexpectedError } from 'n8n-workflow';
/**
* Error thrown during instance bootstrapping when environment variable
* configuration is invalid. These errors indicate a misconfiguration
* that must be fixed before the instance can start.
*/
export class InstanceBootstrappingError extends UnexpectedError {}

View file

@ -1,6 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { OidcInstanceSettingsLoader } from './loaders/oidc.instance-settings-loader';
import { OwnerInstanceSettingsLoader } from './loaders/owner.instance-settings-loader';
import { SecurityPolicyInstanceSettingsLoader } from './loaders/security-policy.instance-settings-loader';
@ -11,6 +12,7 @@ export class InstanceSettingsLoaderService {
constructor(
private logger: Logger,
private readonly ownerLoader: OwnerInstanceSettingsLoader,
private readonly oidcLoader: OidcInstanceSettingsLoader,
private readonly securityPolicyLoader: SecurityPolicyInstanceSettingsLoader,
) {
this.logger = this.logger.scoped('instance-settings-loader');
@ -18,6 +20,7 @@ export class InstanceSettingsLoaderService {
async init(): Promise<void> {
await this.run('owner', async () => await this.ownerLoader.run());
await this.run('oidc', async () => await this.oidcLoader.run());
await this.run('security-policy', async () => await this.securityPolicyLoader.run());
}

View file

@ -0,0 +1,126 @@
import { OIDC_PROMPT_VALUES } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { z } from 'zod';
import { InstanceBootstrappingError } from '../instance-bootstrapping.error';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
const PROVISIONING_MODES = ['disabled', 'instance_role', 'instance_and_project_roles'] as const;
const ssoEnvSchema = z
.object({
oidcClientId: z
.string()
.min(1, 'N8N_SSO_OIDC_CLIENT_ID is required when configuring OIDC via environment variables'),
oidcClientSecret: z
.string()
.min(
1,
'N8N_SSO_OIDC_CLIENT_SECRET is required when configuring OIDC via environment variables',
),
oidcDiscoveryEndpoint: z.string().url('N8N_SSO_OIDC_DISCOVERY_ENDPOINT must be a valid URL'),
oidcLoginEnabled: z.boolean(),
oidcPrompt: z.enum(OIDC_PROMPT_VALUES, {
errorMap: () => ({
message: `N8N_SSO_OIDC_PROMPT must be one of: ${OIDC_PROMPT_VALUES.join(', ')}`,
}),
}),
oidcAcrValues: z.string(),
ssoUserRoleProvisioning: z.enum(PROVISIONING_MODES, {
errorMap: () => ({
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${PROVISIONING_MODES.join(', ')}`,
}),
}),
})
.transform((input) => ({
oidc: {
clientId: input.oidcClientId,
clientSecret: input.oidcClientSecret,
discoveryEndpoint: input.oidcDiscoveryEndpoint,
loginEnabled: input.oidcLoginEnabled,
prompt: input.oidcPrompt,
authenticationContextClassReference: input.oidcAcrValues
? input.oidcAcrValues
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: [],
},
provisioning: {
scopesProvisionInstanceRole:
input.ssoUserRoleProvisioning === 'instance_role' ||
input.ssoUserRoleProvisioning === 'instance_and_project_roles',
scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles',
},
}));
@Service()
export class OidcInstanceSettingsLoader {
constructor(
private readonly instanceSettingsLoaderConfig: InstanceSettingsLoaderConfig,
private readonly settingsRepository: SettingsRepository,
private readonly cipher: Cipher,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
isConfiguredByEnv(): boolean {
return this.instanceSettingsLoaderConfig.ssoManagedByEnv;
}
async run(): Promise<'created' | 'skipped'> {
const { ssoManagedByEnv, oidcClientId, oidcClientSecret, oidcDiscoveryEndpoint } =
this.instanceSettingsLoaderConfig;
if (!ssoManagedByEnv) {
if (oidcClientId || oidcClientSecret || oidcDiscoveryEndpoint) {
this.logger.warn(
'N8N_SSO_OIDC_* env vars are set but N8N_SSO_MANAGED_BY_ENV is not enabled — ignoring SSO env vars',
);
}
return 'skipped';
}
this.logger.info('N8N_SSO_MANAGED_BY_ENV is enabled — applying OIDC SSO env vars');
const result = ssoEnvSchema.safeParse(this.instanceSettingsLoaderConfig);
if (!result.success) {
throw new InstanceBootstrappingError(result.error.issues[0].message);
}
const { oidc, provisioning } = result.data;
await this.settingsRepository.upsert(
{
key: OIDC_PREFERENCES_DB_KEY,
value: JSON.stringify({
...oidc,
clientSecret: this.cipher.encrypt(oidc.clientSecret),
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
await this.settingsRepository.upsert(
{
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(provisioning),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
this.logger.debug('OIDC configuration applied from environment variables');
return 'created';
}
}

View file

@ -1,9 +1,10 @@
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { OperationalError } from 'n8n-workflow';
import { z } from 'zod';
import { InstanceBootstrappingError } from '../instance-bootstrapping.error';
import { OwnershipService } from '@/services/ownership.service';
/** Bcrypt hash format: $2b$NN$<53 base64 chars> (60 chars total) */
@ -63,7 +64,7 @@ export class OwnerInstanceSettingsLoader {
const result = ownerEnvSchema.safeParse(this.instanceSettingsLoaderConfig);
if (!result.success) {
throw new OperationalError(result.error.issues[0].message);
throw new InstanceBootstrappingError(result.error.issues[0].message);
}
await this.ownershipService.setupOwner(result.data.payload, result.data.options);

View file

@ -3,14 +3,20 @@ import { mock } from 'jest-mock-extended';
import { ProvisioningController } from '../provisioning.controller.ee';
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import type { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
import { type Response } from 'express';
import { type AuthenticatedRequest } from '@n8n/db';
import { type ProvisioningConfigDto } from '@n8n/api-types';
const provisioningService = mock<ProvisioningService>();
const licenseState = mock<LicenseState>();
const oidcSettingsLoader = mock<OidcInstanceSettingsLoader>();
const controller = new ProvisioningController(provisioningService, licenseState);
const controller = new ProvisioningController(
provisioningService,
licenseState,
oidcSettingsLoader,
);
describe('ProvisioningController', () => {
beforeEach(() => {

View file

@ -1,6 +1,10 @@
import { AuthenticatedRequest } from '@n8n/db';
import { Get, GlobalScope, Patch, RestController } from '@n8n/decorators';
import { LicenseState } from '@n8n/backend-common';
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ProvisioningService } from './provisioning.service.ee';
import { Response } from 'express';
@ -9,6 +13,7 @@ export class ProvisioningController {
constructor(
private readonly provisioningService: ProvisioningService,
private readonly licenseState: LicenseState,
private readonly oidcSettingsLoader: OidcInstanceSettingsLoader,
) {}
@Get('/config')
@ -28,6 +33,12 @@ export class ProvisioningController {
return res.status(403).json({ message: 'Provisioning is not licensed' });
}
if (this.oidcSettingsLoader.isConfiguredByEnv()) {
throw new BadRequestError(
'Provisioning configuration is managed via environment variables and cannot be modified through the UI',
);
}
return await this.provisioningService.patchConfig(req.body);
}
}

View file

@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended';
import type { AuthService } from '@/auth/auth.service';
import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants';
import type { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
import type { AuthlessRequest } from '@/requests';
import type { UrlService } from '@/services/url.service';
@ -18,7 +19,15 @@ const oidcService = mock<OidcService>();
const urlService = mock<UrlService>();
const globalConfig = mock<GlobalConfig>();
const logger = mock<Logger>();
const controller = new OidcController(oidcService, authService, urlService, globalConfig, logger);
const oidcSettingsLoader = mock<OidcInstanceSettingsLoader>();
const controller = new OidcController(
oidcService,
authService,
urlService,
globalConfig,
logger,
oidcSettingsLoader,
);
const user = mock<User>({
id: '456',

View file

@ -9,6 +9,7 @@ import { Request, Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
import { AuthlessRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
@ -24,6 +25,7 @@ export class OidcController {
private readonly urlService: UrlService,
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
private readonly oidcSettingsLoader: OidcInstanceSettingsLoader,
) {}
@Get('/config')
@ -45,6 +47,11 @@ export class OidcController {
_res: Response,
@Body payload: OidcConfigDto,
) {
if (this.oidcSettingsLoader.isConfiguredByEnv()) {
throw new BadRequestError(
'OIDC configuration is managed via environment variables and cannot be modified through the UI',
);
}
await this.oidcService.updateConfig(payload);
const config = this.oidcService.getRedactedConfig();
return config;

View file

@ -251,6 +251,7 @@ export class FrontendService {
authenticationMethod: getCurrentAuthenticationMethod(),
},
sso: {
managedByEnv: this.globalConfig.instanceSettingsLoader.ssoManagedByEnv,
saml: {
loginEnabled: false,
loginLabel: '',

View file

@ -0,0 +1,67 @@
import { testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
beforeAll(async () => {
await testDb.init();
});
afterAll(async () => {
await testDb.terminate();
});
describe('OidcInstanceSettingsLoader → OidcService roundtrip', () => {
let originalConfig: Record<string, unknown>;
beforeEach(() => {
const globalConfig = Container.get(GlobalConfig);
const loader = globalConfig.instanceSettingsLoader;
originalConfig = { ...loader };
});
afterEach(async () => {
// Restore original config
const globalConfig = Container.get(GlobalConfig);
Object.assign(globalConfig.instanceSettingsLoader, originalConfig);
// Clean up DB rows
const settingsRepository = Container.get(SettingsRepository);
await settingsRepository.delete({ key: OIDC_PREFERENCES_DB_KEY });
await settingsRepository.delete({ key: PROVISIONING_PREFERENCES_DB_KEY });
});
it('should write config that OidcService reads back with correct values', async () => {
const globalConfig = Container.get(GlobalConfig);
Object.assign(globalConfig.instanceSettingsLoader, {
ssoManagedByEnv: true,
oidcClientId: 'my-client-id',
oidcClientSecret: 'my-actual-secret',
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
oidcLoginEnabled: true,
oidcPrompt: 'consent',
oidcAcrValues: 'mfa, phrh',
ssoUserRoleProvisioning: 'instance_and_project_roles',
});
const loader = Container.get(OidcInstanceSettingsLoader);
await loader.run();
const oidcService = Container.get(OidcService);
const config = await oidcService.loadConfig(true);
expect(config.clientId).toBe('my-client-id');
expect(config.clientSecret).toBe('my-actual-secret');
expect(config.discoveryEndpoint.toString()).toBe(
'https://idp.example.com/.well-known/openid-configuration',
);
expect(config.loginEnabled).toBe(true);
expect(config.prompt).toBe('consent');
expect(config.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
});
});

View file

@ -4392,6 +4392,7 @@
"settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)",
"settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)",
"settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)",
"settings.sso.settings.oidc.overrideBanner": "OIDC connection is configured via environment variables. To modify, update the environment variables and restart n8n.",
"settings.sso.settings.userRoleProvisioning.label": "User role provisioning",
"settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.",
"settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs",

View file

@ -88,6 +88,7 @@ export const defaultSettings: FrontendSettings = {
saveManualExecutions: false,
saveExecutionProgress: false,
sso: {
managedByEnv: false,
ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' },
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },

View file

@ -140,14 +140,15 @@ describe('Init', () => {
const oidc = { loginEnabled: false, loginUrl: '', callbackUrl: '' };
settingsStore.userManagement.authenticationMethod = UserManagementAuthenticationMethod.Saml;
settingsStore.settings.sso = { saml, ldap, oidc };
settingsStore.settings.sso = { managedByEnv: false, saml, ldap, oidc };
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml] = true;
await initializeCore();
expect(ssoStore.initialize).toHaveBeenCalledWith({
authenticationMethod: UserManagementAuthenticationMethod.Saml,
config: { saml, ldap, oidc },
managedByEnv: false,
config: { managedByEnv: false, saml, ldap, oidc },
features: {
saml: true,
ldap: false,

View file

@ -75,6 +75,7 @@ export async function initializeCore() {
ssoStore.initialize({
authenticationMethod: settingsStore.userManagement
.authenticationMethod as UserManagementAuthenticationMethod,
managedByEnv: settingsStore.settings.sso.managedByEnv,
config: settingsStore.settings.sso,
features: {
saml: settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],

View file

@ -24,6 +24,7 @@ const message = useMessage();
const savingForm = ref<boolean>(false);
const roleMappingRuleEditorRef = ref<InstanceType<typeof RoleMappingRuleEditor> | null>(null);
const isOverrideActive = computed(() => ssoStore.ssoManagedByEnv);
const discoveryEndpoint = ref('');
const clientId = ref('');
@ -217,7 +218,9 @@ const onTest = async () => {
}
};
const hasUnsavedChanges = computed(() => !cannotSaveOidcSettings.value && !savingForm.value);
const hasUnsavedChanges = computed(
() => !cannotSaveOidcSettings.value && !savingForm.value && !isOverrideActive.value,
);
defineExpose({ hasUnsavedChanges, onSave: onOidcSettingsSave });
@ -242,6 +245,7 @@ onMounted(async () => {
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
:disabled="isOverrideActive"
type="text"
data-test-id="oidc-discovery-endpoint"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@ -253,6 +257,7 @@ onMounted(async () => {
<label>Client ID</label>
<N8nInput
:model-value="clientId"
:disabled="isOverrideActive"
type="text"
data-test-id="oidc-client-id"
@update:model-value="(v: string) => (clientId = v)"
@ -265,6 +270,7 @@ onMounted(async () => {
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
:disabled="isOverrideActive"
type="password"
data-test-id="oidc-client-secret"
@update:model-value="(v: string) => (clientSecret = v)"
@ -278,6 +284,7 @@ onMounted(async () => {
<label>Prompt</label>
<N8nSelect
:model-value="prompt"
:disabled="isOverrideActive"
data-test-id="oidc-prompt"
@update:model-value="handlePromptChange"
>
@ -298,6 +305,7 @@ onMounted(async () => {
v-model:mapping-method="mappingMethod"
v-model:legacy-value="userRoleProvisioning"
auth-protocol="oidc"
:disabled="isOverrideActive"
/>
<RoleMappingRuleEditor
v-if="mappingMethod === 'rules_in_n8n'"
@ -316,6 +324,7 @@ onMounted(async () => {
<N8nInput
:model-value="authenticationContextClassReference"
type="textarea"
:disabled="isOverrideActive"
data-test-id="oidc-authentication-context-class-reference"
placeholder="mfa, phrh, pwd"
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
@ -337,6 +346,7 @@ onMounted(async () => {
:model-value="ssoStore.isOidcLoginEnabled ? 'enabled' : 'disabled'"
size="medium"
data-test-id="sso-oidc-toggle"
:disabled="isOverrideActive"
@update:model-value="ssoStore.isOidcLoginEnabled = $event === 'enabled'"
>
<template #prefix>
@ -351,6 +361,7 @@ onMounted(async () => {
<div :class="$style.buttons">
<N8nButton
v-if="!isOverrideActive"
data-test-id="sso-oidc-save"
size="large"
:loading="savingForm"

View file

@ -26,8 +26,9 @@ const legacyValue = defineModel<UserRoleProvisioningSetting>('legacyValue', {
default: 'disabled',
});
const { authProtocol } = defineProps<{
const { authProtocol, disabled = false } = defineProps<{
authProtocol: SupportedProtocolType;
disabled?: boolean;
}>();
const i18n = useI18n();
@ -107,7 +108,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
<div :class="shared.settingsItemControl">
<N8nSelect
:model-value="legacyValue"
:disabled="!canManage"
:disabled="disabled || !canManage"
data-test-id="oidc-user-role-provisioning"
@update:model-value="legacyValue = $event as UserRoleProvisioningSetting"
>
@ -133,7 +134,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
<N8nSelect
v-model="roleAssignment"
size="medium"
:disabled="!canManage"
:disabled="disabled || !canManage"
data-test-id="role-assignment-select"
>
<N8nOption
@ -160,7 +161,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
<N8nSelect
v-model="mappingMethod"
size="medium"
:disabled="!canManage"
:disabled="disabled || !canManage"
data-test-id="role-mapping-method-select"
>
<N8nOption

View file

@ -37,6 +37,7 @@ export const useSSOStore = defineStore('sso', () => {
const initialize = (options: {
authenticationMethod: UserManagementAuthenticationMethod;
managedByEnv?: boolean;
config: {
ldap?: Pick<LdapConfig, 'loginLabel' | 'loginEnabled'>;
saml?: Pick<SamlPreferences, 'loginLabel' | 'loginEnabled'>;
@ -52,6 +53,7 @@ export const useSSOStore = defineStore('sso', () => {
};
}) => {
authenticationMethod.value = options.authenticationMethod;
ssoManagedByEnv.value = options.managedByEnv ?? false;
isEnterpriseLdapEnabled.value = options.features.ldap;
if (options.config.ldap) {
@ -132,6 +134,8 @@ export const useSSOStore = defineStore('sso', () => {
const isEnterpriseOidcEnabled = ref(false);
const ssoManagedByEnv = ref(false);
const getOidcConfig = async () => {
const config = await ssoApi.getOidcConfig(rootStore.restApiContext);
oidcConfig.value = config;
@ -224,6 +228,7 @@ export const useSSOStore = defineStore('sso', () => {
oidc,
oidcConfig,
ssoManagedByEnv,
isOidcLoginEnabled,
isEnterpriseOidcEnabled,
isDefaultAuthenticationOidc,

View file

@ -10,6 +10,7 @@ import { ElDialog } from 'element-plus';
import {
N8nActionBox,
N8nButton,
N8nCallout,
N8nHeading,
N8nOption,
N8nSelect,
@ -111,6 +112,13 @@ onMounted(() => {
{{ i18n.baseText('settings.sso.info.link') }}
</a>
</p>
<N8nCallout
v-if="ssoStore.ssoManagedByEnv"
theme="warning"
style="margin-bottom: var(--spacing--lg)"
>
{{ i18n.baseText('settings.sso.settings.oidc.overrideBanner') }}
</N8nCallout>
<!-- Protocol selector rendered independently outside form v-ifs for E2E timing -->
<div v-if="hasAnySsoEnabled" :class="[shared.card, $style.protocolCard]">
<div data-test-id="sso-auth-protocol-select" :class="shared.settingsItem">
@ -122,6 +130,7 @@ onMounted(() => {
<N8nSelect
filterable
size="medium"
:disabled="ssoStore.ssoManagedByEnv"
:model-value="authProtocol"
:placeholder="i18n.baseText('parameterInput.select')"
@update:model-value="onAuthProtocolUpdated"