Skip to content

Commit

Permalink
feat(core): Add SAML post and test endpoints (#5595)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
flipswitchingmonkey authored Mar 3, 2023
1 parent b517959 commit 523fa71
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 31 deletions.
4 changes: 4 additions & 0 deletions packages/cli/src/sso/saml/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export class SamlUrls {

static readonly config = '/config';

static readonly configTest = '/config/test';

static readonly configToggleEnabled = '/config/toggle';

static readonly restConfig = this.samlRESTRoot + this.config;

static readonly defaultRedirect = '/';
Expand Down
51 changes: 43 additions & 8 deletions packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { SamlUrls } from '../constants';
import type { SamlConfiguration } from '../types/requests';
import { AuthError, BadRequestError } from '@/ResponseHelper';
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();

Expand All @@ -27,17 +30,38 @@ samlControllerProtected.get(

/**
* POST /sso/saml/config
* Return SAML config
* Set SAML config
*/
samlControllerProtected.post(
SamlUrls.config,
samlLicensedOwnerMiddleware,
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);
return res.send(result);
} 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');
}
},
);
Expand Down Expand Up @@ -96,12 +120,23 @@ samlControllerProtected.get(
SamlUrls.initSSO,
samlLicensedAndEnabledMiddleware,
async (req: express.Request, res: express.Response) => {
const url = SamlService.getInstance().getRedirectLoginRequestUrl();
if (url) {
// TODO:SAML: redirect to the URL on the client side
return res.status(301).send(url);
const result = SamlService.getInstance().getLoginRequestUrl();
if (result?.binding === 'redirect') {
// forced client side redirect
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 {
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);
},
);
140 changes: 123 additions & 17 deletions packages/cli/src/sso/saml/saml.service.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type express from 'express';
import * as Db from '@/Db';
import type { User } from '@/databases/entities/User';
import { jsonParse, LoggerProxy } from 'n8n-workflow';
import { AuthError } from '@/ResponseHelper';
import { AuthError, BadRequestError } from '@/ResponseHelper';
import { getServiceProviderInstance } from './serviceProvider.ee';
import type { SamlUserAttributes } from './types/samlUserAttributes';
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
Expand All @@ -20,6 +20,10 @@ import {
setSamlLoginLabel,
updateUserFromSamlAttributes,
} 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 {
private static instance: SamlService;
Expand All @@ -44,6 +48,10 @@ export class SamlService {

private _metadata = '';

private metadataUrl = '';

private loginBinding: SamlLoginBinding = 'post';

public get metadata(): string {
return this._metadata;
}
Expand All @@ -53,7 +61,7 @@ export class SamlService {
}

constructor() {
this.loadSamlPreferences()
this.loadFromDbAndApplySamlPreferences()
.then(() => {
LoggerProxy.debug('Initializing SAML service');
})
Expand All @@ -70,7 +78,7 @@ export class SamlService {
}

async init(): Promise<void> {
await this.loadSamlPreferences();
await this.loadFromDbAndApplySamlPreferences();
}

getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
Expand All @@ -83,19 +91,48 @@ export class SamlService {
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(
this.getIdentityProviderInstance(),
'redirect',
);
//TODO:SAML: debug logging
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(
req: express.Request,
binding: 'post' | 'redirect',
binding: SamlLoginBinding,
): Promise<
| {
authenticatedUser: User | undefined;
Expand Down Expand Up @@ -150,21 +187,32 @@ export class SamlService {
return {
mapping: this.attributeMapping,
metadata: this.metadata,
metadataUrl: this.metadataUrl,
loginBinding: this.loginBinding,
loginEnabled: isSamlLoginEnabled(),
loginLabel: getSamlLoginLabel(),
};
}

async setSamlPreferences(prefs: SamlPreferences): Promise<void> {
this.attributeMapping = prefs.mapping;
this.metadata = prefs.metadata;
setSamlLoginEnabled(prefs.loginEnabled);
setSamlLoginLabel(prefs.loginLabel);
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
this.loginBinding = prefs.loginBinding ?? this.loginBinding;
this.metadata = prefs.metadata ?? this.metadata;
this.attributeMapping = prefs.mapping ?? this.attributeMapping;
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);
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({
where: { key: SAML_PREFERENCES_DB_KEY },
});
Expand All @@ -178,26 +226,47 @@ export class SamlService {
return;
}

async saveSamlPreferences(): Promise<void> {
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
const settingsValue = JSON.stringify(this.getSamlPreferences());
let result: Settings;
if (samlPreferences) {
samlPreferences.value = settingsValue;
await Db.collections.Settings.save(samlPreferences);
result = await Db.collections.Settings.save(samlPreferences);
} else {
await Db.collections.Settings.save({
result = await Db.collections.Settings.save({
key: SAML_PREFERENCES_DB_KEY,
value: settingsValue,
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(
req: express.Request,
binding: 'post' | 'redirect',
binding: SamlLoginBinding,
): Promise<SamlUserAttributes> {
let parsedSamlResponse;
try {
Expand Down Expand Up @@ -226,4 +295,41 @@ export class SamlService {
}
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 change: 1 addition & 0 deletions packages/cli/src/sso/saml/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type SamlLoginBinding = 'post' | 'redirect';
1 change: 1 addition & 0 deletions packages/cli/src/sso/saml/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ import type { SamlPreferences } from './samlPreferences';
export declare namespace SamlConfiguration {
type Read = AuthenticatedRequest<{}, {}, {}, {}>;
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>;
}
33 changes: 27 additions & 6 deletions packages/cli/src/sso/saml/types/samlPreferences.ts
Original file line number Diff line number Diff line change
@@ -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 {
mapping: SamlAttributeMapping;
metadata: string;
loginEnabled: boolean;
loginLabel: string;
export class SamlPreferences {
@IsObject()
@IsOptional()
mapping?: SamlAttributeMapping;

@IsString()
@IsOptional()
metadata?: string;

@IsString()
@IsOptional()
metadataUrl?: string;

@IsString()
@IsOptional()
loginBinding?: SamlLoginBinding = 'redirect';

@IsBoolean()
@IsOptional()
loginEnabled?: boolean;

@IsString()
@IsOptional()
loginLabel?: string;
}
15 changes: 15 additions & 0 deletions packages/cli/src/sso/saml/views/initSsoPost.ts
Original file line number Diff line number Diff line change
@@ -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>`;
}
Loading

0 comments on commit 523fa71

Please sign in to comment.