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:
@@ -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 = '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user