mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add SAML post and test endpoints (#5595)
* consolidate SSO settings * update saml settings * fix type error * limit user changes when saml is enabled * add test * add toggle endpoint and fetch metadata * rename enabled param * add handling of POST saml login request * add config test endpoint
This commit is contained in:
parent
b5179597f3
commit
523fa71705
|
@ -15,6 +15,10 @@ export class SamlUrls {
|
||||||
|
|
||||||
static readonly config = '/config';
|
static readonly config = '/config';
|
||||||
|
|
||||||
|
static readonly configTest = '/config/test';
|
||||||
|
|
||||||
|
static readonly configToggleEnabled = '/config/toggle';
|
||||||
|
|
||||||
static readonly restConfig = this.samlRESTRoot + this.config;
|
static readonly restConfig = this.samlRESTRoot + this.config;
|
||||||
|
|
||||||
static readonly defaultRedirect = '/';
|
static readonly defaultRedirect = '/';
|
||||||
|
|
|
@ -8,7 +8,10 @@ import { SamlUrls } from '../constants';
|
||||||
import type { SamlConfiguration } from '../types/requests';
|
import type { SamlConfiguration } from '../types/requests';
|
||||||
import { AuthError, BadRequestError } from '@/ResponseHelper';
|
import { AuthError, BadRequestError } from '@/ResponseHelper';
|
||||||
import { issueCookie } from '../../../auth/jwt';
|
import { issueCookie } from '../../../auth/jwt';
|
||||||
import { isSamlPreferences } from '../samlHelpers';
|
import { validate } from 'class-validator';
|
||||||
|
import type { PostBindingContext } from 'samlify/types/src/entity';
|
||||||
|
import { getInitSSOFormView } from '../views/initSsoPost';
|
||||||
|
import { getInitSSOPostView } from '../views/initSsoRedirect';
|
||||||
|
|
||||||
export const samlControllerProtected = express.Router();
|
export const samlControllerProtected = express.Router();
|
||||||
|
|
||||||
|
@ -27,17 +30,38 @@ samlControllerProtected.get(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /sso/saml/config
|
* POST /sso/saml/config
|
||||||
* Return SAML config
|
* Set SAML config
|
||||||
*/
|
*/
|
||||||
samlControllerProtected.post(
|
samlControllerProtected.post(
|
||||||
SamlUrls.config,
|
SamlUrls.config,
|
||||||
samlLicensedOwnerMiddleware,
|
samlLicensedOwnerMiddleware,
|
||||||
async (req: SamlConfiguration.Update, res: express.Response) => {
|
async (req: SamlConfiguration.Update, res: express.Response) => {
|
||||||
if (isSamlPreferences(req.body)) {
|
const validationResult = await validate(req.body);
|
||||||
|
if (validationResult.length === 0) {
|
||||||
const result = await SamlService.getInstance().setSamlPreferences(req.body);
|
const result = await SamlService.getInstance().setSamlPreferences(req.body);
|
||||||
return res.send(result);
|
return res.send(result);
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError('Body is not a SamlPreferences object');
|
throw new BadRequestError(
|
||||||
|
'Body is not a valid SamlPreferences object: ' +
|
||||||
|
validationResult.map((e) => e.toString()).join(','),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /sso/saml/config/toggle
|
||||||
|
* Set SAML config
|
||||||
|
*/
|
||||||
|
samlControllerProtected.post(
|
||||||
|
SamlUrls.configToggleEnabled,
|
||||||
|
samlLicensedOwnerMiddleware,
|
||||||
|
async (req: SamlConfiguration.Toggle, res: express.Response) => {
|
||||||
|
if (req.body.loginEnabled !== undefined) {
|
||||||
|
await SamlService.getInstance().setSamlPreferences({ loginEnabled: req.body.loginEnabled });
|
||||||
|
res.sendStatus(200);
|
||||||
|
} else {
|
||||||
|
throw new BadRequestError('Body should contain a boolean "loginEnabled" property');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -96,12 +120,23 @@ samlControllerProtected.get(
|
||||||
SamlUrls.initSSO,
|
SamlUrls.initSSO,
|
||||||
samlLicensedAndEnabledMiddleware,
|
samlLicensedAndEnabledMiddleware,
|
||||||
async (req: express.Request, res: express.Response) => {
|
async (req: express.Request, res: express.Response) => {
|
||||||
const url = SamlService.getInstance().getRedirectLoginRequestUrl();
|
const result = SamlService.getInstance().getLoginRequestUrl();
|
||||||
if (url) {
|
if (result?.binding === 'redirect') {
|
||||||
// TODO:SAML: redirect to the URL on the client side
|
// forced client side redirect
|
||||||
return res.status(301).send(url);
|
return res.send(getInitSSOPostView(result.context));
|
||||||
|
// return res.status(301).send(result.context.context);
|
||||||
|
} else if (result?.binding === 'post') {
|
||||||
|
return res.send(getInitSSOFormView(result.context as PostBindingContext));
|
||||||
} else {
|
} else {
|
||||||
throw new AuthError('SAML redirect failed, please check your SAML configuration.');
|
throw new AuthError('SAML redirect failed, please check your SAML configuration.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
samlControllerProtected.get(
|
||||||
|
SamlUrls.configTest,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
const testResult = await SamlService.getInstance().testSamlConnection();
|
||||||
|
return res.send(testResult);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type express from 'express';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import type { User } from '@/databases/entities/User';
|
import type { User } from '@/databases/entities/User';
|
||||||
import { jsonParse, LoggerProxy } from 'n8n-workflow';
|
import { jsonParse, LoggerProxy } from 'n8n-workflow';
|
||||||
import { AuthError } from '@/ResponseHelper';
|
import { AuthError, BadRequestError } from '@/ResponseHelper';
|
||||||
import { getServiceProviderInstance } from './serviceProvider.ee';
|
import { getServiceProviderInstance } from './serviceProvider.ee';
|
||||||
import type { SamlUserAttributes } from './types/samlUserAttributes';
|
import type { SamlUserAttributes } from './types/samlUserAttributes';
|
||||||
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
|
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
|
||||||
|
@ -20,6 +20,10 @@ import {
|
||||||
setSamlLoginLabel,
|
setSamlLoginLabel,
|
||||||
updateUserFromSamlAttributes,
|
updateUserFromSamlAttributes,
|
||||||
} from './samlHelpers';
|
} from './samlHelpers';
|
||||||
|
import type { Settings } from '../../databases/entities/Settings';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { SamlLoginBinding } from './types';
|
||||||
|
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
|
||||||
|
|
||||||
export class SamlService {
|
export class SamlService {
|
||||||
private static instance: SamlService;
|
private static instance: SamlService;
|
||||||
|
@ -44,6 +48,10 @@ export class SamlService {
|
||||||
|
|
||||||
private _metadata = '';
|
private _metadata = '';
|
||||||
|
|
||||||
|
private metadataUrl = '';
|
||||||
|
|
||||||
|
private loginBinding: SamlLoginBinding = 'post';
|
||||||
|
|
||||||
public get metadata(): string {
|
public get metadata(): string {
|
||||||
return this._metadata;
|
return this._metadata;
|
||||||
}
|
}
|
||||||
|
@ -53,7 +61,7 @@ export class SamlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadSamlPreferences()
|
this.loadFromDbAndApplySamlPreferences()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
LoggerProxy.debug('Initializing SAML service');
|
LoggerProxy.debug('Initializing SAML service');
|
||||||
})
|
})
|
||||||
|
@ -70,7 +78,7 @@ export class SamlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
await this.loadSamlPreferences();
|
await this.loadFromDbAndApplySamlPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
|
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
|
||||||
|
@ -83,19 +91,48 @@ export class SamlService {
|
||||||
return this.identityProviderInstance;
|
return this.identityProviderInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRedirectLoginRequestUrl(): string {
|
getLoginRequestUrl(binding?: SamlLoginBinding): {
|
||||||
|
binding: SamlLoginBinding;
|
||||||
|
context: BindingContext | PostBindingContext;
|
||||||
|
} {
|
||||||
|
if (binding === undefined) binding = this.loginBinding;
|
||||||
|
if (binding === 'post') {
|
||||||
|
return {
|
||||||
|
binding,
|
||||||
|
context: this.getPostLoginRequestUrl(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
binding,
|
||||||
|
context: this.getRedirectLoginRequestUrl(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRedirectLoginRequestUrl(): BindingContext {
|
||||||
const loginRequest = getServiceProviderInstance().createLoginRequest(
|
const loginRequest = getServiceProviderInstance().createLoginRequest(
|
||||||
this.getIdentityProviderInstance(),
|
this.getIdentityProviderInstance(),
|
||||||
'redirect',
|
'redirect',
|
||||||
);
|
);
|
||||||
//TODO:SAML: debug logging
|
//TODO:SAML: debug logging
|
||||||
LoggerProxy.debug(loginRequest.context);
|
LoggerProxy.debug(loginRequest.context);
|
||||||
return loginRequest.context;
|
return loginRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPostLoginRequestUrl(): PostBindingContext {
|
||||||
|
const loginRequest = getServiceProviderInstance().createLoginRequest(
|
||||||
|
this.getIdentityProviderInstance(),
|
||||||
|
'post',
|
||||||
|
) as PostBindingContext;
|
||||||
|
//TODO:SAML: debug logging
|
||||||
|
|
||||||
|
LoggerProxy.debug(loginRequest.context);
|
||||||
|
return loginRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSamlLogin(
|
async handleSamlLogin(
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
binding: 'post' | 'redirect',
|
binding: SamlLoginBinding,
|
||||||
): Promise<
|
): Promise<
|
||||||
| {
|
| {
|
||||||
authenticatedUser: User | undefined;
|
authenticatedUser: User | undefined;
|
||||||
|
@ -150,21 +187,32 @@ export class SamlService {
|
||||||
return {
|
return {
|
||||||
mapping: this.attributeMapping,
|
mapping: this.attributeMapping,
|
||||||
metadata: this.metadata,
|
metadata: this.metadata,
|
||||||
|
metadataUrl: this.metadataUrl,
|
||||||
|
loginBinding: this.loginBinding,
|
||||||
loginEnabled: isSamlLoginEnabled(),
|
loginEnabled: isSamlLoginEnabled(),
|
||||||
loginLabel: getSamlLoginLabel(),
|
loginLabel: getSamlLoginLabel(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSamlPreferences(prefs: SamlPreferences): Promise<void> {
|
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
|
||||||
this.attributeMapping = prefs.mapping;
|
this.loginBinding = prefs.loginBinding ?? this.loginBinding;
|
||||||
this.metadata = prefs.metadata;
|
this.metadata = prefs.metadata ?? this.metadata;
|
||||||
setSamlLoginEnabled(prefs.loginEnabled);
|
this.attributeMapping = prefs.mapping ?? this.attributeMapping;
|
||||||
setSamlLoginLabel(prefs.loginLabel);
|
if (prefs.metadataUrl) {
|
||||||
|
this.metadataUrl = prefs.metadataUrl;
|
||||||
|
const fetchedMetadata = await this.fetchMetadataFromUrl();
|
||||||
|
if (fetchedMetadata) {
|
||||||
|
this.metadata = fetchedMetadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
|
||||||
|
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
|
||||||
this.getIdentityProviderInstance(true);
|
this.getIdentityProviderInstance(true);
|
||||||
await this.saveSamlPreferences();
|
const result = await this.saveSamlPreferencesToDb();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSamlPreferences(): Promise<SamlPreferences | undefined> {
|
async loadFromDbAndApplySamlPreferences(): Promise<SamlPreferences | undefined> {
|
||||||
const samlPreferences = await Db.collections.Settings.findOne({
|
const samlPreferences = await Db.collections.Settings.findOne({
|
||||||
where: { key: SAML_PREFERENCES_DB_KEY },
|
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||||
});
|
});
|
||||||
|
@ -178,26 +226,47 @@ export class SamlService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSamlPreferences(): Promise<void> {
|
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
|
||||||
const samlPreferences = await Db.collections.Settings.findOne({
|
const samlPreferences = await Db.collections.Settings.findOne({
|
||||||
where: { key: SAML_PREFERENCES_DB_KEY },
|
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||||
});
|
});
|
||||||
const settingsValue = JSON.stringify(this.getSamlPreferences());
|
const settingsValue = JSON.stringify(this.getSamlPreferences());
|
||||||
|
let result: Settings;
|
||||||
if (samlPreferences) {
|
if (samlPreferences) {
|
||||||
samlPreferences.value = settingsValue;
|
samlPreferences.value = settingsValue;
|
||||||
await Db.collections.Settings.save(samlPreferences);
|
result = await Db.collections.Settings.save(samlPreferences);
|
||||||
} else {
|
} else {
|
||||||
await Db.collections.Settings.save({
|
result = await Db.collections.Settings.save({
|
||||||
key: SAML_PREFERENCES_DB_KEY,
|
key: SAML_PREFERENCES_DB_KEY,
|
||||||
value: settingsValue,
|
value: settingsValue,
|
||||||
loadOnStartup: true,
|
loadOnStartup: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (result) return jsonParse<SamlPreferences>(result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetadataFromUrl(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const prevRejectStatus = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
const response = await axios.get(this.metadataUrl);
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevRejectStatus;
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
const xml = (await response.data) as string;
|
||||||
|
// TODO: SAML: validate XML
|
||||||
|
// throw new BadRequestError('Received XML is not valid SAML metadata.');
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new BadRequestError('SAML Metadata URL is invalid or response is .');
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttributesFromLoginResponse(
|
async getAttributesFromLoginResponse(
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
binding: 'post' | 'redirect',
|
binding: SamlLoginBinding,
|
||||||
): Promise<SamlUserAttributes> {
|
): Promise<SamlUserAttributes> {
|
||||||
let parsedSamlResponse;
|
let parsedSamlResponse;
|
||||||
try {
|
try {
|
||||||
|
@ -226,4 +295,41 @@ export class SamlService {
|
||||||
}
|
}
|
||||||
return attributes;
|
return attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testSamlConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const requestContext = this.getLoginRequestUrl();
|
||||||
|
if (!requestContext) return false;
|
||||||
|
if (requestContext.binding === 'redirect') {
|
||||||
|
const fetchResult = await axios.get(requestContext.context.context);
|
||||||
|
if (fetchResult.status !== 200) {
|
||||||
|
LoggerProxy.debug('SAML: Error while testing SAML connection.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (requestContext.binding === 'post') {
|
||||||
|
const context = requestContext.context as PostBindingContext;
|
||||||
|
const endpoint = context.entityEndpoint;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append(context.type, context.context);
|
||||||
|
if (context.relayState) {
|
||||||
|
params.append('RelayState', context.relayState);
|
||||||
|
}
|
||||||
|
const fetchResult = await axios.post(endpoint, params, {
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
'Content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (fetchResult.status !== 200) {
|
||||||
|
LoggerProxy.debug('SAML: Error while testing SAML connection.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
LoggerProxy.debug('SAML: Error while testing SAML connection: ', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
1
packages/cli/src/sso/saml/types/index.ts
Normal file
1
packages/cli/src/sso/saml/types/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type SamlLoginBinding = 'post' | 'redirect';
|
|
@ -4,4 +4,5 @@ import type { SamlPreferences } from './samlPreferences';
|
||||||
export declare namespace SamlConfiguration {
|
export declare namespace SamlConfiguration {
|
||||||
type Read = AuthenticatedRequest<{}, {}, {}, {}>;
|
type Read = AuthenticatedRequest<{}, {}, {}, {}>;
|
||||||
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
|
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
|
||||||
|
type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,29 @@
|
||||||
import type { SamlAttributeMapping } from './samlAttributeMapping';
|
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { SamlLoginBinding } from '.';
|
||||||
|
import { SamlAttributeMapping } from './samlAttributeMapping';
|
||||||
|
|
||||||
export interface SamlPreferences {
|
export class SamlPreferences {
|
||||||
mapping: SamlAttributeMapping;
|
@IsObject()
|
||||||
metadata: string;
|
@IsOptional()
|
||||||
loginEnabled: boolean;
|
mapping?: SamlAttributeMapping;
|
||||||
loginLabel: string;
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metadata?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
metadataUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
loginBinding?: SamlLoginBinding = 'redirect';
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
loginEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
loginLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
15
packages/cli/src/sso/saml/views/initSsoPost.ts
Normal file
15
packages/cli/src/sso/saml/views/initSsoPost.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { PostBindingContext } from 'samlify/types/src/entity';
|
||||||
|
|
||||||
|
export function getInitSSOFormView(context: PostBindingContext): string {
|
||||||
|
return `
|
||||||
|
<form id="saml-form" method="post" action="${context.entityEndpoint}" autocomplete="off">
|
||||||
|
<input type="hidden" name="${context.type}" value="${context.context}" />
|
||||||
|
${context.relayState ? '<input type="hidden" name="RelayState" value="{{relayState}}" />' : ''}
|
||||||
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Automatic form submission
|
||||||
|
(function(){
|
||||||
|
document.forms[0].submit();
|
||||||
|
})();
|
||||||
|
</script>`;
|
||||||
|
}
|
12
packages/cli/src/sso/saml/views/initSsoRedirect.ts
Normal file
12
packages/cli/src/sso/saml/views/initSsoRedirect.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import type { BindingContext } from 'samlify/types/src/entity';
|
||||||
|
|
||||||
|
export function getInitSSOPostView(context: BindingContext): string {
|
||||||
|
return `
|
||||||
|
<html></html>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Automatic redirect
|
||||||
|
(function(){
|
||||||
|
location.href = "${context.context}";
|
||||||
|
})();
|
||||||
|
</script>`;
|
||||||
|
}
|
Loading…
Reference in a new issue