diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d8d26e98e1..b29636ab99 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -153,9 +153,8 @@ import { isAdvancedExecutionFiltersEnabled, } from './executions/executionHelpers'; import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers'; -import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee'; +import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; -import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee'; import { LdapManager } from './Ldap/LdapManager.ee'; const exec = promisify(callbackExec); @@ -370,6 +369,7 @@ class Server extends AbstractServer { const internalHooks = Container.get(InternalHooks); const mailer = getMailerInstance(); const postHog = this.postHog; + const samlService = SamlService.getInstance(); const controllers: object[] = [ new AuthController({ config, internalHooks, repositories, logger, postHog }), @@ -389,6 +389,7 @@ class Server extends AbstractServer { logger, postHog, }), + new SamlController(samlService), ]; if (isLdapEnabled()) { @@ -514,9 +515,6 @@ class Server extends AbstractServer { } } - 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 diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts new file mode 100644 index 0000000000..ff69e1ac5b --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -0,0 +1,137 @@ +import express from 'express'; +import { Get, Post, RestController } from '../../../decorators'; +import { SamlUrls } from '../constants'; +import { + samlLicensedAndEnabledMiddleware, + samlLicensedOwnerMiddleware, +} from '../middleware/samlEnabledMiddleware'; +import { SamlService } from '../saml.service.ee'; +import { SamlConfiguration } from '../types/requests'; +import { AuthError, BadRequestError } from '../../../ResponseHelper'; +import { getInitSSOFormView } from '../views/initSsoPost'; +import { getInitSSOPostView } from '../views/initSsoRedirect'; +import { issueCookie } from '../../../auth/jwt'; +import { validate } from 'class-validator'; +import type { PostBindingContext } from 'samlify/types/src/entity'; + +@RestController('/sso/saml') +export class SamlController { + constructor(private samlService: SamlService) {} + + @Get(SamlUrls.metadata) + async getServiceProviderMetadata(req: express.Request, res: express.Response) { + return res + .header('Content-Type', 'text/xml') + .send(this.samlService.getServiceProviderInstance().getMetadata()); + } + + /** + * GET /sso/saml/config + * Return SAML config + */ + @Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) + async configGet(req: SamlConfiguration.Read, res: express.Response) { + const prefs = this.samlService.samlPreferences; + return res.send(prefs); + } + + /** + * POST /sso/saml/config + * Set SAML config + */ + @Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) + async configPost(req: SamlConfiguration.Update, res: express.Response) { + const validationResult = await validate(req.body); + if (validationResult.length === 0) { + const result = await this.samlService.setSamlPreferences(req.body); + return res.send(result); + } else { + throw new BadRequestError( + 'Body is not a valid SamlPreferences object: ' + + validationResult.map((e) => e.toString()).join(','), + ); + } + } + + /** + * POST /sso/saml/config/toggle + * Set SAML config + */ + @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedOwnerMiddleware] }) + async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { + if (req.body.loginEnabled === undefined) { + throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); + } + await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled }); + return res.sendStatus(200); + } + + /** + * GET /sso/saml/acs + * Assertion Consumer Service endpoint + */ + @Get(SamlUrls.acs, { middlewares: [samlLicensedAndEnabledMiddleware] }) + async acsGet(req: express.Request, res: express.Response) { + const loginResult = await this.samlService.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 + */ + @Post(SamlUrls.acs, { middlewares: [samlLicensedAndEnabledMiddleware] }) + async acsPost(req: express.Request, res: express.Response) { + const loginResult = await this.samlService.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 + */ + @Get(SamlUrls.initSSO, { middlewares: [samlLicensedAndEnabledMiddleware] }) + async initSsoGet(req: express.Request, res: express.Response) { + const result = this.samlService.getLoginRequestUrl(); + if (result?.binding === 'redirect') { + // forced client side redirect through the use of a javascript redirect + return res.send(getInitSSOPostView(result.context)); + // TODO:SAML: If we want the frontend to handle the redirect, we will send the redirect URL instead: + // 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.'); + } + } + + /** + * GET /sso/saml/config/test + * Test SAML config + */ + @Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] }) + async configTestGet(req: express.Request, res: express.Response) { + const testResult = await this.samlService.testSamlConnection(); + return res.send(testResult); + } +} 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 deleted file mode 100644 index 8f142d7484..0000000000 --- a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts +++ /dev/null @@ -1,147 +0,0 @@ -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, BadRequestError } from '@/ResponseHelper'; -import { issueCookie } from '../../../auth/jwt'; -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(); - -/** - * GET /sso/saml/config - * Return SAML config - */ -samlControllerProtected.get( - SamlUrls.config, - samlLicensedOwnerMiddleware, - (req: SamlConfiguration.Read, res: express.Response) => { - const prefs = SamlService.getInstance().samlPreferences; - return res.send(prefs); - }, -); - -/** - * POST /sso/saml/config - * Set SAML config - */ -samlControllerProtected.post( - SamlUrls.config, - samlLicensedOwnerMiddleware, - async (req: SamlConfiguration.Update, res: express.Response) => { - 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 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) { - throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); - } - await SamlService.getInstance().setSamlPreferences({ loginEnabled: req.body.loginEnabled }); - res.sendStatus(200); - }, -); - -/** - * 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 result = SamlService.getInstance().getLoginRequestUrl(); - if (result?.binding === 'redirect') { - // forced client side redirect through the use of a javascript redirect - return res.send(getInitSSOPostView(result.context)); - // TODO:SAML: If we want the frontend to handle the redirect, we will send the redirect URL instead: - // 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.'); - } - }, -); - -/** - * GET /sso/saml/config/test - * Test SAML config - */ -samlControllerProtected.get( - SamlUrls.configTest, - samlLicensedOwnerMiddleware, - async (req: express.Request, res: express.Response) => { - const testResult = await SamlService.getInstance().testSamlConnection(); - return res.send(testResult); - }, -); 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 deleted file mode 100644 index 999b7f2df2..0000000000 --- a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts +++ /dev/null @@ -1,19 +0,0 @@ -import express from 'express'; -import { SamlUrls } from '../constants'; -import { SamlService } from '../saml.service.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(SamlService.getInstance().getServiceProviderInstance().getMetadata()); -}); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 523b904f0d..d3d1188210 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -48,6 +48,13 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, + signatureConfig: { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }, }; public get samlPreferences(): SamlPreferences { @@ -189,6 +196,8 @@ export class SamlService { this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; this._samlPreferences.ignoreSSL = prefs.ignoreSSL ?? this._samlPreferences.ignoreSSL; this._samlPreferences.acsBinding = prefs.acsBinding ?? this._samlPreferences.acsBinding; + this._samlPreferences.signatureConfig = + prefs.signatureConfig ?? this._samlPreferences.signatureConfig; this._samlPreferences.authnRequestsSigned = prefs.authnRequestsSigned ?? this._samlPreferences.authnRequestsSigned; this._samlPreferences.wantAssertionsSigned = diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index fb9e0a3b7f..841cea5719 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -10,7 +10,11 @@ import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { FlowResult } from 'samlify/types/src/flow'; import type { SamlAttributeMapping } from './types/samlAttributeMapping'; import { SAML_ENTERPRISE_FEATURE_ENABLED, SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; -import { isSamlCurrentAuthenticationMethod } from '../ssoHelpers'; +import { + isEmailCurrentAuthenticationMethod, + isSamlCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '../ssoHelpers'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -22,8 +26,17 @@ export function getSamlLoginLabel(): string { return config.getEnv(SAML_LOGIN_LABEL); } +// can only toggle between email and saml, not directly to e.g. ldap export function setSamlLoginEnabled(enabled: boolean): void { - config.set(SAML_LOGIN_ENABLED, enabled); + if (enabled) { + if (isEmailCurrentAuthenticationMethod()) { + config.set(SAML_LOGIN_ENABLED, true); + setCurrentAuthenticationMethod('saml'); + } + } else { + config.set(SAML_LOGIN_ENABLED, false); + setCurrentAuthenticationMethod('email'); + } } export function setSamlLoginLabel(label: string): void { diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index 796d64a343..4c80af3290 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -15,6 +15,7 @@ export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProvi authnRequestsSigned: prefs.authnRequestsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned, wantMessageSigned: prefs.wantMessageSigned, + signatureConfig: prefs.signatureConfig, nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'], assertionConsumerService: [ { diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index d013d0ed4e..c5c72bcd0f 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -1,4 +1,5 @@ import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { SignatureConfig } from 'samlify/types/src/types'; import { SamlLoginBinding } from '.'; import { SamlAttributeMapping } from './samlAttributeMapping'; @@ -46,4 +47,14 @@ export class SamlPreferences { @IsString() @IsOptional() acsBinding?: SamlLoginBinding = 'post'; + + @IsObject() + @IsOptional() + signatureConfig?: SignatureConfig = { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }; } diff --git a/packages/cli/src/sso/ssoHelpers.ts b/packages/cli/src/sso/ssoHelpers.ts index f296feddcb..bf87952818 100644 --- a/packages/cli/src/sso/ssoHelpers.ts +++ b/packages/cli/src/sso/ssoHelpers.ts @@ -5,6 +5,10 @@ export function isSamlCurrentAuthenticationMethod(): boolean { return config.getEnv('userManagement.authenticationMethod') === 'saml'; } +export function isEmailCurrentAuthenticationMethod(): boolean { + return config.getEnv('userManagement.authenticationMethod') === 'email'; +} + export function isSsoJustInTimeProvisioningEnabled(): boolean { return config.getEnv('sso.justInTimeProvisioning'); }