feat(core): Add OIDC support for SSO (#15988)

Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-06-13 10:18:14 -04:00
committed by GitHub
parent 0d5ac1f822
commit 30148df7f3
40 changed files with 1358 additions and 197 deletions

View File

@@ -101,7 +101,7 @@ const onUserAction = (user: IUser, action: string) =>
<N8nActionToggle
v-if="
!user.isOwner &&
user.signInType !== 'ldap' &&
!['ldap'].includes(user.signInType) &&
!readonly &&
getActions(user).length > 0 &&
actions.length > 0

View File

@@ -929,6 +929,7 @@
"forgotPassword.returnToSignIn": "Back to sign in",
"forgotPassword.sendingEmailError": "Problem sending email",
"forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password",
"forgotPassword.oidcUserPasswordResetUnavailable": "Please contact your OIDC administrator to reset your password",
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
"forgotPassword.tooManyRequests": "Youve reached the password reset limit. Please try again in a few minutes.",
"forms.resourceFiltersDropdown.filters": "Filters",
@@ -2716,6 +2717,7 @@
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "Yes, disable it",
"settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?",
"settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.",
"settings.ldap.disabled.title": "Available on the Enterprise plan",
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.",
"settings.ldap.disabled.buttonText": "See plans",
@@ -2775,8 +2777,8 @@
"settings.sso": "SSO",
"settings.sso.title": "Single Sign On",
"settings.sso.subtitle": "SAML 2.0 Configuration",
"settings.sso.info": "Activate SAML SSO to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
"settings.sso.info.link": "Learn how to configure SAML 2.0.",
"settings.sso.info": "Activate SAML or OIDC to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
"settings.sso.info.link": "Learn how to configure SAML or OIDC.",
"settings.sso.activation.tooltip": "You need to save the settings first before activating SAML",
"settings.sso.activated": "Activated",
"settings.sso.deactivated": "Deactivated",
@@ -2804,6 +2806,8 @@
"settings.sso.actionBox.title": "Available on the Enterprise plan",
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans",
"settings.oidc.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable OIDC login?",
"settings.oidc.confirmMessage.beforeSaveForm.message": "If you do so, all OIDC users will be converted to email users.",
"settings.mfa.secret": "Secret {secret}",
"settings.mfa": "MFA",
"settings.mfa.title": "Multi-factor Authentication",

View File

@@ -1,4 +1,4 @@
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import type { OidcConfigDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
@@ -39,3 +39,18 @@ export const toggleSamlConfig = async (
export const testSamlConfig = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test');
};
export const getOidcConfig = async (context: IRestApiContext): Promise<OidcConfigDto> => {
return await makeRestApiRequest(context, 'GET', '/sso/oidc/config');
};
export const saveOidcConfig = async (
context: IRestApiContext,
data: OidcConfigDto,
): Promise<OidcConfigDto> => {
return await makeRestApiRequest(context, 'POST', '/sso/oidc/config', data);
};
export const initOidcLogin = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/oidc/login');
};

View File

@@ -608,6 +608,7 @@ export const enum UserManagementAuthenticationMethod {
Email = 'email',
Ldap = 'ldap',
Saml = 'saml',
Oidc = 'oidc',
}
export interface IPermissionGroup {
@@ -1423,6 +1424,7 @@ export type EnterpriseEditionFeatureKey =
| 'LogStreaming'
| 'Variables'
| 'Saml'
| 'Oidc'
| 'SourceControl'
| 'ExternalSecrets'
| 'AuditLogs'

View File

@@ -24,6 +24,7 @@ export const defaultSettings: FrontendSettings = {
enterprise: {
sharing: false,
ldap: false,
oidc: false,
saml: false,
logStreaming: false,
debugInEditor: false,
@@ -78,6 +79,7 @@ export const defaultSettings: FrontendSettings = {
sso: {
ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' },
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },
},
telemetry: {
enabled: false,

View File

@@ -220,6 +220,7 @@ export function createMockEnterpriseSettings(
sharing: false,
ldap: false,
saml: false,
oidc: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,

View File

@@ -2,14 +2,19 @@
import { useSSOStore } from '@/stores/sso.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
const i18n = useI18n();
const ssoStore = useSSOStore();
const toast = useToast();
const settingsStore = useSettingsStore();
const onSSOLogin = async () => {
try {
window.location.href = await ssoStore.getSSORedirectUrl();
const redirectUrl = ssoStore.isDefaultAuthenticationSaml
? await ssoStore.getSSORedirectUrl()
: settingsStore.settings.sso.oidc.loginUrl;
window.location.href = redirectUrl;
} catch (error) {
toast.showError(error, 'Error', error.message);
}

View File

@@ -644,6 +644,7 @@ export const EnterpriseEditionFeature: Record<
LogStreaming: 'logStreaming',
Variables: 'variables',
Saml: 'saml',
Oidc: 'oidc',
SourceControl: 'sourceControl',
ExternalSecrets: 'externalSecrets',
AuditLogs: 'auditLogs',
@@ -698,6 +699,7 @@ export const CURL_IMPORT_NODES_PROTOCOLS: { [key: string]: string } = {
export const enum SignInType {
LDAP = 'ldap',
EMAIL = 'email',
OIDC = 'oidc',
}
export const N8N_SALES_EMAIL = 'sales@n8n.io';

View File

@@ -17,6 +17,7 @@ describe('permissions', () => {
ldap: {},
license: {},
logStreaming: {},
oidc: {},
orchestration: {},
project: {},
saml: {},
@@ -93,6 +94,7 @@ describe('permissions', () => {
read: true,
},
saml: {},
oidc: {},
securityAudit: {},
sourceControl: {},
tag: {

View File

@@ -33,6 +33,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
license: {},
logStreaming: {},
saml: {},
oidc: {},
securityAudit: {},
folder: {},
insights: {},

View File

@@ -46,6 +46,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
});
const ldap = ref({ loginLabel: '', loginEnabled: false });
const saml = ref({ loginLabel: '', loginEnabled: false });
const oidc = ref({ loginEnabled: false, loginUrl: '', callbackUrl: '' });
const mfa = ref({ enabled: false });
const folders = ref({ enabled: false });
@@ -95,6 +96,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isSamlLoginEnabled = computed(() => saml.value.loginEnabled);
const isOidcLoginEnabled = computed(() => oidc.value.loginEnabled);
const oidcCallBackUrl = computed(() => oidc.value.callbackUrl);
const isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled);
const isAskAiEnabled = computed(() => settings.value.askAi?.enabled);
@@ -181,6 +186,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml,
);
const isDefaultAuthenticationOidc = computed(
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Oidc,
);
const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []);
const isBelowUserQuota = computed(
@@ -210,6 +219,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
saml.value.loginLabel = settings.value.sso.saml.loginLabel;
}
if (settings.value.sso?.oidc) {
oidc.value.loginEnabled = settings.value.sso.oidc.loginEnabled;
oidc.value.loginUrl = settings.value.sso.oidc.loginUrl || '';
oidc.value.callbackUrl = settings.value.sso.oidc.callbackUrl || '';
}
mfa.value.enabled = settings.value.mfa?.enabled;
folders.value.enabled = settings.value.folders?.enabled;
@@ -420,6 +435,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isLdapLoginEnabled,
ldapLoginLabel,
isSamlLoginEnabled,
isOidcLoginEnabled,
showSetupPage,
deploymentType,
isCloudDeployment,
@@ -444,6 +460,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isMultiMain,
isWorkerViewAvailable,
isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
workflowCallerPolicyDefaultOption,
permanentlyDismissedBanners,
isBelowUserQuota,
@@ -456,6 +473,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAiCreditsEnabled,
aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas,
partialExecutionVersion,
oidcCallBackUrl,
reset,
testLdapConnection,
getLdapConfig,
@@ -470,6 +489,5 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
getSettings,
setSettings,
initialize,
partialExecutionVersion,
};
});

View File

@@ -1,4 +1,5 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { OidcConfigDto } from '@n8n/api-types';
import { type SamlPreferences } from '@n8n/api-types';
import { computed, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { defineStore } from 'pinia';
@@ -17,17 +18,13 @@ export const useSSOStore = defineStore('sso', () => {
const route = useRoute();
const state = reactive({
loading: false,
samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined,
oidcConfig: undefined as OidcConfigDto | undefined,
});
const isLoading = computed(() => state.loading);
const samlConfig = computed(() => state.samlConfig);
const setLoading = (loading: boolean) => {
state.loading = loading;
};
const oidcConfig = computed(() => state.oidcConfig);
const isSamlLoginEnabled = computed({
get: () => settingsStore.isSamlLoginEnabled,
@@ -45,15 +42,43 @@ export const useSSOStore = defineStore('sso', () => {
void toggleLoginEnabled(value);
},
});
const isOidcLoginEnabled = computed({
get: () => settingsStore.isOidcLoginEnabled,
set: (value: boolean) => {
settingsStore.setSettings({
...settingsStore.settings,
sso: {
...settingsStore.settings.sso,
oidc: {
...settingsStore.settings.sso.oidc,
loginEnabled: value,
},
},
});
},
});
const isEnterpriseSamlEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
);
const isEnterpriseOidcEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc],
);
const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml);
const isDefaultAuthenticationOidc = computed(() => settingsStore.isDefaultAuthenticationOidc);
const showSsoLoginButton = computed(
() =>
isSamlLoginEnabled.value &&
isEnterpriseSamlEnabled.value &&
isDefaultAuthenticationSaml.value,
(isSamlLoginEnabled.value &&
isEnterpriseSamlEnabled.value &&
isDefaultAuthenticationSaml.value) ||
(isOidcLoginEnabled.value &&
isEnterpriseOidcEnabled.value &&
isDefaultAuthenticationOidc.value),
);
const getSSORedirectUrl = async () =>
@@ -66,6 +91,9 @@ export const useSSOStore = defineStore('sso', () => {
await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled });
const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext);
// const getOidcRedirectLUrl = async () => await ssoApi)
const getSamlConfig = async () => {
const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext);
state.samlConfig = samlConfig;
@@ -83,20 +111,36 @@ export const useSSOStore = defineStore('sso', () => {
const userData = computed(() => usersStore.currentUser);
const getOidcConfig = async () => {
const oidcConfig = await ssoApi.getOidcConfig(rootStore.restApiContext);
state.oidcConfig = oidcConfig;
return oidcConfig;
};
const saveOidcConfig = async (config: OidcConfigDto) => {
const savedConfig = await ssoApi.saveOidcConfig(rootStore.restApiContext, config);
state.oidcConfig = savedConfig;
return savedConfig;
};
return {
isLoading,
setLoading,
isEnterpriseOidcEnabled,
isSamlLoginEnabled,
isOidcLoginEnabled,
isEnterpriseSamlEnabled,
isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
showSsoLoginButton,
samlConfig,
oidcConfig,
userData,
getSSORedirectUrl,
getSamlMetadata,
getSamlConfig,
saveSamlConfig,
testSamlConfig,
updateUser,
userData,
getOidcConfig,
saveOidcConfig,
};
});

View File

@@ -72,7 +72,9 @@ const isExternalAuthEnabled = computed((): boolean => {
settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap';
const isSamlEnabled =
settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml;
return isLdapEnabled || isSamlEnabled;
const isOidcEnabled =
settingsStore.settings.enterprise.oidc && currentUser.value?.signInType === 'oidc';
return isLdapEnabled || isSamlEnabled || isOidcEnabled;
});
const isPersonalSecurityEnabled = computed((): boolean => {
return usersStore.isInstanceOwner || !isExternalAuthEnabled.value;

View File

@@ -65,6 +65,7 @@ describe('SettingsSso View', () => {
const pinia = createTestingPinia();
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isEnterpriseOidcEnabled = false;
const pageRedirectionHelper = usePageRedirectionHelper();
@@ -82,6 +83,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
@@ -102,6 +104,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
@@ -126,6 +129,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia });
@@ -163,6 +167,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia });
@@ -199,6 +204,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia });
@@ -228,6 +234,7 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
const { getByTestId } = renderView({ pinia });
@@ -258,6 +265,8 @@ describe('SettingsSso View', () => {
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isSamlLoginEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isOidcLoginEnabled = false;
const error = new Error('Request failed with status code 404');
ssoStore.getSamlConfig.mockRejectedValue(error);

View File

@@ -9,17 +9,27 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { MODAL_CONFIRM } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
type SupportedProtocolType = (typeof SupportedProtocols)[keyof typeof SupportedProtocols];
const IdentityProviderSettingsType = {
URL: 'url',
XML: 'xml',
};
const SupportedProtocols = {
SAML: 'saml',
OIDC: 'oidc',
} as const;
const i18n = useI18n();
const telemetry = useTelemetry();
const rootStore = useRootStore();
const ssoStore = useSSOStore();
const message = useMessage();
const settingsStore = useSettingsStore();
const toast = useToast();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
@@ -29,11 +39,24 @@ const ssoActivatedLabel = computed(() =>
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const oidcActivatedLabel = computed(() =>
ssoStore.isOidcLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const ssoSettingsSaved = ref(false);
const redirectUrl = ref();
const entityId = ref();
const clientId = ref('');
const clientSecret = ref('');
const discoveryEndpoint = ref('');
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
const ipsOptions = ref([
{
label: i18n.baseText('settings.sso.settings.ips.options.url'),
@@ -49,6 +72,8 @@ const ipsType = ref(IdentityProviderSettingsType.URL);
const metadataUrl = ref();
const metadata = ref();
const redirectUrl = ref();
const isSaveEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
@@ -67,6 +92,17 @@ const isTestEnabled = computed(() => {
return false;
});
async function loadSamlConfig() {
if (!ssoStore.isEnterpriseSamlEnabled) {
return;
}
try {
await getSamlConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig();
@@ -167,39 +203,79 @@ const isToggleSsoDisabled = computed(() => {
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.sso.title'));
if (!ssoStore.isEnterpriseSamlEnabled) {
await Promise.all([loadSamlConfig(), loadOidcConfig()]);
if (ssoStore.isDefaultAuthenticationSaml) {
authProtocol.value = SupportedProtocols.SAML;
} else if (ssoStore.isDefaultAuthenticationOidc) {
authProtocol.value = SupportedProtocols.OIDC;
}
});
const getOidcConfig = async () => {
const config = await ssoStore.getOidcConfig();
clientId.value = config.clientId;
clientSecret.value = config.clientSecret;
discoveryEndpoint.value = config.discoveryEndpoint;
};
async function loadOidcConfig() {
if (!ssoStore.isEnterpriseOidcEnabled) {
return;
}
try {
await getSamlConfig();
await getOidcConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
function onAuthProtocolUpdated(value: SupportedProtocolType) {
authProtocol.value = value;
}
const cannotSaveOidcSettings = computed(() => {
return (
ssoStore.oidcConfig?.clientId === clientId.value &&
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled
);
});
async function onOidcSettingsSave() {
if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) {
const confirmAction = await message.confirm(
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'),
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
),
},
);
if (confirmAction !== MODAL_CONFIRM) return;
}
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
clientSecret: clientSecret.value,
discoveryEndpoint: discoveryEndpoint.value,
loginEnabled: ssoStore.isOidcLoginEnabled,
});
clientSecret.value = newConfig.clientSecret;
}
</script>
<template>
<div class="pb-3xl">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
<div :class="$style.top">
<n8n-heading size="xlarge">{{ i18n.baseText('settings.sso.subtitle') }}</n8n-heading>
<n8n-tooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<el-switch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</n8n-tooltip>
<div class="pb-2xl">
<div :class="$style.heading">
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.sso.title') }}</n8n-heading>
</div>
<n8n-info-tip>
{{ i18n.baseText('settings.sso.info') }}
@@ -209,67 +285,173 @@ onMounted(async () => {
</n8n-info-tip>
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
<label>Select Authentication Protocol</label>
<div>
<N8nSelect
filterable
:model-value="authProtocol"
:placeholder="i18n.baseText('parameterInput.select')"
@update:model-value="onAuthProtocolUpdated"
@keydown.stop
>
<N8nOption
v-for="protocol in Object.values(SupportedProtocols)"
:key="protocol"
:value="protocol"
:label="protocol.toUpperCase()"
data-test-id="credential-select-option"
>
</N8nOption>
</N8nSelect>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.URL">
<n8n-input
v-model="metadataUrl"
type="text"
name="metadataUrl"
</div>
<div v-if="authProtocol === SupportedProtocols.SAML">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-show="ipsType === IdentityProviderSettingsType.URL">
<n8n-input
v-model="metadataUrl"
type="text"
name="metadataUrl"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<n8n-input
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
</div>
<div :class="$style.group">
<n8n-tooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<el-switch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</n8n-tooltip>
</div>
</div>
<div :class="$style.buttons">
<n8n-button
:disabled="!isSaveEnabled"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
data-test-id="sso-save"
@click="onSave"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
<n8n-button
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</n8n-button>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<n8n-input
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<div v-if="authProtocol === SupportedProtocols.OIDC">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="settingsStore.oidcCallBackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
<small>Copy the Redirect URL to configure your OIDC provider </small>
</div>
<div :class="$style.group">
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
type="text"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@update:model-value="(v: string) => (discoveryEndpoint = v)"
/>
<small>Paste here your discovery endpoint</small>
</div>
<div :class="$style.group">
<label>Client ID</label>
<N8nInput
:model-value="clientId"
type="text"
@update:model-value="(v: string) => (clientId = v)"
/>
<small
>The client ID you received when registering your application with your provider</small
>
</div>
<div :class="$style.group">
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
type="password"
@update:model-value="(v: string) => (clientSecret = v)"
/>
<small
>The client Secret you received when registering your application with your
provider</small
>
</div>
<div :class="$style.group">
<el-switch
v-model="ssoStore.isOidcLoginEnabled"
data-test-id="sso-oidc-toggle"
:class="$style.switch"
:inactive-text="oidcActivatedLabel"
/>
</div>
<div :class="$style.buttons">
<n8n-button size="large" :disabled="cannotSaveOidcSettings" @click="onOidcSettingsSave">
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
</div>
</div>
<div :class="$style.buttons">
<n8n-button :disabled="!isSaveEnabled" size="large" data-test-id="sso-save" @click="onSave">
{{ i18n.baseText('settings.sso.settings.save') }}
</n8n-button>
<n8n-button
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</n8n-button>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<n8n-action-box
v-else
@@ -287,11 +469,8 @@ onMounted(async () => {
</template>
<style lang="scss" module>
.top {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xl) 0 var(--spacing-xl);
.heading {
margin-bottom: var(--spacing-s);
}
.switch {