Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add saml integration #3806

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
945 changes: 627 additions & 318 deletions package-lock.json

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/cli/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,32 @@ export const schema = {
default: '',
env: 'N8N_USER_MANAGEMENT_JWT_SECRET',
},
saml: {
enabled: {
doc: 'SAML SSO enabled flag.',
format: Boolean,
default: false,
env: 'N8N_USER_MANAGEMENT_SAML_ENABLED',
},
ssoUrl: {
doc: 'SAML SSO URL.',
format: String,
default: '',
env: 'N8N_USER_MANAGEMENT_SAML_SSO_URL',
},
issuer: {
doc: 'SAML SSO Issuer.',
format: String,
default: '',
env: 'N8N_USER_MANAGEMENT_SAML_ISSUER',
},
certificate: {
doc: 'SAML SSO Certificate.',
format: String,
default: '',
env: 'N8N_USER_MANAGEMENT_SAML_CERTIFICATE',
},
},
emails: {
mode: {
doc: 'How to send emails',
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@types/cookie-parser": "^1.4.2",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.5",
"@types/jest": "^27.4.0",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
Expand All @@ -79,7 +80,9 @@
"@types/node": "^16.11.22",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/passport": "^1.0.9",
"@types/passport-jwt": "^3.0.6",
"@types/passport-saml": "^1.1.3",
"@types/psl": "^1.1.0",
"@types/request-promise-native": "~1.0.15",
"@types/superagent": "4.1.13",
Expand All @@ -98,9 +101,9 @@
"typescript": "~4.6.0"
},
"dependencies": {
"@oclif/core": "^1.9.3",
"@apidevtools/swagger-cli": "4.0.0",
"@oclif/command": "^1.5.18",
"@oclif/core": "^1.9.3",
"@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1",
Expand Down Expand Up @@ -153,9 +156,10 @@
"open": "^7.0.0",
"openapi-types": "^10.0.0",
"p-cancelable": "^2.0.0",
"passport": "^0.5.0",
"passport": "^0.5.3",
"passport-cookie": "^1.0.9",
"passport-jwt": "^4.0.0",
"passport-saml": "^3.2.1",
"pg": "^8.3.0",
"prom-client": "^13.1.0",
"psl": "^1.8.0",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,12 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
authType: IAuthType;
}

export enum IAuthType {
saml = 'saml',
basic = 'basic',
}

export interface IPersonalizationSurveyAnswers {
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
WorkflowRunner,
getCredentialForUser,
getCredentialWithoutUser,
IAuthType,
} from '.';

import config from '../config';
Expand Down Expand Up @@ -329,6 +330,7 @@ class App {
enabled: config.getEnv('templates.enabled'),
host: config.getEnv('templates.host'),
},
authType: IAuthType.basic,
};
}

Expand All @@ -355,6 +357,11 @@ class App {
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
});

// replace auth option with saml if enabled
if (config.getEnv('userManagement.saml.enabled')) {
this.frontendSettings.authType = IAuthType.saml;
}

return this.frontendSettings;
}

Expand Down
94 changes: 92 additions & 2 deletions packages/cli/src/UserManagement/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Request, Response } from 'express';
import { IDataObject } from 'n8n-workflow';
import { Db, ResponseHelper } from '../..';
import passport from 'passport';
import { IDataObject, LoggerProxy as Logger } from 'n8n-workflow';
import { Db, ResponseHelper, InternalHooksManager } from '../..';
import { AUTH_COOKIE_NAME } from '../../constants';
import { issueCookie, resolveJwt } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
import { compareHash, sanitizeUser } from '../UserManagementHelper';
import { User } from '../../databases/entities/User';
import type { LoginRequest } from '../../requests';
import config = require('../../../config');
import { entities } from '../../databases/entities';

export function authenticationMethods(this: N8nApp): void {
/**
Expand Down Expand Up @@ -121,4 +123,92 @@ export function authenticationMethods(this: N8nApp): void {
};
}),
);

/**
* Initialize sso.
*
* Authless endpoint.
*/
this.app.get(
`/${this.restEndpoint}/login/saml`,
passport.authenticate('saml', {
failureRedirect: `/${this.restEndpoint}/login/saml`,
failureFlash: true,
session: false,
}),
);

/**
* Sso callback.
*
* Authless endpoint.
*/
this.app.post(`/${this.restEndpoint}/login/saml/callback`,
passport.authenticate('saml', {
failureRedirect: `/${this.restEndpoint}/login/saml`,
failureFlash: true,
session: false,
}),
async (req, res) => {
if (!req.isAuthenticated() || !req.user) throw Error('Authentication failed');
const samlUser = req.user as {
nameID: string;
firstName?: string;
lastName?: string;
};

let user;
try {
user = await Db.collections.User.findOne(
{
email: samlUser.nameID,
},
{
relations: ['globalRole'],
},
);
} catch (error) {
throw new Error('Unable to access database.');
}

if (!user) {
const role = await Db.collections.Role.findOne({ scope: 'global', name: 'member' });
if (!role) {
Logger.error(
'Request to send email invite(s) to user(s) failed because no global member role was found in database',
);
throw new ResponseHelper.ResponseError(
'Members role not found in database - inconsistent state',
undefined,
500,
);
}

const newUser = new User();
newUser.email = samlUser.nameID;
newUser.isPending = false;
newUser.globalRole = role;
if (samlUser.firstName) {
newUser.firstName = samlUser.firstName;
}
if (samlUser.lastName) {
newUser.lastName = samlUser.lastName;
}

const userRepository = Db.linkRepository(entities.User);
user = userRepository.create(newUser);
await userRepository.save(user);

void InternalHooksManager.getInstance().onUserSignup({
user_id: user.id,
});
}

if (!user) throw new Error('User undefined');

await issueCookie(res, user);

res.redirect(302, '/');
},
);
}
19 changes: 17 additions & 2 deletions packages/cli/src/UserManagement/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
/* eslint-disable import/no-cycle */
import cookieParser from 'cookie-parser';
import passport from 'passport';
import { Strategy } from 'passport-jwt';
import { Strategy as JwtStrategy } from 'passport-jwt';
import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
import { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { LoggerProxy as Logger } from 'n8n-workflow';
Expand Down Expand Up @@ -40,7 +41,7 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
};

passport.use(
new Strategy(options, async function validateCookieContents(jwtPayload: JwtPayload, done) {
new JwtStrategy(options, async function validateCookieContents(jwtPayload: JwtPayload, done) {
try {
const user = await resolveJwtContent(jwtPayload);
return done(null, user);
Expand All @@ -51,6 +52,20 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
}),
);

passport.use(
new SamlStrategy(
{
issuer: config.getEnv('userManagement.saml.issuer'),
protocol: 'http://',
path: `/login/saml/callback`,
entryPoint: config.getEnv('userManagement.saml.ssoUrl'),
cert: config.getEnv('userManagement.saml.certificate'),
},
(profile: Profile | null | undefined, done: VerifiedCallback) =>
done(null, profile as Record<string, unknown>),
),
);

this.app.use(passport.initialize());

this.app.use(async (req: Request, res: Response, next: NextFunction) => {
Expand Down
26 changes: 18 additions & 8 deletions packages/editor-ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { HIRING_BANNER, VIEWS } from './constants';

import mixins from 'vue-typed-mixins';
import { showMessage } from './components/mixins/showMessage';
import { IUser } from './Interface';
import { IAuthType, IUser } from './Interface';
import { mapGetters } from 'vuex';
import { userHelpers } from './components/mixins/userHelpers';
import { addHeaders, loadLanguage } from './plugins/i18n';
Expand All @@ -43,7 +43,7 @@ export default mixins(
Modals,
},
computed: {
...mapGetters('settings', ['isHiringBannerEnabled', 'isTemplatesEnabled', 'isTemplatesEndpointReachable', 'isUserManagementEnabled', 'showSetupPage']),
...mapGetters('settings', ['isHiringBannerEnabled', 'isTemplatesEnabled', 'isTemplatesEndpointReachable', 'isUserManagementEnabled', 'showSetupPage', 'authType']),
...mapGetters('users', ['currentUser']),
defaultLocale (): string {
return this.$store.getters.defaultLocale;
Expand Down Expand Up @@ -119,14 +119,24 @@ export default mixins(
return;
}

// if cannot access page and not logged in, ask to sign in
const user = this.currentUser as IUser | null;

// if cannot access page and not logged in, ask to sign in
if (!user) {
const redirect =
this.$route.query.redirect ||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
this.$router.replace({ name: VIEWS.SIGNIN, query: { redirect } });
return;
if (!this.authType) throw Error("No auth type provided");
switch (this.authType) {
case IAuthType.saml:
this.$store.dispatch('users/initializeSamlSignin');
break;
case IAuthType.basic:
const redirect =
this.$route.query.redirect ||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
this.$router.replace({ name: VIEWS.SIGNIN, query: { redirect } });
return;
default:
throw Error("Unknown auth type");
}
}

// if cannot access page and is logged in, respect signin redirect
Expand Down
7 changes: 7 additions & 0 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,11 @@ export type IPersonalizationSurveyAnswersV2 = {

export type IRole = 'default' | 'owner' | 'member';

export enum IAuthType {
saml = "saml",
basic = "basic",
}

export interface IUserResponse {
id: string;
firstName?: string;
Expand Down Expand Up @@ -559,6 +564,7 @@ export interface IPermissionGroup {
role?: IRole[];
um?: boolean;
api?: boolean;
authTypes?: IAuthType[];
}

export interface IPermissions {
Expand Down Expand Up @@ -666,6 +672,7 @@ export interface IN8nUISettings {
latestVersion: number;
path: string;
};
authType: IAuthType;
}

export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
Expand Down
16 changes: 15 additions & 1 deletion packages/editor-ui/src/components/MainSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ import {
IWorkflowDataUpdate,
IMenuItem,
IUser,
IAuthType,
} from '../Interface';

import ExecutionsList from '@/components/ExecutionsList.vue';
Expand Down Expand Up @@ -257,6 +258,7 @@ export default mixins(
]),
...mapGetters('settings', [
'isTemplatesEnabled',
'authType',
]),
canUserAccessSettings(): boolean {
return [
Expand Down Expand Up @@ -378,7 +380,19 @@ export default mixins(
try {
await this.$store.dispatch('users/logout');

const route = this.$router.resolve({ name: VIEWS.SIGNIN });
let viewName: string;
switch(this.authType) {
case IAuthType.saml:
viewName = VIEWS.SIGNOUT;
break;
case IAuthType.basic:
viewName = VIEWS.SIGNIN;
break;
default:
throw new Error('Unknown auth type');
}

const route = this.$router.resolve({ name: viewName });
window.open(route.href, '_self');
} catch (e) {
this.$showError(e, this.$locale.baseText('auth.signout.error'));
Expand Down
2 changes: 2 additions & 0 deletions packages/editor-ui/src/components/mixins/userHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ export const userHelpers = Vue.extend({
const currentUser = this.$store.getters['users/currentUser'];
const isUMEnabled = this.$store.getters['settings/isUserManagementEnabled'];
const isPublicApiEnabled = this.$store.getters['settings/isPublicApiEnabled'];
const authType = this.$store.getters['settings/authType'];

return permissions && isAuthorized(permissions, {
currentUser,
isUMEnabled,
isPublicApiEnabled,
authType,
});
},
},
Expand Down
Loading