feat(editor): SSO setup (#5736)

* feat(editor): SSO settings page

* feat(editor): SSO settings page

* feat(editor): SSO settings page

* feat(editor): SSO settings page

* feat(editor): SSO settings page

* feat(editor): SSO settings page

* Merge remote-tracking branch 'origin/master' into pay-170-sso-set-up-page

# Conflicts:
#	packages/cli/src/sso/saml/routes/saml.controller.ee.ts

* feat(editor): Prevent SSO settings page route

* feat(editor): some UI improvements

* fix(editor): SSO settings saml config optional chaining

* fix return values saml controller

* fix(editor): drop dompurify

* fix(editor): save xml as is

* return authenticationMethod with settings

* fix(editor): add missing prop to server

* chore(editor): code formatting

* fix ldap/saml enable toggle endpoint

* fix missing import

* prevent faulty ldap setting from breaking startup

* remove sso fake-door from users page

* fix(editor): update SSO settings route permissions + unit testing

* fix(editor): update vite config for test

* fix(editor): add paddings to SSO settings page buttons, add translation

* fix(editor): fix saml unit test

* fix(core): Improve saml test connection function (#5899)

improve-saml-test-connection return

---------

Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com>
Co-authored-by: Romain Minaud <romain.minaud@gmail.com>
This commit is contained in:
Csaba Tuncsik 2023-04-04 14:28:29 +02:00 committed by GitHub
parent 83e25c066a
commit f4e59499fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1177 additions and 671 deletions

View file

@ -24,6 +24,7 @@ import type {
IExecutionsSummary, IExecutionsSummary,
FeatureFlags, FeatureFlags,
WorkflowSettings, WorkflowSettings,
AuthenticationMethod,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -552,6 +553,7 @@ export interface IUserManagementSettings {
enabled: boolean; enabled: boolean;
showSetupOnFirstLoad?: boolean; showSetupOnFirstLoad?: boolean;
smtpSetup: boolean; smtpSetup: boolean;
authenticationMethod: AuthenticationMethod;
} }
export interface IActiveDirectorySettings { export interface IActiveDirectorySettings {
enabled: boolean; enabled: boolean;

View file

@ -26,10 +26,12 @@ import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
import { License } from '@/License'; import { License } from '@/License';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { import {
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod,
setCurrentAuthenticationMethod, setCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers'; } from '@/sso/ssoHelpers';
import { InternalServerError } from '../ResponseHelper';
/** /**
* Check whether the LDAP feature is disabled in the instance * Check whether the LDAP feature is disabled in the instance
@ -54,25 +56,21 @@ export const setLdapLoginLabel = (value: string): void => {
/** /**
* Set the LDAP login enabled to the configuration object * Set the LDAP login enabled to the configuration object
*/ */
export const setLdapLoginEnabled = async (value: boolean): Promise<void> => { export async function setLdapLoginEnabled(enabled: boolean): Promise<void> {
if (config.get(LDAP_LOGIN_ENABLED) === value) { if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) {
return; if (enabled) {
} config.set(LDAP_LOGIN_ENABLED, true);
// only one auth method can be active at a time, with email being the default await setCurrentAuthenticationMethod('ldap');
if (value && isEmailCurrentAuthenticationMethod()) { } else if (!enabled) {
// enable ldap login and disable email login, but only if email is the current auth method config.set(LDAP_LOGIN_ENABLED, false);
config.set(LDAP_LOGIN_ENABLED, true); await setCurrentAuthenticationMethod('email');
await setCurrentAuthenticationMethod('ldap'); }
} else if (!value && isLdapCurrentAuthenticationMethod()) {
// disable ldap login, but only if ldap is the current auth method
config.set(LDAP_LOGIN_ENABLED, false);
await setCurrentAuthenticationMethod('email');
} else { } else {
Logger.warn( throw new InternalServerError(
'Cannot switch LDAP login enabled state when an authentication method other than email is active', `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`,
); );
} }
}; }
/** /**
* Retrieve the LDAP login label from the configuration object * Retrieve the LDAP login label from the configuration object
@ -217,7 +215,15 @@ export const handleLdapInit = async (): Promise<void> => {
const ldapConfig = await getLdapConfig(); const ldapConfig = await getLdapConfig();
await setGlobalLdapConfigVariables(ldapConfig); try {
await setGlobalLdapConfigVariables(ldapConfig);
} catch (error) {
Logger.error(
`Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
error,
);
}
// init LDAP manager with the current // init LDAP manager with the current
// configuration // configuration

View file

@ -157,6 +157,7 @@ import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/sam
import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlController } from './sso/saml/routes/saml.controller.ee';
import { SamlService } from './sso/saml/saml.service.ee'; import { SamlService } from './sso/saml/saml.service.ee';
import { LdapManager } from './Ldap/LdapManager.ee'; import { LdapManager } from './Ldap/LdapManager.ee';
import { getCurrentAuthenticationMethod } from './sso/ssoHelpers';
const exec = promisify(callbackExec); const exec = promisify(callbackExec);
@ -269,6 +270,7 @@ class Server extends AbstractServer {
config.getEnv('userManagement.isInstanceOwnerSetUp') === false && config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&
config.getEnv('userManagement.skipInstanceOwnerSetup') === false, config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
smtpSetup: isEmailSetUp(), smtpSetup: isEmailSetUp(),
authenticationMethod: getCurrentAuthenticationMethod(),
}, },
sso: { sso: {
saml: { saml: {
@ -328,6 +330,7 @@ class Server extends AbstractServer {
// refresh user management status // refresh user management status
Object.assign(this.frontendSettings.userManagement, { Object.assign(this.frontendSettings.userManagement, {
enabled: isUserManagementEnabled(), enabled: isUserManagementEnabled(),
authenticationMethod: getCurrentAuthenticationMethod(),
showSetupOnFirstLoad: showSetupOnFirstLoad:
config.getEnv('userManagement.disabled') === false && config.getEnv('userManagement.disabled') === false &&
config.getEnv('userManagement.isInstanceOwnerSetUp') === false && config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&

View file

@ -3,8 +3,6 @@ export class SamlUrls {
static readonly initSSO = '/initsso'; static readonly initSSO = '/initsso';
static readonly restInitSSO = this.samlRESTRoot + this.initSSO;
static readonly acs = '/acs'; static readonly acs = '/acs';
static readonly restAcs = this.samlRESTRoot + this.acs; static readonly restAcs = this.samlRESTRoot + this.acs;
@ -17,9 +15,9 @@ export class SamlUrls {
static readonly configTest = '/config/test'; static readonly configTest = '/config/test';
static readonly configToggleEnabled = '/config/toggle'; static readonly configTestReturn = '/config/test/return';
static readonly restConfig = this.samlRESTRoot + this.config; static readonly configToggleEnabled = '/config/toggle';
static readonly defaultRedirect = '/'; static readonly defaultRedirect = '/';

View file

@ -16,7 +16,11 @@ import type { PostBindingContext } from 'samlify/types/src/entity';
import { isSamlLicensedAndEnabled } from '../samlHelpers'; import { isSamlLicensedAndEnabled } from '../samlHelpers';
import type { SamlLoginBinding } from '../types'; import type { SamlLoginBinding } from '../types';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest } from '@/requests';
import { getServiceProviderEntityId, getServiceProviderReturnUrl } from '../serviceProvider.ee'; import {
getServiceProviderConfigTestReturnUrl,
getServiceProviderEntityId,
getServiceProviderReturnUrl,
} from '../serviceProvider.ee';
@RestController('/sso/saml') @RestController('/sso/saml')
export class SamlController { export class SamlController {
@ -34,13 +38,13 @@ export class SamlController {
* Return SAML config * Return SAML config
*/ */
@Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) @Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] })
async configGet(req: AuthenticatedRequest, res: express.Response) { async configGet() {
const prefs = this.samlService.samlPreferences; const prefs = this.samlService.samlPreferences;
return res.send({ return {
...prefs, ...prefs,
entityID: getServiceProviderEntityId(), entityID: getServiceProviderEntityId(),
returnUrl: getServiceProviderReturnUrl(), returnUrl: getServiceProviderReturnUrl(),
}); };
} }
/** /**
@ -48,11 +52,11 @@ export class SamlController {
* Set SAML config * Set SAML config
*/ */
@Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) @Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] })
async configPost(req: SamlConfiguration.Update, res: express.Response) { async configPost(req: SamlConfiguration.Update) {
const validationResult = await validate(req.body); const validationResult = await validate(req.body);
if (validationResult.length === 0) { if (validationResult.length === 0) {
const result = await this.samlService.setSamlPreferences(req.body); const result = await this.samlService.setSamlPreferences(req.body);
return res.send(result); return result;
} else { } else {
throw new BadRequestError( throw new BadRequestError(
'Body is not a valid SamlPreferences object: ' + 'Body is not a valid SamlPreferences object: ' +
@ -100,6 +104,10 @@ export class SamlController {
private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) {
const loginResult = await this.samlService.handleSamlLogin(req, binding); const loginResult = await this.samlService.handleSamlLogin(req, binding);
if (loginResult) { if (loginResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (req.body.RelayState && req.body.RelayState === getServiceProviderConfigTestReturnUrl()) {
return res.status(202).send(loginResult.attributes);
}
if (loginResult.authenticatedUser) { if (loginResult.authenticatedUser) {
// Only sign in user if SAML is enabled, otherwise treat as test connection // Only sign in user if SAML is enabled, otherwise treat as test connection
if (isSamlLicensedAndEnabled()) { if (isSamlLicensedAndEnabled()) {
@ -134,13 +142,13 @@ export class SamlController {
*/ */
@Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] }) @Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] })
async configTestGet(req: AuthenticatedRequest, res: express.Response) { async configTestGet(req: AuthenticatedRequest, res: express.Response) {
return this.handleInitSSO(res); return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl());
} }
private async handleInitSSO(res: express.Response) { private async handleInitSSO(res: express.Response, relayState?: string) {
const result = this.samlService.getLoginRequestUrl(); const result = this.samlService.getLoginRequestUrl(relayState);
if (result?.binding === 'redirect') { if (result?.binding === 'redirect') {
return res.send(result.context.context); return result.context.context;
} else if (result?.binding === 'post') { } else if (result?.binding === 'post') {
return res.send(getInitSSOFormView(result.context as PostBindingContext)); return res.send(getInitSSOFormView(result.context as PostBindingContext));
} else { } else {

View file

@ -20,12 +20,13 @@ import {
setSamlLoginLabel, setSamlLoginLabel,
updateUserFromSamlAttributes, updateUserFromSamlAttributes,
} from './samlHelpers'; } from './samlHelpers';
import type { Settings } from '../../databases/entities/Settings'; import type { Settings } from '@/databases/entities/Settings';
import axios from 'axios'; import axios from 'axios';
import https from 'https'; import https from 'https';
import type { SamlLoginBinding } from './types'; import type { SamlLoginBinding } from './types';
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import { validateMetadata, validateResponse } from './samlValidator'; import { validateMetadata, validateResponse } from './samlValidator';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
@Service() @Service()
export class SamlService { export class SamlService {
@ -48,6 +49,7 @@ export class SamlService {
loginLabel: 'SAML', loginLabel: 'SAML',
wantAssertionsSigned: true, wantAssertionsSigned: true,
wantMessageSigned: true, wantMessageSigned: true,
relayState: getInstanceBaseUrl(),
signatureConfig: { signatureConfig: {
prefix: 'ds', prefix: 'ds',
location: { location: {
@ -92,7 +94,10 @@ export class SamlService {
return getServiceProviderInstance(this._samlPreferences); return getServiceProviderInstance(this._samlPreferences);
} }
getLoginRequestUrl(binding?: SamlLoginBinding): { getLoginRequestUrl(
relayState?: string,
binding?: SamlLoginBinding,
): {
binding: SamlLoginBinding; binding: SamlLoginBinding;
context: BindingContext | PostBindingContext; context: BindingContext | PostBindingContext;
} { } {
@ -100,28 +105,29 @@ export class SamlService {
if (binding === 'post') { if (binding === 'post') {
return { return {
binding, binding,
context: this.getPostLoginRequestUrl(), context: this.getPostLoginRequestUrl(relayState),
}; };
} else { } else {
return { return {
binding, binding,
context: this.getRedirectLoginRequestUrl(), context: this.getRedirectLoginRequestUrl(relayState),
}; };
} }
} }
private getRedirectLoginRequestUrl(): BindingContext { private getRedirectLoginRequestUrl(relayState?: string): BindingContext {
const loginRequest = this.getServiceProviderInstance().createLoginRequest( const sp = this.getServiceProviderInstance();
this.getIdentityProviderInstance(), sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
'redirect', const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect');
);
//TODO:SAML: debug logging //TODO:SAML: debug logging
LoggerProxy.debug(loginRequest.context); LoggerProxy.debug(loginRequest.context);
return loginRequest; return loginRequest;
} }
private getPostLoginRequestUrl(): PostBindingContext { private getPostLoginRequestUrl(relayState?: string): PostBindingContext {
const loginRequest = this.getServiceProviderInstance().createLoginRequest( const sp = this.getServiceProviderInstance();
sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
const loginRequest = sp.createLoginRequest(
this.getIdentityProviderInstance(), this.getIdentityProviderInstance(),
'post', 'post',
) as PostBindingContext; ) as PostBindingContext;

View file

@ -4,7 +4,7 @@ import * as Db from '@/Db';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { License } from '@/License'; import { License } from '@/License';
import { AuthError } from '@/ResponseHelper'; import { AuthError, InternalServerError } from '@/ResponseHelper';
import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
import type { SamlPreferences } from './types/samlPreferences'; import type { SamlPreferences } from './types/samlPreferences';
import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { SamlUserAttributes } from './types/samlUserAttributes';
@ -12,11 +12,11 @@ import type { FlowResult } from 'samlify/types/src/flow';
import type { SamlAttributeMapping } from './types/samlAttributeMapping'; import type { SamlAttributeMapping } from './types/samlAttributeMapping';
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
import { import {
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod,
isSamlCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod,
setCurrentAuthenticationMethod, setCurrentAuthenticationMethod,
} from '../ssoHelpers'; } from '../ssoHelpers';
import { LoggerProxy } from 'n8n-workflow';
/** /**
* Check whether the SAML feature is licensed and enabled in the instance * Check whether the SAML feature is licensed and enabled in the instance
*/ */
@ -30,18 +30,17 @@ export function getSamlLoginLabel(): string {
// can only toggle between email and saml, not directly to e.g. ldap // can only toggle between email and saml, not directly to e.g. ldap
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> { export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
if (config.get(SAML_LOGIN_ENABLED) === enabled) { if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) {
return; if (enabled) {
} config.set(SAML_LOGIN_ENABLED, true);
if (enabled && isEmailCurrentAuthenticationMethod()) { await setCurrentAuthenticationMethod('saml');
config.set(SAML_LOGIN_ENABLED, true); } else if (!enabled) {
await setCurrentAuthenticationMethod('saml'); config.set(SAML_LOGIN_ENABLED, false);
} else if (!enabled && isSamlCurrentAuthenticationMethod()) { await setCurrentAuthenticationMethod('email');
config.set(SAML_LOGIN_ENABLED, false); }
await setCurrentAuthenticationMethod('email');
} else { } else {
LoggerProxy.warn( throw new InternalServerError(
'Cannot switch SAML login enabled state when an authentication method other than email is active', `Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`,
); );
} }
} }

View file

@ -15,6 +15,10 @@ export function getServiceProviderReturnUrl(): string {
return getInstanceBaseUrl() + SamlUrls.restAcs; return getInstanceBaseUrl() + SamlUrls.restAcs;
} }
export function getServiceProviderConfigTestReturnUrl(): string {
return getInstanceBaseUrl() + SamlUrls.configTestReturn;
}
// TODO:SAML: make these configurable for the end user // TODO:SAML: make these configurable for the end user
export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance {
if (serviceProviderInstance === undefined) { if (serviceProviderInstance === undefined) {
@ -24,6 +28,7 @@ export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProvi
wantAssertionsSigned: prefs.wantAssertionsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned,
wantMessageSigned: prefs.wantMessageSigned, wantMessageSigned: prefs.wantMessageSigned,
signatureConfig: prefs.signatureConfig, signatureConfig: prefs.signatureConfig,
relayState: prefs.relayState,
nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'], nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'],
assertionConsumerService: [ assertionConsumerService: [
{ {

View file

@ -57,4 +57,8 @@ export class SamlPreferences {
action: 'after', action: 'after',
}, },
}; };
@IsString()
@IsOptional()
relayState?: string = '';
} }

View file

@ -127,7 +127,7 @@ describe('Instance owner', () => {
.send({ .send({
loginEnabled: true, loginEnabled: true,
}) })
.expect(200); .expect(500);
expect(getCurrentAuthenticationMethod()).toBe('ldap'); expect(getCurrentAuthenticationMethod()).toBe('ldap');
}); });

View file

@ -37,6 +37,7 @@ import {
import { SignInType } from './constants'; import { SignInType } from './constants';
import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants'; import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
import { BulkCommand, Undoable } from '@/models/history'; import { BulkCommand, Undoable } from '@/models/history';
import { PartialBy } from '@/utils/typeHelpers';
export * from 'n8n-design-system/types'; export * from 'n8n-design-system/types';
@ -1484,3 +1485,43 @@ export type ExecutionsQueryFilter = {
startedAfter?: string; startedAfter?: string;
startedBefore?: string; startedBefore?: string;
}; };
export type SamlAttributeMapping = {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
};
export type SamlLoginBinding = 'post' | 'redirect';
export type SamlSignatureConfig = {
prefix: 'ds';
location: {
reference: '/samlp:Response/saml:Issuer';
action: 'after';
};
};
export type SamlPreferencesLoginEnabled = {
loginEnabled: boolean;
};
export type SamlPreferences = {
mapping?: SamlAttributeMapping;
metadata?: string;
metadataUrl?: string;
ignoreSSL?: boolean;
loginBinding?: SamlLoginBinding;
acsBinding?: SamlLoginBinding;
authnRequestsSigned?: boolean;
loginLabel?: string;
wantAssertionsSigned?: boolean;
wantMessageSigned?: boolean;
signatureConfig?: SamlSignatureConfig;
} & PartialBy<SamlPreferencesLoginEnabled, 'loginEnabled'>;
export type SamlPreferencesExtractedData = {
entityID: string;
returnUrl: string;
};

View file

@ -1,6 +1,39 @@
import { makeRestApiRequest } from '@/utils'; import { makeRestApiRequest } from '@/utils';
import { IRestApiContext } from '@/Interface'; import {
IRestApiContext,
SamlPreferencesLoginEnabled,
SamlPreferences,
SamlPreferencesExtractedData,
} from '@/Interface';
export const initSSO = (context: IRestApiContext): Promise<string> => { export const initSSO = (context: IRestApiContext): Promise<string> => {
return makeRestApiRequest(context, 'GET', '/sso/saml/initsso'); return makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
}; };
export const getSamlMetadata = (context: IRestApiContext): Promise<SamlPreferences> => {
return makeRestApiRequest(context, 'GET', '/sso/saml/metadata');
};
export const getSamlConfig = (
context: IRestApiContext,
): Promise<SamlPreferences & SamlPreferencesExtractedData> => {
return makeRestApiRequest(context, 'GET', '/sso/saml/config');
};
export const saveSamlConfig = (
context: IRestApiContext,
data: SamlPreferences,
): Promise<SamlPreferences | undefined> => {
return makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
};
export const toggleSamlConfig = (
context: IRestApiContext,
data: SamlPreferencesLoginEnabled,
): Promise<void> => {
return makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data);
};
export const testSamlConfig = (context: IRestApiContext): Promise<string> => {
return makeRestApiRequest(context, 'GET', '/sso/saml/config/test');
};

View file

@ -74,6 +74,14 @@ export default mixins(userHelpers, pushConnection).extend({
available: this.canAccessApiSettings(), available: this.canAccessApiSettings(),
activateOnRouteNames: [VIEWS.API_SETTINGS], activateOnRouteNames: [VIEWS.API_SETTINGS],
}, },
{
id: 'settings-sso',
icon: 'user-lock',
label: this.$locale.baseText('settings.sso'),
position: 'top',
available: this.canAccessSso(),
activateOnRouteNames: [VIEWS.SSO_SETTINGS],
},
{ {
id: 'settings-ldap', id: 'settings-ldap',
icon: 'network-wired', icon: 'network-wired',
@ -143,6 +151,9 @@ export default mixins(userHelpers, pushConnection).extend({
canAccessUsageAndPlan(): boolean { canAccessUsageAndPlan(): boolean {
return this.canUserAccessRouteByName(VIEWS.USAGE); return this.canUserAccessRouteByName(VIEWS.USAGE);
}, },
canAccessSso(): boolean {
return this.canUserAccessRouteByName(VIEWS.SSO_SETTINGS);
},
onVersionClick() { onVersionClick() {
this.uiStore.openModal(ABOUT_MODAL_KEY); this.uiStore.openModal(ABOUT_MODAL_KEY);
}, },
@ -191,6 +202,11 @@ export default mixins(userHelpers, pushConnection).extend({
this.$router.push({ name: VIEWS.USAGE }); this.$router.push({ name: VIEWS.USAGE });
} }
break; break;
case 'settings-sso':
if (this.$router.currentRoute.name !== VIEWS.SSO_SETTINGS) {
this.$router.push({ name: VIEWS.SSO_SETTINGS });
}
break;
default: default:
break; break;
} }

View file

@ -388,6 +388,7 @@ export enum VIEWS {
WORKFLOW_EXECUTIONS = 'WorkflowExecutions', WORKFLOW_EXECUTIONS = 'WorkflowExecutions',
USAGE = 'Usage', USAGE = 'Usage',
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView', LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
SSO_SETTINGS = 'SSoSettings',
} }
export enum FAKE_DOOR_FEATURES { export enum FAKE_DOOR_FEATURES {

View file

@ -1718,7 +1718,24 @@
"settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)", "settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)",
"settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server", "settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server",
"settings.ldap.section.synchronization.title": "Synchronization", "settings.ldap.section.synchronization.title": "Synchronization",
"settings.sso": "SSO",
"settings.sso.title": "Single Sign On",
"settings.sso.subtitle": "SAML 2.0",
"settings.sso.info": "SAML SSO (Security Assertion Markup Language Single Sign-On) is a type of authentication process that enables users to access multiple applications with a single set of login credentials. {link}",
"settings.sso.info.link": "More info.",
"settings.sso.activation.tooltip": "You need to save the settings first before activating SAML",
"settings.sso.activated": "Activated",
"settings.sso.deactivated": "Deactivated",
"settings.sso.settings.redirectUrl.label": "Redirect URL",
"settings.sso.settings.redirectUrl.copied": "Redirect URL copied to clipboard",
"settings.sso.settings.redirectUrl.help": "Save the Redirect URL as youll need it to configure these in the SAML providers settings.",
"settings.sso.settings.entityId.label": "Entity ID",
"settings.sso.settings.entityId.copied": "Entity ID copied to clipboard",
"settings.sso.settings.entityId.help": "Save the Entity URL as youll need it to configure these in the SAML providers settings.",
"settings.sso.settings.ips.label": "Identity Provider Settings",
"settings.sso.settings.ips.help": "Add the raw Metadata XML provided by your Identity Provider",
"settings.sso.settings.test": "Test settings",
"settings.sso.settings.save": "Save settings",
"sso.login.divider": "or", "sso.login.divider": "or",
"sso.login.button": "Continue with SSO" "sso.login.button": "Continue with SSO"
} }

View file

@ -125,6 +125,7 @@ import {
faVideo, faVideo,
faTree, faTree,
faStickyNote as faSolidStickyNote, faStickyNote as faSolidStickyNote,
faUserLock,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
@ -258,5 +259,6 @@ addIcon(faUserFriends);
addIcon(faUsers); addIcon(faUsers);
addIcon(faVideo); addIcon(faVideo);
addIcon(faTree); addIcon(faTree);
addIcon(faUserLock);
Vue.component('font-awesome-icon', FontAwesomeIcon); Vue.component('font-awesome-icon', FontAwesomeIcon);

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,10 @@
import { computed, reactive } from 'vue'; import { computed, reactive, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature } from '@/constants';
import { useRootStore } from '@/stores/n8nRootStore'; import { useRootStore } from '@/stores/n8nRootStore';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { initSSO } from '@/api/sso'; import * as ssoApi from '@/api/sso';
import { SamlPreferences } from '@/Interface';
export const useSSOStore = defineStore('sso', () => { export const useSSOStore = defineStore('sso', () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
@ -19,7 +20,22 @@ export const useSSOStore = defineStore('sso', () => {
state.loading = loading; state.loading = loading;
}; };
const isSamlLoginEnabled = computed(() => settingsStore.isSamlLoginEnabled); const isSamlLoginEnabled = computed({
get: () => settingsStore.isSamlLoginEnabled,
set: (value: boolean) => {
settingsStore.setSettings({
...settingsStore.settings,
sso: {
...settingsStore.settings.sso,
saml: {
...settingsStore.settings.sso.saml,
loginEnabled: value,
},
},
});
toggleLoginEnabled(value);
},
});
const isEnterpriseSamlEnabled = computed(() => const isEnterpriseSamlEnabled = computed(() =>
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Saml), settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Saml),
); );
@ -31,12 +47,27 @@ export const useSSOStore = defineStore('sso', () => {
isDefaultAuthenticationSaml.value, isDefaultAuthenticationSaml.value,
); );
const getSSORedirectUrl = () => initSSO(rootStore.getRestApiContext); const getSSORedirectUrl = () => ssoApi.initSSO(rootStore.getRestApiContext);
const toggleLoginEnabled = (enabled: boolean) =>
ssoApi.toggleSamlConfig(rootStore.getRestApiContext, { loginEnabled: enabled });
const getSamlMetadata = () => ssoApi.getSamlMetadata(rootStore.getRestApiContext);
const getSamlConfig = () => ssoApi.getSamlConfig(rootStore.getRestApiContext);
const saveSamlConfig = (config: SamlPreferences) =>
ssoApi.saveSamlConfig(rootStore.getRestApiContext, config);
const testSamlConfig = () => ssoApi.testSamlConfig(rootStore.getRestApiContext);
return { return {
isLoading, isLoading,
setLoading, setLoading,
isSamlLoginEnabled,
isEnterpriseSamlEnabled,
showSsoLoginButton, showSsoLoginButton,
getSSORedirectUrl, getSSORedirectUrl,
getSamlMetadata,
getSamlConfig,
saveSamlConfig,
testSamlConfig,
}; };
}); });

View file

@ -0,0 +1,124 @@
import { beforeAll } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { isAuthorized } from '@/utils';
import { useSettingsStore } from '@/stores/settings';
import { useSSOStore } from '@/stores/sso';
import { IN8nUISettings, IUser, UserManagementAuthenticationMethod } from '@/Interface';
import { routes } from '@/router';
import { VIEWS } from '@/constants';
const DEFAULT_SETTINGS: IN8nUISettings = {
allowedModules: {},
communityNodesEnabled: false,
defaultLocale: '',
endpointWebhook: '',
endpointWebhookTest: '',
enterprise: {
advancedExecutionFilters: false,
sharing: false,
ldap: false,
saml: false,
logStreaming: false,
},
executionMode: '',
executionTimeout: 0,
hideUsagePage: false,
hiringBannerEnabled: false,
instanceId: '',
isNpmAvailable: false,
license: { environment: 'production' },
logLevel: 'info',
maxExecutionTimeout: 0,
oauthCallbackUrls: { oauth1: '', oauth2: '' },
onboardingCallPromptEnabled: false,
personalizationSurveyEnabled: false,
posthog: {
apiHost: '',
apiKey: '',
autocapture: false,
debug: false,
disableSessionRecording: false,
enabled: false,
},
publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } },
pushBackend: 'sse',
saveDataErrorExecution: '',
saveDataSuccessExecution: '',
saveManualExecutions: false,
sso: {
ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' },
},
telemetry: { enabled: false },
templates: { enabled: false, host: '' },
timezone: '',
urlBaseEditor: '',
urlBaseWebhook: '',
userManagement: {
enabled: false,
smtpSetup: false,
authenticationMethod: UserManagementAuthenticationMethod.Email,
},
versionCli: '',
versionNotifications: {
enabled: false,
endpoint: '',
infoUrl: '',
},
workflowCallerPolicyDefaultOption: 'any',
workflowTagsDisabled: false,
deployment: {
type: 'default',
},
};
const DEFAULT_USER: IUser = {
id: '1',
isPending: false,
isDefaultUser: true,
isOwner: false,
isPendingUser: false,
globalRole: {
name: 'default',
id: '1',
createdAt: new Date(),
},
};
describe('userUtils', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
let ssoStore: ReturnType<typeof useSSOStore>;
describe('isAuthorized', () => {
beforeAll(() => {
setActivePinia(createPinia());
settingsStore = useSettingsStore();
ssoStore = useSSOStore();
});
it('should check SSO settings route permissions', () => {
const ssoSettingsPermissions = routes
.find((route) => route.path.startsWith('/settings'))
?.children?.find((route) => route.name === VIEWS.SSO_SETTINGS)?.meta?.permissions;
const user: IUser = {
...DEFAULT_USER,
isDefaultUser: false,
isOwner: true,
globalRole: {
...DEFAULT_USER.globalRole,
id: '1',
name: 'owner',
createdAt: new Date(),
},
};
settingsStore.setSettings({
...DEFAULT_SETTINGS,
enterprise: { ...DEFAULT_SETTINGS.enterprise, saml: true },
});
expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true);
});
});
});

View file

@ -0,0 +1 @@
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View file

@ -0,0 +1,174 @@
<script lang="ts" setup>
import { computed, ref, onBeforeMount } from 'vue';
import { Notification } from 'element-ui';
import { useSSOStore } from '@/stores/sso';
import { i18n as locale } from '@/plugins/i18n';
import CopyInput from '@/components/CopyInput.vue';
const ssoStore = useSSOStore();
const ssoActivatedLabel = computed(() =>
ssoStore.isSamlLoginEnabled
? locale.baseText('settings.sso.activated')
: locale.baseText('settings.sso.deactivated'),
);
const ssoSettingsSaved = ref(false);
const metadata = ref();
const redirectUrl = ref();
const entityId = ref();
const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig();
entityId.value = config?.entityID;
redirectUrl.value = config?.returnUrl;
metadata.value = config?.metadata;
ssoSettingsSaved.value = !!config?.metadata;
};
const onSave = async () => {
try {
await ssoStore.saveSamlConfig({ metadata: metadata.value });
await getSamlConfig();
} catch (error) {
Notification.error({
title: 'Error',
message: error.message,
position: 'bottom-right',
});
}
};
const onTest = async () => {
try {
const url = await ssoStore.testSamlConfig();
window.open(url, '_blank');
} catch (error) {
Notification.error({
title: 'Error',
message: error.message,
position: 'bottom-right',
});
}
};
onBeforeMount(async () => {
try {
await getSamlConfig();
} catch (error) {
Notification.error({
title: 'Error',
message: error.message,
position: 'bottom-right',
});
}
});
</script>
<template>
<div>
<n8n-heading size="2xlarge">{{ locale.baseText('settings.sso.title') }}</n8n-heading>
<div :class="$style.top">
<n8n-heading size="medium">{{ locale.baseText('settings.sso.subtitle') }}</n8n-heading>
<n8n-tooltip :disabled="ssoStore.isSamlLoginEnabled">
<template #content>
<span>
{{ locale.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<el-switch
v-model="ssoStore.isSamlLoginEnabled"
:disabled="!ssoSettingsSaved"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</n8n-tooltip>
</div>
<n8n-info-tip>
<i18n :class="$style.count" path="settings.sso.info">
<template #link>
<a href="https://docs.n8n.io/user-management/sso/" target="_blank">
{{ locale.baseText('settings.sso.info.link') }}
</a>
</template>
</i18n>
</n8n-info-tip>
<div :class="$style.group">
<label>{{ locale.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:class="$style.copyInput"
:value="redirectUrl"
:copy-button-text="locale.baseText('generic.clickToCopy')"
:toast-title="locale.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ locale.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ locale.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:class="$style.copyInput"
:value="entityId"
:copy-button-text="locale.baseText('generic.clickToCopy')"
:toast-title="locale.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ locale.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ locale.baseText('settings.sso.settings.ips.label') }}</label>
<n8n-input v-model="metadata" type="textarea" />
<small>{{ locale.baseText('settings.sso.settings.ips.help') }}</small>
</div>
<div :class="$style.buttons">
<n8n-button :disabled="!ssoSettingsSaved" type="tertiary" @click="onTest">
{{ locale.baseText('settings.sso.settings.test') }}
</n8n-button>
<n8n-button :disabled="!metadata" @click="onSave">
{{ locale.baseText('settings.sso.settings.save') }}
</n8n-button>
</div>
</div>
</template>
<style lang="scss" module>
.top {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xl) 0 var(--spacing-xl);
}
.switch {
span {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
color: var(--color-text-light);
}
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing-2xl) 0 var(--spacing-3xl);
button {
margin: 0 var(--spacing-s) 0 0;
}
}
.group {
padding: var(--spacing-xl) 0 0;
label {
display: inline-block;
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
padding: 0 0 var(--spacing-2xs);
}
small {
display: block;
padding: var(--spacing-2xs) 0 0;
font-size: var(--font-size-2xs);
color: var(--color-text-base);
}
}
</style>

View file

@ -47,13 +47,6 @@
@copyInviteLink="onCopyInviteLink" @copyInviteLink="onCopyInviteLink"
/> />
</div> </div>
<feature-coming-soon
v-for="fakeDoorFeature in fakeDoorFeatures"
:key="fakeDoorFeature.id"
:featureId="fakeDoorFeature.id"
class="pb-3xl"
showTitle
/>
</div> </div>
</template> </template>
@ -61,8 +54,7 @@
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants';
import PageAlert from '../components/PageAlert.vue'; import PageAlert from '../components/PageAlert.vue';
import FeatureComingSoon from '@/components/FeatureComingSoon.vue'; import { IUser, IUserListAction } from '@/Interface';
import { IFakeDoor, IUser, IUserListAction } from '@/Interface';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage'; import { showMessage } from '@/mixins/showMessage';
import { copyPaste } from '@/mixins/copyPaste'; import { copyPaste } from '@/mixins/copyPaste';
@ -77,7 +69,6 @@ export default mixins(showMessage, copyPaste).extend({
name: 'SettingsUsersView', name: 'SettingsUsersView',
components: { components: {
PageAlert, PageAlert,
FeatureComingSoon,
}, },
async mounted() { async mounted() {
if (!this.usersStore.showUMSetupWarning) { if (!this.usersStore.showUMSetupWarning) {
@ -107,9 +98,6 @@ export default mixins(showMessage, copyPaste).extend({
}, },
]; ];
}, },
fakeDoorFeatures(): IFakeDoor[] {
return this.uiStore.getFakeDoorByLocation('settings/users');
},
}, },
methods: { methods: {
redirectToSetup() { redirectToSetup() {

View file

@ -132,5 +132,15 @@ export default mergeConfig(
}, },
}, },
}, },
resolve: {
alias: [
// https://github.com/vitest-dev/vitest/discussions/1806
{
find: /^monaco-editor$/,
replacement:
__dirname + "/node_modules/monaco-editor/esm/vs/editor/editor.api",
},
],
},
}), }),
); );