mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
83e25c066a
commit
f4e59499fc
|
@ -24,6 +24,7 @@ import type {
|
|||
IExecutionsSummary,
|
||||
FeatureFlags,
|
||||
WorkflowSettings,
|
||||
AuthenticationMethod,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
||||
|
@ -552,6 +553,7 @@ export interface IUserManagementSettings {
|
|||
enabled: boolean;
|
||||
showSetupOnFirstLoad?: boolean;
|
||||
smtpSetup: boolean;
|
||||
authenticationMethod: AuthenticationMethod;
|
||||
}
|
||||
export interface IActiveDirectorySettings {
|
||||
enabled: boolean;
|
||||
|
|
|
@ -26,10 +26,12 @@ import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
|
|||
import { License } from '@/License';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import {
|
||||
getCurrentAuthenticationMethod,
|
||||
isEmailCurrentAuthenticationMethod,
|
||||
isLdapCurrentAuthenticationMethod,
|
||||
setCurrentAuthenticationMethod,
|
||||
} from '@/sso/ssoHelpers';
|
||||
import { InternalServerError } from '../ResponseHelper';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const setLdapLoginEnabled = async (value: boolean): Promise<void> => {
|
||||
if (config.get(LDAP_LOGIN_ENABLED) === value) {
|
||||
return;
|
||||
}
|
||||
// only one auth method can be active at a time, with email being the default
|
||||
if (value && isEmailCurrentAuthenticationMethod()) {
|
||||
// enable ldap login and disable email login, but only if email is the current auth method
|
||||
config.set(LDAP_LOGIN_ENABLED, true);
|
||||
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');
|
||||
export async function setLdapLoginEnabled(enabled: boolean): Promise<void> {
|
||||
if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) {
|
||||
if (enabled) {
|
||||
config.set(LDAP_LOGIN_ENABLED, true);
|
||||
await setCurrentAuthenticationMethod('ldap');
|
||||
} else if (!enabled) {
|
||||
config.set(LDAP_LOGIN_ENABLED, false);
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
}
|
||||
} else {
|
||||
Logger.warn(
|
||||
'Cannot switch LDAP login enabled state when an authentication method other than email is active',
|
||||
throw new InternalServerError(
|
||||
`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
|
||||
|
@ -217,7 +215,15 @@ export const handleLdapInit = async (): Promise<void> => {
|
|||
|
||||
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
|
||||
// configuration
|
||||
|
|
|
@ -157,6 +157,7 @@ import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/sam
|
|||
import { SamlController } from './sso/saml/routes/saml.controller.ee';
|
||||
import { SamlService } from './sso/saml/saml.service.ee';
|
||||
import { LdapManager } from './Ldap/LdapManager.ee';
|
||||
import { getCurrentAuthenticationMethod } from './sso/ssoHelpers';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
|
@ -269,6 +270,7 @@ class Server extends AbstractServer {
|
|||
config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&
|
||||
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
|
||||
smtpSetup: isEmailSetUp(),
|
||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||
},
|
||||
sso: {
|
||||
saml: {
|
||||
|
@ -328,6 +330,7 @@ class Server extends AbstractServer {
|
|||
// refresh user management status
|
||||
Object.assign(this.frontendSettings.userManagement, {
|
||||
enabled: isUserManagementEnabled(),
|
||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||
showSetupOnFirstLoad:
|
||||
config.getEnv('userManagement.disabled') === false &&
|
||||
config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&
|
||||
|
|
|
@ -3,8 +3,6 @@ export class SamlUrls {
|
|||
|
||||
static readonly initSSO = '/initsso';
|
||||
|
||||
static readonly restInitSSO = this.samlRESTRoot + this.initSSO;
|
||||
|
||||
static readonly acs = '/acs';
|
||||
|
||||
static readonly restAcs = this.samlRESTRoot + this.acs;
|
||||
|
@ -17,9 +15,9 @@ export class SamlUrls {
|
|||
|
||||
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 = '/';
|
||||
|
||||
|
|
|
@ -16,7 +16,11 @@ import type { PostBindingContext } from 'samlify/types/src/entity';
|
|||
import { isSamlLicensedAndEnabled } from '../samlHelpers';
|
||||
import type { SamlLoginBinding } from '../types';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
import { getServiceProviderEntityId, getServiceProviderReturnUrl } from '../serviceProvider.ee';
|
||||
import {
|
||||
getServiceProviderConfigTestReturnUrl,
|
||||
getServiceProviderEntityId,
|
||||
getServiceProviderReturnUrl,
|
||||
} from '../serviceProvider.ee';
|
||||
|
||||
@RestController('/sso/saml')
|
||||
export class SamlController {
|
||||
|
@ -34,13 +38,13 @@ export class SamlController {
|
|||
* Return SAML config
|
||||
*/
|
||||
@Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] })
|
||||
async configGet(req: AuthenticatedRequest, res: express.Response) {
|
||||
async configGet() {
|
||||
const prefs = this.samlService.samlPreferences;
|
||||
return res.send({
|
||||
return {
|
||||
...prefs,
|
||||
entityID: getServiceProviderEntityId(),
|
||||
returnUrl: getServiceProviderReturnUrl(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,11 +52,11 @@ export class SamlController {
|
|||
* Set SAML config
|
||||
*/
|
||||
@Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] })
|
||||
async configPost(req: SamlConfiguration.Update, res: express.Response) {
|
||||
async configPost(req: SamlConfiguration.Update) {
|
||||
const validationResult = await validate(req.body);
|
||||
if (validationResult.length === 0) {
|
||||
const result = await this.samlService.setSamlPreferences(req.body);
|
||||
return res.send(result);
|
||||
return result;
|
||||
} else {
|
||||
throw new BadRequestError(
|
||||
'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) {
|
||||
const loginResult = await this.samlService.handleSamlLogin(req, binding);
|
||||
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) {
|
||||
// Only sign in user if SAML is enabled, otherwise treat as test connection
|
||||
if (isSamlLicensedAndEnabled()) {
|
||||
|
@ -134,13 +142,13 @@ export class SamlController {
|
|||
*/
|
||||
@Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] })
|
||||
async configTestGet(req: AuthenticatedRequest, res: express.Response) {
|
||||
return this.handleInitSSO(res);
|
||||
return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl());
|
||||
}
|
||||
|
||||
private async handleInitSSO(res: express.Response) {
|
||||
const result = this.samlService.getLoginRequestUrl();
|
||||
private async handleInitSSO(res: express.Response, relayState?: string) {
|
||||
const result = this.samlService.getLoginRequestUrl(relayState);
|
||||
if (result?.binding === 'redirect') {
|
||||
return res.send(result.context.context);
|
||||
return result.context.context;
|
||||
} else if (result?.binding === 'post') {
|
||||
return res.send(getInitSSOFormView(result.context as PostBindingContext));
|
||||
} else {
|
||||
|
|
|
@ -20,12 +20,13 @@ import {
|
|||
setSamlLoginLabel,
|
||||
updateUserFromSamlAttributes,
|
||||
} from './samlHelpers';
|
||||
import type { Settings } from '../../databases/entities/Settings';
|
||||
import type { Settings } from '@/databases/entities/Settings';
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import type { SamlLoginBinding } from './types';
|
||||
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
|
||||
import { validateMetadata, validateResponse } from './samlValidator';
|
||||
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
|
||||
|
||||
@Service()
|
||||
export class SamlService {
|
||||
|
@ -48,6 +49,7 @@ export class SamlService {
|
|||
loginLabel: 'SAML',
|
||||
wantAssertionsSigned: true,
|
||||
wantMessageSigned: true,
|
||||
relayState: getInstanceBaseUrl(),
|
||||
signatureConfig: {
|
||||
prefix: 'ds',
|
||||
location: {
|
||||
|
@ -92,7 +94,10 @@ export class SamlService {
|
|||
return getServiceProviderInstance(this._samlPreferences);
|
||||
}
|
||||
|
||||
getLoginRequestUrl(binding?: SamlLoginBinding): {
|
||||
getLoginRequestUrl(
|
||||
relayState?: string,
|
||||
binding?: SamlLoginBinding,
|
||||
): {
|
||||
binding: SamlLoginBinding;
|
||||
context: BindingContext | PostBindingContext;
|
||||
} {
|
||||
|
@ -100,28 +105,29 @@ export class SamlService {
|
|||
if (binding === 'post') {
|
||||
return {
|
||||
binding,
|
||||
context: this.getPostLoginRequestUrl(),
|
||||
context: this.getPostLoginRequestUrl(relayState),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
binding,
|
||||
context: this.getRedirectLoginRequestUrl(),
|
||||
context: this.getRedirectLoginRequestUrl(relayState),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getRedirectLoginRequestUrl(): BindingContext {
|
||||
const loginRequest = this.getServiceProviderInstance().createLoginRequest(
|
||||
this.getIdentityProviderInstance(),
|
||||
'redirect',
|
||||
);
|
||||
private getRedirectLoginRequestUrl(relayState?: string): BindingContext {
|
||||
const sp = this.getServiceProviderInstance();
|
||||
sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
|
||||
const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect');
|
||||
//TODO:SAML: debug logging
|
||||
LoggerProxy.debug(loginRequest.context);
|
||||
return loginRequest;
|
||||
}
|
||||
|
||||
private getPostLoginRequestUrl(): PostBindingContext {
|
||||
const loginRequest = this.getServiceProviderInstance().createLoginRequest(
|
||||
private getPostLoginRequestUrl(relayState?: string): PostBindingContext {
|
||||
const sp = this.getServiceProviderInstance();
|
||||
sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
|
||||
const loginRequest = sp.createLoginRequest(
|
||||
this.getIdentityProviderInstance(),
|
||||
'post',
|
||||
) as PostBindingContext;
|
||||
|
|
|
@ -4,7 +4,7 @@ import * as Db from '@/Db';
|
|||
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||
import { User } from '@db/entities/User';
|
||||
import { License } from '@/License';
|
||||
import { AuthError } from '@/ResponseHelper';
|
||||
import { AuthError, InternalServerError } from '@/ResponseHelper';
|
||||
import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper';
|
||||
import type { SamlPreferences } from './types/samlPreferences';
|
||||
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 { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
|
||||
import {
|
||||
getCurrentAuthenticationMethod,
|
||||
isEmailCurrentAuthenticationMethod,
|
||||
isSamlCurrentAuthenticationMethod,
|
||||
setCurrentAuthenticationMethod,
|
||||
} from '../ssoHelpers';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
/**
|
||||
* 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
|
||||
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
|
||||
if (config.get(SAML_LOGIN_ENABLED) === enabled) {
|
||||
return;
|
||||
}
|
||||
if (enabled && isEmailCurrentAuthenticationMethod()) {
|
||||
config.set(SAML_LOGIN_ENABLED, true);
|
||||
await setCurrentAuthenticationMethod('saml');
|
||||
} else if (!enabled && isSamlCurrentAuthenticationMethod()) {
|
||||
config.set(SAML_LOGIN_ENABLED, false);
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) {
|
||||
if (enabled) {
|
||||
config.set(SAML_LOGIN_ENABLED, true);
|
||||
await setCurrentAuthenticationMethod('saml');
|
||||
} else if (!enabled) {
|
||||
config.set(SAML_LOGIN_ENABLED, false);
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
}
|
||||
} else {
|
||||
LoggerProxy.warn(
|
||||
'Cannot switch SAML login enabled state when an authentication method other than email is active',
|
||||
throw new InternalServerError(
|
||||
`Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,10 @@ export function getServiceProviderReturnUrl(): string {
|
|||
return getInstanceBaseUrl() + SamlUrls.restAcs;
|
||||
}
|
||||
|
||||
export function getServiceProviderConfigTestReturnUrl(): string {
|
||||
return getInstanceBaseUrl() + SamlUrls.configTestReturn;
|
||||
}
|
||||
|
||||
// TODO:SAML: make these configurable for the end user
|
||||
export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance {
|
||||
if (serviceProviderInstance === undefined) {
|
||||
|
@ -24,6 +28,7 @@ export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProvi
|
|||
wantAssertionsSigned: prefs.wantAssertionsSigned,
|
||||
wantMessageSigned: prefs.wantMessageSigned,
|
||||
signatureConfig: prefs.signatureConfig,
|
||||
relayState: prefs.relayState,
|
||||
nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'],
|
||||
assertionConsumerService: [
|
||||
{
|
||||
|
|
|
@ -57,4 +57,8 @@ export class SamlPreferences {
|
|||
action: 'after',
|
||||
},
|
||||
};
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
relayState?: string = '';
|
||||
}
|
||||
|
|
|
@ -127,7 +127,7 @@ describe('Instance owner', () => {
|
|||
.send({
|
||||
loginEnabled: true,
|
||||
})
|
||||
.expect(200);
|
||||
.expect(500);
|
||||
|
||||
expect(getCurrentAuthenticationMethod()).toBe('ldap');
|
||||
});
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { SignInType } from './constants';
|
||||
import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
|
||||
import { BulkCommand, Undoable } from '@/models/history';
|
||||
import { PartialBy } from '@/utils/typeHelpers';
|
||||
|
||||
export * from 'n8n-design-system/types';
|
||||
|
||||
|
@ -1484,3 +1485,43 @@ export type ExecutionsQueryFilter = {
|
|||
startedAfter?: 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;
|
||||
};
|
||||
|
|
|
@ -1,6 +1,39 @@
|
|||
import { makeRestApiRequest } from '@/utils';
|
||||
import { IRestApiContext } from '@/Interface';
|
||||
import {
|
||||
IRestApiContext,
|
||||
SamlPreferencesLoginEnabled,
|
||||
SamlPreferences,
|
||||
SamlPreferencesExtractedData,
|
||||
} from '@/Interface';
|
||||
|
||||
export const initSSO = (context: IRestApiContext): Promise<string> => {
|
||||
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');
|
||||
};
|
||||
|
|
|
@ -74,6 +74,14 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
available: this.canAccessApiSettings(),
|
||||
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',
|
||||
icon: 'network-wired',
|
||||
|
@ -143,6 +151,9 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
canAccessUsageAndPlan(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.USAGE);
|
||||
},
|
||||
canAccessSso(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.SSO_SETTINGS);
|
||||
},
|
||||
onVersionClick() {
|
||||
this.uiStore.openModal(ABOUT_MODAL_KEY);
|
||||
},
|
||||
|
@ -191,6 +202,11 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
this.$router.push({ name: VIEWS.USAGE });
|
||||
}
|
||||
break;
|
||||
case 'settings-sso':
|
||||
if (this.$router.currentRoute.name !== VIEWS.SSO_SETTINGS) {
|
||||
this.$router.push({ name: VIEWS.SSO_SETTINGS });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -388,6 +388,7 @@ export enum VIEWS {
|
|||
WORKFLOW_EXECUTIONS = 'WorkflowExecutions',
|
||||
USAGE = 'Usage',
|
||||
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
|
||||
SSO_SETTINGS = 'SSoSettings',
|
||||
}
|
||||
|
||||
export enum FAKE_DOOR_FEATURES {
|
||||
|
|
|
@ -1718,7 +1718,24 @@
|
|||
"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.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 you’ll need it to configure these in the SAML provider’s 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 you’ll need it to configure these in the SAML provider’s 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.button": "Continue with SSO"
|
||||
}
|
||||
|
|
|
@ -125,6 +125,7 @@ import {
|
|||
faVideo,
|
||||
faTree,
|
||||
faStickyNote as faSolidStickyNote,
|
||||
faUserLock,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
|
@ -258,5 +259,6 @@ addIcon(faUserFriends);
|
|||
addIcon(faUsers);
|
||||
addIcon(faVideo);
|
||||
addIcon(faTree);
|
||||
addIcon(faUserLock);
|
||||
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,9 +1,10 @@
|
|||
import { computed, reactive } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
import { useRootStore } from '@/stores/n8nRootStore';
|
||||
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', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
@ -19,7 +20,22 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
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(() =>
|
||||
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Saml),
|
||||
);
|
||||
|
@ -31,12 +47,27 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
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 {
|
||||
isLoading,
|
||||
setLoading,
|
||||
isSamlLoginEnabled,
|
||||
isEnterpriseSamlEnabled,
|
||||
showSsoLoginButton,
|
||||
getSSORedirectUrl,
|
||||
getSamlMetadata,
|
||||
getSamlConfig,
|
||||
saveSamlConfig,
|
||||
testSamlConfig,
|
||||
};
|
||||
});
|
||||
|
|
124
packages/editor-ui/src/utils/__tests__/userUtils.test.ts
Normal file
124
packages/editor-ui/src/utils/__tests__/userUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
1
packages/editor-ui/src/utils/typeHelpers.ts
Normal file
1
packages/editor-ui/src/utils/typeHelpers.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
174
packages/editor-ui/src/views/SettingsSso.vue
Normal file
174
packages/editor-ui/src/views/SettingsSso.vue
Normal 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>
|
|
@ -47,13 +47,6 @@
|
|||
@copyInviteLink="onCopyInviteLink"
|
||||
/>
|
||||
</div>
|
||||
<feature-coming-soon
|
||||
v-for="fakeDoorFeature in fakeDoorFeatures"
|
||||
:key="fakeDoorFeature.id"
|
||||
:featureId="fakeDoorFeature.id"
|
||||
class="pb-3xl"
|
||||
showTitle
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -61,8 +54,7 @@
|
|||
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants';
|
||||
|
||||
import PageAlert from '../components/PageAlert.vue';
|
||||
import FeatureComingSoon from '@/components/FeatureComingSoon.vue';
|
||||
import { IFakeDoor, IUser, IUserListAction } from '@/Interface';
|
||||
import { IUser, IUserListAction } from '@/Interface';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { showMessage } from '@/mixins/showMessage';
|
||||
import { copyPaste } from '@/mixins/copyPaste';
|
||||
|
@ -77,7 +69,6 @@ export default mixins(showMessage, copyPaste).extend({
|
|||
name: 'SettingsUsersView',
|
||||
components: {
|
||||
PageAlert,
|
||||
FeatureComingSoon,
|
||||
},
|
||||
async mounted() {
|
||||
if (!this.usersStore.showUMSetupWarning) {
|
||||
|
@ -107,9 +98,6 @@ export default mixins(showMessage, copyPaste).extend({
|
|||
},
|
||||
];
|
||||
},
|
||||
fakeDoorFeatures(): IFakeDoor[] {
|
||||
return this.uiStore.getFakeDoorByLocation('settings/users');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
redirectToSetup() {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue