diff --git a/packages/cli/package.json b/packages/cli/package.json index 2cad4b973..2469d344d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -76,8 +76,8 @@ "@types/json-diff": "^0.5.1", "@types/jsonwebtoken": "^9.0.1", "@types/localtunnel": "^1.9.0", - "@types/lodash.get": "^4.4.6", "@types/lodash.debounce": "^4.0.7", + "@types/lodash.get": "^4.4.6", "@types/lodash.intersection": "^4.4.7", "@types/lodash.iteratee": "^4.7.7", "@types/lodash.merge": "^4.6.6", @@ -191,6 +191,7 @@ "psl": "^1.8.0", "reflect-metadata": "^0.1.13", "replacestream": "^4.0.3", + "samlify": "^2.8.9", "semver": "^7.3.8", "shelljs": "^0.8.5", "source-map-support": "^0.5.21", diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index b7b22336d..f69349de2 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -190,7 +190,7 @@ export function send( try { const data = await processFunction(req, res); - sendSuccessResponse(res, data, raw); + if (!res.headersSent) sendSuccessResponse(res, data, raw); } catch (error) { if (error instanceof Error) { if (!(error instanceof ResponseError) || error.httpStatusCode > 404) { diff --git a/packages/cli/src/Saml/helpers.ts b/packages/cli/src/Saml/helpers.ts deleted file mode 100644 index c12f02cdb..000000000 --- a/packages/cli/src/Saml/helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getLicense } from '../License'; -import { isUserManagementEnabled } from '../UserManagement/UserManagementHelper'; - -/** - * Check whether the SAML feature is licensed and enabled in the instance - */ -export function isSamlEnabled(): boolean { - const license = getLicense(); - return isUserManagementEnabled() && license.isSamlEnabled(); -} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index efeabb4f4..13cb901ac 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -142,10 +142,13 @@ import { setupBasicAuth } from './middlewares/basicAuth'; import { setupExternalJWTAuth } from './middlewares/externalJWTAuth'; import { PostHogClient } from './posthog'; import { eventBus } from './eventbus'; -import { isSamlEnabled } from './Saml/helpers'; import { Container } from 'typedi'; import { InternalHooks } from './InternalHooks'; import { getStatusUsingPreviousExecutionStatusMethod } from './executions/executionHelpers'; +import { isSamlLicensed } from './sso/saml/samlHelpers'; +import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee'; +import { SamlService } from './sso/saml/saml.service.ee'; +import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee'; const exec = promisify(callbackExec); @@ -318,7 +321,7 @@ class Server extends AbstractServer { sharing: isSharingEnabled(), logStreaming: isLogStreamingEnabled(), ldap: isLdapEnabled(), - saml: isSamlEnabled(), + saml: isSamlLicensed(), }); if (isLdapEnabled()) { @@ -495,6 +498,19 @@ class Server extends AbstractServer { this.app.use(`/${this.restEndpoint}/ldap`, ldapController); } + // ---------------------------------------- + // SAML + // ---------------------------------------- + + // initialize SamlService + await SamlService.getInstance().init(); + + // public SAML endpoints + this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerPublic); + this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected); + + // ---------------------------------------- + // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index c65383816..ada08e48e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -237,7 +237,7 @@ export class Start extends BaseCommand { // Load settings from database and set them to config. const databaseSettings = await Db.collections.Settings.findBy({ loadOnStartup: true }); databaseSettings.forEach((setting) => { - config.set(setting.key, jsonParse(setting.value)); + config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value })); }); config.set('nodes.packagesMissing', ''); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 1d660eb72..78e728c33 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -813,6 +813,11 @@ export const schema = { }, }, }, + authenticationMethod: { + doc: 'How to authenticate users (e.g. "email", "ldap", "saml")', + format: ['email', 'ldap', 'saml'] as const, + default: 'email', + }, }, externalFrontendHooksUrls: { @@ -1006,6 +1011,27 @@ export const schema = { }, }, + sso: { + justInTimeProvisioning: { + format: Boolean, + default: true, + doc: 'Whether to automatically create users when they login via SSO.', + }, + redirectLoginToSso: { + format: Boolean, + default: true, + doc: 'Whether to automatically redirect users from login dialog to initialize SSO flow.', + }, + saml: { + enabled: { + format: Boolean, + default: false, + doc: 'Whether to enable SAML SSO.', + }, + }, + }, + + // TODO: move into sso settings ldap: { loginEnabled: { format: Boolean, diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 0db2cbee3..5092d1ea6 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -19,6 +19,8 @@ import type { } from '@/Interfaces'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; import type { PostHogClient } from '@/posthog'; +import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; +import { SamlUrls } from '../sso/saml/constants'; @RestController() export class AuthController { @@ -57,14 +59,34 @@ export class AuthController { * Authless endpoint. */ @Post('/login') - async login(req: LoginRequest, res: Response): Promise { + async login(req: LoginRequest, res: Response): Promise { const { email, password } = req.body; if (!email) throw new Error('Email is required to log in'); if (!password) throw new Error('Password is required to log in'); - const user = - (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password)); + let user: User | undefined; + if (isSamlCurrentAuthenticationMethod()) { + // attempt to fetch user data with the credentials, but don't log in yet + const preliminaryUser = await handleEmailLogin(email, password); + // if the user is an owner, continue with the login + if (preliminaryUser?.globalRole?.name === 'owner') { + user = preliminaryUser; + } else { + // TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow + // if (doRedirectUsersFromLoginToSsoFlow()) { + res.redirect(SamlUrls.restInitSSO); + return; + // return withFeatureFlags(this.postHog, sanitizeUser(preliminaryUser)); + // } else { + // throw new AuthError( + // 'Login with username and password is disabled due to SAML being the default authentication method. Please use SAML to log in.', + // ); + // } + } + } else { + user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password)); + } if (user) { await issueCookie(res, user); return withFeatureFlags(this.postHog, sanitizeUser(user)); diff --git a/packages/cli/src/databases/entities/AuthIdentity.ts b/packages/cli/src/databases/entities/AuthIdentity.ts index cfe8ab270..706920628 100644 --- a/packages/cli/src/databases/entities/AuthIdentity.ts +++ b/packages/cli/src/databases/entities/AuthIdentity.ts @@ -2,7 +2,7 @@ import { Column, Entity, ManyToOne, PrimaryColumn, Unique } from 'typeorm'; import { AbstractEntity } from './AbstractEntity'; import { User } from './User'; -export type AuthProviderType = 'ldap' | 'email'; //| 'saml' | 'google'; +export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google'; @Entity() @Unique(['providerId', 'providerType']) diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 070b86dd0..34c76ea59 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -18,6 +18,7 @@ import { } from '@/UserManagement/UserManagementHelper'; import type { Repository } from 'typeorm'; import type { User } from '@db/entities/User'; +import { SamlUrls } from '../sso/saml/constants'; const jwtFromRequest = (req: Request) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -95,6 +96,9 @@ export const setupAuthMiddlewares = ( req.url.startsWith(`/${restEndpoint}/change-password`) || req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) || req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`) || + req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.metadata}`) || + req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.initSSO}`) || + req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.acs}`) || isAuthExcluded(req.url, ignoredEndpoints) ) { return next(); diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts new file mode 100644 index 000000000..16565fa71 --- /dev/null +++ b/packages/cli/src/sso/saml/constants.ts @@ -0,0 +1,25 @@ +export class SamlUrls { + static readonly samlRESTRoot = '/rest/sso/saml'; + + static readonly initSSO = '/initsso'; + + static readonly restInitSSO = this.samlRESTRoot + this.initSSO; + + static readonly acs = '/acs'; + + static readonly restAcs = this.samlRESTRoot + this.acs; + + static readonly metadata = '/metadata'; + + static readonly restMetadata = this.samlRESTRoot + this.metadata; + + static readonly config = '/config'; + + static readonly restConfig = this.samlRESTRoot + this.config; + + static readonly defaultRedirect = '/'; + + static readonly samlOnboarding = '/settings/personal'; // TODO:SAML: implement signup page +} + +export const SAML_PREFERENCES_DB_KEY = 'features.saml'; diff --git a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts new file mode 100644 index 000000000..bcd1005e1 --- /dev/null +++ b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts @@ -0,0 +1,24 @@ +import type { RequestHandler } from 'express'; +import type { AuthenticatedRequest } from '../../../requests'; +import { isSamlCurrentAuthenticationMethod } from '../../ssoHelpers'; +import { isSamlEnabled, isSamlLicensed } from '../samlHelpers'; + +export const samlLicensedOwnerMiddleware: RequestHandler = ( + req: AuthenticatedRequest, + res, + next, +) => { + if (isSamlLicensed() && req.user?.globalRole.name === 'owner') { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; + +export const samlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => { + if (isSamlEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts new file mode 100644 index 000000000..9879cbe82 --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts @@ -0,0 +1,105 @@ +import express from 'express'; +import { + samlLicensedAndEnabledMiddleware, + samlLicensedOwnerMiddleware, +} from '../middleware/samlEnabledMiddleware'; +import { SamlService } from '../saml.service.ee'; +import { SamlUrls } from '../constants'; +import type { SamlConfiguration } from '../types/requests'; +import { AuthError } from '../../../ResponseHelper'; +import { issueCookie } from '../../../auth/jwt'; + +export const samlControllerProtected = express.Router(); + +/** + * GET /sso/saml/config + * Return SAML config + */ +samlControllerProtected.get( + SamlUrls.config, + samlLicensedOwnerMiddleware, + async (req: SamlConfiguration.Read, res: express.Response) => { + const prefs = await SamlService.getInstance().getSamlPreferences(); + return res.send(prefs); + }, +); + +/** + * POST /sso/saml/config + * Return SAML config + */ +samlControllerProtected.post( + SamlUrls.config, + samlLicensedOwnerMiddleware, + async (req: SamlConfiguration.Update, res: express.Response) => { + const result = await SamlService.getInstance().setSamlPreferences({ + metadata: req.body.metadata, + mapping: req.body.mapping, + }); + return res.send(result); + }, +); + +/** + * GET /sso/saml/acs + * Assertion Consumer Service endpoint + */ +samlControllerProtected.get( + SamlUrls.acs, + samlLicensedAndEnabledMiddleware, + async (req: express.Request, res: express.Response) => { + const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'redirect'); + if (loginResult) { + if (loginResult.authenticatedUser) { + await issueCookie(res, loginResult.authenticatedUser); + if (loginResult.onboardingRequired) { + return res.redirect(SamlUrls.samlOnboarding); + } else { + return res.redirect(SamlUrls.defaultRedirect); + } + } + } + throw new AuthError('SAML Authentication failed'); + }, +); + +/** + * POST /sso/saml/acs + * Assertion Consumer Service endpoint + */ +samlControllerProtected.post( + SamlUrls.acs, + samlLicensedAndEnabledMiddleware, + async (req: express.Request, res: express.Response) => { + const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'post'); + if (loginResult) { + if (loginResult.authenticatedUser) { + await issueCookie(res, loginResult.authenticatedUser); + if (loginResult.onboardingRequired) { + return res.redirect(SamlUrls.samlOnboarding); + } else { + return res.redirect(SamlUrls.defaultRedirect); + } + } + } + throw new AuthError('SAML Authentication failed'); + }, +); + +/** + * GET /sso/saml/initsso + * Access URL for implementing SP-init SSO + */ +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); + } else { + throw new AuthError('SAML redirect failed, please check your SAML configuration.'); + } + }, +); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts new file mode 100644 index 000000000..33e9427f7 --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts @@ -0,0 +1,17 @@ +import express from 'express'; +import { SamlUrls } from '../constants'; +import { getServiceProviderInstance } from '../serviceProvider.ee'; + +/** + * SSO Endpoints that are public + */ + +export const samlControllerPublic = express.Router(); + +/** + * GET /sso/saml/metadata + * Return Service Provider metadata + */ +samlControllerPublic.get(SamlUrls.metadata, async (req: express.Request, res: express.Response) => { + return res.header('Content-Type', 'text/xml').send(getServiceProviderInstance().getMetadata()); +}); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts new file mode 100644 index 000000000..b5a2fe637 --- /dev/null +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -0,0 +1,228 @@ +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 { getServiceProviderInstance } from './serviceProvider.ee'; +import type { SamlUserAttributes } from './types/samlUserAttributes'; +import type { SamlAttributeMapping } from './types/samlAttributeMapping'; +import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers'; +import type { SamlPreferences } from './types/samlPreferences'; +import { SAML_PREFERENCES_DB_KEY } from './constants'; +import type { IdentityProviderInstance } from 'samlify'; +import { IdentityProvider } from 'samlify'; +import { + createUserFromSamlAttributes, + getMappedSamlAttributesFromFlowResult, + updateUserFromSamlAttributes, +} from './samlHelpers'; + +export class SamlService { + private static instance: SamlService; + + private identityProviderInstance: IdentityProviderInstance | undefined; + + private _attributeMapping: SamlAttributeMapping = { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', + lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', + userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + }; + + public get attributeMapping(): SamlAttributeMapping { + return this._attributeMapping; + } + + public set attributeMapping(mapping: SamlAttributeMapping) { + // TODO:SAML: add validation + this._attributeMapping = mapping; + } + + private _metadata = ''; + + public get metadata(): string { + return this._metadata; + } + + public set metadata(metadata: string) { + this._metadata = metadata; + } + + constructor() { + this.loadSamlPreferences() + .then(() => { + LoggerProxy.debug('Initializing SAML service'); + }) + .catch(() => { + LoggerProxy.error('Error initializing SAML service'); + }); + } + + static getInstance(): SamlService { + if (!SamlService.instance) { + SamlService.instance = new SamlService(); + } + return SamlService.instance; + } + + async init(): Promise { + await this.loadSamlPreferences(); + } + + getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { + if (this.identityProviderInstance === undefined || forceRecreate) { + this.identityProviderInstance = IdentityProvider({ + metadata: this.metadata, + }); + } + + return this.identityProviderInstance; + } + + getRedirectLoginRequestUrl(): string { + const loginRequest = getServiceProviderInstance().createLoginRequest( + this.getIdentityProviderInstance(), + 'redirect', + ); + //TODO:SAML: debug logging + LoggerProxy.debug(loginRequest.context); + return loginRequest.context; + } + + async handleSamlLogin( + req: express.Request, + binding: 'post' | 'redirect', + ): Promise< + | { + authenticatedUser: User | undefined; + attributes: SamlUserAttributes; + onboardingRequired: boolean; + } + | undefined + > { + const attributes = await this.getAttributesFromLoginResponse(req, binding); + if (attributes.email) { + const user = await Db.collections.User.findOne({ + where: { email: attributes.email }, + relations: ['globalRole', 'authIdentities'], + }); + if (user) { + // Login path for existing users that are fully set up + if ( + user.authIdentities.find( + (e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName, + ) + ) { + return { + authenticatedUser: user, + attributes, + onboardingRequired: false, + }; + } else { + // Login path for existing users that are NOT fully set up for SAML + const updatedUser = await updateUserFromSamlAttributes(user, attributes); + return { + authenticatedUser: updatedUser, + attributes, + onboardingRequired: true, + }; + } + } else { + // New users to be created JIT based on SAML attributes + if (isSsoJustInTimeProvisioningEnabled()) { + const newUser = await createUserFromSamlAttributes(attributes); + return { + authenticatedUser: newUser, + attributes, + onboardingRequired: true, + }; + } + } + } + return undefined; + } + + async getSamlPreferences(): Promise { + return { + mapping: this.attributeMapping, + metadata: this.metadata, + }; + } + + async setSamlPreferences(prefs: SamlPreferences): Promise { + this.attributeMapping = prefs.mapping; + this.metadata = prefs.metadata; + this.getIdentityProviderInstance(true); + await this.saveSamlPreferences(); + } + + async loadSamlPreferences(): Promise { + const samlPreferences = await Db.collections.Settings.findOne({ + where: { key: SAML_PREFERENCES_DB_KEY }, + }); + if (samlPreferences) { + const prefs = jsonParse(samlPreferences.value); + if (prefs) { + this.attributeMapping = prefs.mapping; + this.metadata = prefs.metadata; + } + return prefs; + } + return; + } + + async saveSamlPreferences(): Promise { + const samlPreferences = await Db.collections.Settings.findOne({ + where: { key: SAML_PREFERENCES_DB_KEY }, + }); + if (samlPreferences) { + samlPreferences.value = JSON.stringify({ + mapping: this.attributeMapping, + metadata: this.metadata, + }); + samlPreferences.loadOnStartup = true; + await Db.collections.Settings.save(samlPreferences); + } else { + await Db.collections.Settings.save({ + key: SAML_PREFERENCES_DB_KEY, + value: JSON.stringify({ + mapping: this.attributeMapping, + metadata: this.metadata, + }), + loadOnStartup: true, + }); + } + } + + async getAttributesFromLoginResponse( + req: express.Request, + binding: 'post' | 'redirect', + ): Promise { + let parsedSamlResponse; + try { + parsedSamlResponse = await getServiceProviderInstance().parseLoginResponse( + this.getIdentityProviderInstance(), + binding, + req, + ); + } catch (error) { + throw error; + // throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); + } + const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( + parsedSamlResponse, + this.attributeMapping, + ); + if (!attributes) { + throw new AuthError('SAML Authentication failed. Invalid SAML response.'); + } + if (!attributes.email && missingAttributes.length > 0) { + throw new AuthError( + `SAML Authentication failed. Invalid SAML response (missing attributes: ${missingAttributes.join( + ', ', + )}).`, + ); + } + return attributes; + } +} diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts new file mode 100644 index 000000000..20733e1fe --- /dev/null +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -0,0 +1,136 @@ +import config from '@/config'; +import * as Db from '@/Db'; +import { AuthIdentity } from '../../databases/entities/AuthIdentity'; +import { User } from '../../databases/entities/User'; +import { getLicense } from '../../License'; +import { AuthError } from '../../ResponseHelper'; +import { hashPassword, isUserManagementEnabled } from '../../UserManagement/UserManagementHelper'; +import type { SamlPreferences } from './types/samlPreferences'; +import type { SamlUserAttributes } from './types/samlUserAttributes'; +import type { FlowResult } from 'samlify/types/src/flow'; +import type { SamlAttributeMapping } from './types/samlAttributeMapping'; +/** + * Check whether the SAML feature is licensed and enabled in the instance + */ +export function isSamlEnabled(): boolean { + return config.getEnv('sso.saml.enabled'); +} + +export function isSamlLicensed(): boolean { + const license = getLicense(); + return ( + isUserManagementEnabled() && + (license.isSamlEnabled() || config.getEnv('enterprise.features.saml')) + ); +} + +export const isSamlPreferences = (candidate: unknown): candidate is SamlPreferences => { + const o = candidate as SamlPreferences; + return typeof o === 'object' && typeof o.metadata === 'string' && typeof o.mapping === 'object'; +}; + +export function generatePassword(): string { + const length = 18; + const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const charsetNoNumbers = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const randomNumber = Math.floor(Math.random() * 10); + const randomUpper = charset.charAt(Math.floor(Math.random() * charsetNoNumbers.length)); + const randomNumberPosition = Math.floor(Math.random() * length); + const randomUpperPosition = Math.floor(Math.random() * length); + let password = ''; + for (let i = 0, n = charset.length; i < length; ++i) { + password += charset.charAt(Math.floor(Math.random() * n)); + } + password = + password.substring(0, randomNumberPosition) + + randomNumber.toString() + + password.substring(randomNumberPosition); + password = + password.substring(0, randomUpperPosition) + + randomUpper + + password.substring(randomUpperPosition); + return password; +} + +export async function createUserFromSamlAttributes(attributes: SamlUserAttributes): Promise { + const user = new User(); + const authIdentity = new AuthIdentity(); + user.email = attributes.email; + user.firstName = attributes.firstName; + user.lastName = attributes.lastName; + user.globalRole = await Db.collections.Role.findOneOrFail({ + where: { name: 'member', scope: 'global' }, + }); + // generates a password that is not used or known to the user + user.password = await hashPassword(generatePassword()); + authIdentity.providerId = attributes.userPrincipalName; + authIdentity.providerType = 'saml'; + authIdentity.user = user; + const resultAuthIdentity = await Db.collections.AuthIdentity.save(authIdentity); + if (!resultAuthIdentity) throw new AuthError('Could not create AuthIdentity'); + user.authIdentities = [authIdentity]; + const resultUser = await Db.collections.User.save(user); + if (!resultUser) throw new AuthError('Could not create User'); + return resultUser; +} + +export async function updateUserFromSamlAttributes( + user: User, + attributes: SamlUserAttributes, +): Promise { + if (!attributes.email) throw new AuthError('Email is required to update user'); + if (!user) throw new AuthError('User not found'); + let samlAuthIdentity = user?.authIdentities.find((e) => e.providerType === 'saml'); + if (!samlAuthIdentity) { + samlAuthIdentity = new AuthIdentity(); + samlAuthIdentity.providerId = attributes.userPrincipalName; + samlAuthIdentity.providerType = 'saml'; + samlAuthIdentity.user = user; + user.authIdentities.push(samlAuthIdentity); + } else { + samlAuthIdentity.providerId = attributes.userPrincipalName; + } + await Db.collections.AuthIdentity.save(samlAuthIdentity); + user.firstName = attributes.firstName; + user.lastName = attributes.lastName; + const resultUser = await Db.collections.User.save(user); + if (!resultUser) throw new AuthError('Could not create User'); + return resultUser; +} + +type GetMappedSamlReturn = { + attributes: SamlUserAttributes | undefined; + missingAttributes: string[]; +}; + +export function getMappedSamlAttributesFromFlowResult( + flowResult: FlowResult, + attributeMapping: SamlAttributeMapping, +): GetMappedSamlReturn { + const result: GetMappedSamlReturn = { + attributes: undefined, + missingAttributes: [] as string[], + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (flowResult?.extract?.attributes) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const attributes = flowResult.extract.attributes as { [key: string]: string }; + // TODO:SAML: fetch mapped attributes from flowResult.extract.attributes and create or login user + const email = attributes[attributeMapping.email]; + const firstName = attributes[attributeMapping.firstName]; + const lastName = attributes[attributeMapping.lastName]; + const userPrincipalName = attributes[attributeMapping.userPrincipalName]; + + result.attributes = { + email, + firstName, + lastName, + userPrincipalName, + }; + if (!email) result.missingAttributes.push(attributeMapping.email); + if (!userPrincipalName) result.missingAttributes.push(attributeMapping.userPrincipalName); + if (!firstName) result.missingAttributes.push(attributeMapping.firstName); + if (!lastName) result.missingAttributes.push(attributeMapping.lastName); + } + return result; +} diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts new file mode 100644 index 000000000..b99bc71a1 --- /dev/null +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -0,0 +1,39 @@ +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; +import type { ServiceProviderInstance } from 'samlify'; +import { ServiceProvider, setSchemaValidator } from 'samlify'; +import { SamlUrls } from './constants'; + +let serviceProviderInstance: ServiceProviderInstance | undefined; + +setSchemaValidator({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validate: async (response: string) => { + // TODO:SAML: implment validation + return Promise.resolve('skipped'); + }, +}); + +const metadata = ` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; + +export function getServiceProviderInstance(): ServiceProviderInstance { + if (serviceProviderInstance === undefined) { + serviceProviderInstance = ServiceProvider({ + metadata, + }); + } + + return serviceProviderInstance; +} diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts new file mode 100644 index 000000000..c9beab0c2 --- /dev/null +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -0,0 +1,7 @@ +import type { AuthenticatedRequest } from '../../../requests'; +import type { SamlPreferences } from './samlPreferences'; + +export declare namespace SamlConfiguration { + type Read = AuthenticatedRequest<{}, {}, {}, {}>; + type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; +} diff --git a/packages/cli/src/sso/saml/types/samlAttributeMapping.ts b/packages/cli/src/sso/saml/types/samlAttributeMapping.ts new file mode 100644 index 000000000..af7dd76e2 --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlAttributeMapping.ts @@ -0,0 +1,6 @@ +export interface SamlAttributeMapping { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +} diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts new file mode 100644 index 000000000..d57f10b9a --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -0,0 +1,7 @@ +import type { SamlAttributeMapping } from './samlAttributeMapping'; + +export interface SamlPreferences { + mapping: SamlAttributeMapping; + metadata: string; + //TODO:SAML: add fields for separate SAML settins to generate metadata from +} diff --git a/packages/cli/src/sso/saml/types/samlUserAttributes.ts b/packages/cli/src/sso/saml/types/samlUserAttributes.ts new file mode 100644 index 000000000..fa3c849f6 --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlUserAttributes.ts @@ -0,0 +1,6 @@ +export interface SamlUserAttributes { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +} diff --git a/packages/cli/src/sso/ssoHelpers.ts b/packages/cli/src/sso/ssoHelpers.ts new file mode 100644 index 000000000..f00ddc0fe --- /dev/null +++ b/packages/cli/src/sso/ssoHelpers.ts @@ -0,0 +1,13 @@ +import config from '@/config'; + +export function isSamlCurrentAuthenticationMethod(): boolean { + return config.getEnv('userManagement.authenticationMethod') === 'saml'; +} + +export function isSsoJustInTimeProvisioningEnabled(): boolean { + return config.getEnv('sso.justInTimeProvisioning'); +} + +export function doRedirectUsersFromLoginToSsoFlow(): boolean { + return config.getEnv('sso.redirectLoginToSso'); +} diff --git a/packages/workflow/src/Authentication.ts b/packages/workflow/src/Authentication.ts new file mode 100644 index 000000000..b743e1eca --- /dev/null +++ b/packages/workflow/src/Authentication.ts @@ -0,0 +1 @@ +export type AuthenticationMethod = 'none' | 'email' | 'ldap' | 'saml'; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 2cd063525..e956f38a2 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -4,6 +4,7 @@ import * as NodeHelpers from './NodeHelpers'; import * as ObservableObject from './ObservableObject'; import * as TelemetryHelpers from './TelemetryHelpers'; +export * from './Authentication'; export * from './Cron'; export * from './DeferredPromise'; export * from './Interfaces'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7f9d7d3..6375d3ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,7 @@ importers: reflect-metadata: ^0.1.13 replacestream: ^4.0.3 run-script-os: ^1.0.7 + samlify: ^2.8.9 semver: ^7.3.8 shelljs: ^0.8.5 source-map-support: ^0.5.21 @@ -328,6 +329,7 @@ importers: psl: 1.9.0 reflect-metadata: 0.1.13 replacestream: 4.0.3 + samlify: 2.8.9 semver: 7.3.8 shelljs: 0.8.5 source-map-support: 0.5.21 @@ -1215,6 +1217,15 @@ packages: z-schema: 4.2.4 dev: true + /@authenio/xml-encryption/2.0.2: + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + dependencies: + '@xmldom/xmldom': 0.8.6 + escape-html: 1.0.3 + xpath: 0.0.32 + dev: false + /@aw-web-design/x-default-browser/1.4.88: resolution: {integrity: sha512-AkEmF0wcwYC2QkhK703Y83fxWARttIWXDmQN8+cof8FmFZ5BRhnNXGymeb1S73bOCLfWjYELxtujL56idCN/XA==} hasBin: true @@ -6886,6 +6897,11 @@ packages: '@xtuc/long': 4.2.2 dev: true + /@xmldom/xmldom/0.8.6: + resolution: {integrity: sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==} + engines: {node: '>=10.0.0'} + dev: false + /@xtuc/ieee754/1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} dev: true @@ -8242,7 +8258,6 @@ packages: /camelcase/6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - dev: true /caniuse-lite/1.0.30001445: resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==} @@ -15539,7 +15554,6 @@ packages: /node-forge/1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} - dev: true /node-gyp-build-optional-packages/5.0.3: resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==} @@ -16132,6 +16146,10 @@ packages: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} dev: false + /pako/1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + dev: false + /param-case/3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: @@ -17947,6 +17965,21 @@ packages: /safer-buffer/2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + /samlify/2.8.9: + resolution: {integrity: sha512-+HHxkBweHwWEEiFWelGhTTX2Zv/7Tjh6xbZPYUURe7JWp1N9cO2jUOiSb13gTzCEXtffye+Ld7M/f2gCU5+B2Q==} + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.6 + camelcase: 6.3.0 + node-forge: 1.3.1 + node-rsa: 1.1.1 + pako: 1.0.11 + uuid: 8.3.2 + xml: 1.0.1 + xml-crypto: 3.0.1 + xpath: 0.0.32 + dev: false + /sanitize-html/2.9.0: resolution: {integrity: sha512-KY1hpSbqFNcpoLf+nP7iStbP5JfQZ2Nd19ZEE7qFsQqRdp+sO5yX/e5+HoG9puFAcSTEpzQuihfKUltDcLlQjg==} dependencies: @@ -21568,11 +21601,23 @@ packages: word: 0.3.0 dev: false + /xml-crypto/3.0.1: + resolution: {integrity: sha512-7XrwB3ujd95KCO6+u9fidb8ajvRJvIfGNWD0XLJoTWlBKz+tFpUzEYxsN+Il/6/gHtEs1RgRh2RH+TzhcWBZUw==} + engines: {node: '>=0.4.0'} + dependencies: + '@xmldom/xmldom': 0.8.6 + xpath: 0.0.32 + dev: false + /xml-name-validator/4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} dev: true + /xml/1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + dev: false + /xml2js/0.4.19: resolution: {integrity: sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==} dependencies: @@ -21602,6 +21647,11 @@ packages: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: true + /xpath/0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + dev: false + /xregexp/2.0.0: resolution: {integrity: sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==} dev: false