diff --git a/app/meteor-accounts-saml/server/definition/IAttributeMapping.ts b/app/meteor-accounts-saml/server/definition/IAttributeMapping.ts new file mode 100644 index 000000000000..eda13d450d5e --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/IAttributeMapping.ts @@ -0,0 +1,17 @@ +export interface IAttributeMapping { + fieldName: string | Array; + regex?: string; + template?: string; +} + +export interface IUserDataMap { + customFields: Map; + attributeList: Set; + identifier: { + type: string; + attribute?: string; + }; + email: IAttributeMapping; + username: IAttributeMapping; + name: IAttributeMapping; +} diff --git a/app/meteor-accounts-saml/server/definition/IAuthorizeRequestVariables.ts b/app/meteor-accounts-saml/server/definition/IAuthorizeRequestVariables.ts new file mode 100644 index 000000000000..d751a3b1883b --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/IAuthorizeRequestVariables.ts @@ -0,0 +1,10 @@ +export interface IAuthorizeRequestVariables extends Record { + newId: string; + instant: string; + callbackUrl: string; + entryPoint: string; + issuer: string; + identifierFormat: string; + authnContextComparison: string; + authnContext: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ILogoutRequestVariables.ts b/app/meteor-accounts-saml/server/definition/ILogoutRequestVariables.ts new file mode 100644 index 000000000000..f85f3d77deb8 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ILogoutRequestVariables.ts @@ -0,0 +1,9 @@ +export interface ILogoutRequestVariables extends Record { + newId: string; + instant: string; + idpSLORedirectURL: string; + issuer: string; + identifierFormat: string; + nameID: string; + sessionIndex: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ILogoutResponse.ts b/app/meteor-accounts-saml/server/definition/ILogoutResponse.ts new file mode 100644 index 000000000000..84de67af369f --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ILogoutResponse.ts @@ -0,0 +1,5 @@ +export interface ILogoutResponse { + id: string; + response: string; + inResponseToId: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ILogoutResponseVariables.ts b/app/meteor-accounts-saml/server/definition/ILogoutResponseVariables.ts new file mode 100644 index 000000000000..fa8a0a3829b2 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ILogoutResponseVariables.ts @@ -0,0 +1,10 @@ +export interface ILogoutResponseVariables extends Record { + newId: string; + instant: string; + idpSLORedirectURL: string; + issuer: string; + identifierFormat: string; + nameID: string; + sessionIndex: string; + inResponseToId: string; +} diff --git a/app/meteor-accounts-saml/server/definition/IMetadataVariables.ts b/app/meteor-accounts-saml/server/definition/IMetadataVariables.ts new file mode 100644 index 000000000000..b26b2b072df5 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/IMetadataVariables.ts @@ -0,0 +1,7 @@ +export interface IMetadataVariables extends Record { + issuer: string; + certificate: string; + identifierFormat: string; + callbackUrl: string; + sloLocation: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLAction.ts b/app/meteor-accounts-saml/server/definition/ISAMLAction.ts new file mode 100644 index 000000000000..1c8f52eb8063 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ISAMLAction.ts @@ -0,0 +1,5 @@ +export interface ISAMLAction { + actionName: string; + serviceName: string; + credentialToken: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLAssertion.ts b/app/meteor-accounts-saml/server/definition/ISAMLAssertion.ts new file mode 100644 index 000000000000..1744afe7b380 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ISAMLAssertion.ts @@ -0,0 +1,4 @@ +export interface ISAMLAssertion { + assertion: Element | Document; + xml: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts new file mode 100644 index 000000000000..b1b3f468ff51 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts @@ -0,0 +1,11 @@ +export interface ISAMLGlobalSettings { + generateUsername: boolean; + nameOverwrite: boolean; + mailOverwrite: boolean; + immutableProperty: string; + defaultUserRole: string; + roleAttributeName: string; + roleAttributeSync: boolean; + userDataFieldMap: string; + usernameNormalize: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLRequest.ts b/app/meteor-accounts-saml/server/definition/ISAMLRequest.ts new file mode 100644 index 000000000000..05eb26eb34de --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ISAMLRequest.ts @@ -0,0 +1,4 @@ +export interface ISAMLRequest { + id: string; + request: string; +} diff --git a/app/meteor-accounts-saml/server/definition/ISAMLUser.ts b/app/meteor-accounts-saml/server/definition/ISAMLUser.ts new file mode 100644 index 000000000000..7a643463f729 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/ISAMLUser.ts @@ -0,0 +1,23 @@ +export interface ISAMLUser { + customFields: Map; + emailList: Array; + fullName: string | null; + roles: Array; + eppn: string | null; + + username?: string; + language?: string; + channels?: Array; + samlLogin: { + provider: string | null; + idp: string; + idpSession: string; + nameID: string; + }; + + attributeList: Map; + identifier: { + type: string; + attribute?: string; + }; +} diff --git a/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts b/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts new file mode 100644 index 000000000000..db045b2e6c67 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts @@ -0,0 +1,28 @@ +export interface IServiceProviderOptions { + provider: string; + entryPoint: string; + idpSLORedirectURL: string; + issuer: string; + cert: string; + privateCert: string; + privateKey: string; + customAuthnContext: string; + authnContextComparison: string; + defaultUserRole: string; + roleAttributeName: string; + roleAttributeSync: boolean; + allowedClockDrift: number; + signatureValidationType: string; + identifierFormat: string; + nameIDPolicyTemplate: string; + authnContextTemplate: string; + authRequestTemplate: string; + logoutResponseTemplate: string; + logoutRequestTemplate: string; + metadataCertificateTemplate: string; + metadataTemplate: string; + callbackUrl: string; + + // The id attribute is filled midway through some operations + id?: string; +} diff --git a/app/meteor-accounts-saml/server/definition/callbacks.ts b/app/meteor-accounts-saml/server/definition/callbacks.ts new file mode 100644 index 000000000000..a9a2b7449b12 --- /dev/null +++ b/app/meteor-accounts-saml/server/definition/callbacks.ts @@ -0,0 +1,11 @@ +export interface ILogoutRequestValidateCallback { + (err: string | object | null, data?: Record | null): void; +} + +export interface ILogoutResponseValidateCallback { + (err: string | object | null, inResponseTo?: string | null): void; +} + +export interface IResponseValidateCallback { + (err: string | object | null, profile?: Record | null, loggedOut?: boolean): void; +} diff --git a/app/meteor-accounts-saml/server/index.js b/app/meteor-accounts-saml/server/index.js index b9c087f10847..8da4b6cc9c9b 100644 --- a/app/meteor-accounts-saml/server/index.js +++ b/app/meteor-accounts-saml/server/index.js @@ -1,2 +1,5 @@ -import './saml_rocketchat'; -import './saml_server'; +import './startup'; +import './loginHandler'; +import './listener'; +import './methods/samlLogout'; +import './methods/addSamlService'; diff --git a/app/meteor-accounts-saml/server/lib/SAML.ts b/app/meteor-accounts-saml/server/lib/SAML.ts new file mode 100644 index 000000000000..b17bb3d32611 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/SAML.ts @@ -0,0 +1,470 @@ +import { ServerResponse } from 'http'; + +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { Accounts } from 'meteor/accounts-base'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import fiber from 'fibers'; +import s from 'underscore.string'; + +import { settings } from '../../../settings/server'; +import { Users, Rooms, CredentialTokens } from '../../../models/server'; +import { IUser } from '../../../../definition/IUser'; +import { IIncomingMessage } from '../../../../definition/IIncomingMessage'; +import { _setUsername, createRoom, generateUsernameSuggestion, addUserToRoom } from '../../../lib/server/functions'; +import { SAMLServiceProvider } from './ServiceProvider'; +import { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; +import { ISAMLAction } from '../definition/ISAMLAction'; +import { ISAMLUser } from '../definition/ISAMLUser'; +import { SAMLUtils } from './Utils'; + +const showErrorMessage = function(res: ServerResponse, err: string): void { + res.writeHead(200, { + 'Content-Type': 'text/html', + }); + const content = `

Sorry, an annoying error occured

${ s.escapeHTML(err) }
`; + res.end(content, 'utf-8'); +}; + +export class SAML { + public static processRequest(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction): void { + // Skip everything if there's no service set by the saml middleware + if (!service) { + if (samlObject.actionName === 'metadata') { + showErrorMessage(res, `Unexpected SAML service ${ samlObject.serviceName }`); + return; + } + + throw new Error(`Unexpected SAML service ${ samlObject.serviceName }`); + } + + switch (samlObject.actionName) { + case 'metadata': + return this.processMetadataAction(res, service); + case 'logout': + return this.processLogoutAction(req, res, service); + case 'sloRedirect': + return this.processSLORedirectAction(req, res); + case 'authorize': + return this.processAuthorizeAction(res, service, samlObject); + case 'validate': + return this.processValidateAction(req, res, service, samlObject); + default: + throw new Error(`Unexpected SAML action ${ samlObject.actionName }`); + } + } + + public static hasCredential(credentialToken: string): boolean { + return CredentialTokens.findOneById(credentialToken) != null; + } + + public static retrieveCredential(credentialToken: string): Record | undefined { + // The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check. + const data = CredentialTokens.findOneById(credentialToken); + if (data) { + return data.userInfo; + } + } + + public static storeCredential(credentialToken: string, loginResult: object): void { + CredentialTokens.create(credentialToken, loginResult); + } + + public static insertOrUpdateSAMLUser(userObject: ISAMLUser): {userId: string; token: string} { + // @ts-ignore RegExp.escape is a meteor method + const escapeRegexp = (email: string): string => RegExp.escape(email); + const { roleAttributeSync, generateUsername, immutableProperty, nameOverwrite, mailOverwrite } = SAMLUtils.globalSettings; + + let customIdentifierMatch = false; + let customIdentifierAttributeName: string | null = null; + let user = null; + + // First, try searching by custom identifier + if (userObject.identifier.type === 'custom' && userObject.identifier.attribute && userObject.attributeList.has(userObject.identifier.attribute)) { + customIdentifierAttributeName = userObject.identifier.attribute; + + const query: Record = {}; + query[`services.saml.${ customIdentifierAttributeName }`] = userObject.attributeList.get(customIdentifierAttributeName); + user = Users.findOne(query); + + if (user) { + customIdentifierMatch = true; + } + } + + // Second, try searching by username or email (according to the immutableProperty setting) + if (!user) { + const expression = userObject.emailList.map((email) => `^${ escapeRegexp(email) }$`).join('|'); + const emailRegex = new RegExp(expression, 'i'); + + user = SAML.findUser(userObject.username, emailRegex); + } + + const emails = userObject.emailList.map((email) => ({ + address: email, + verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), + })); + const globalRoles = userObject.roles; + + let { username } = userObject; + + if (!user) { + const newUser: Record = { + name: userObject.fullName, + active: true, + globalRoles, + emails, + services: { + saml: { + provider: userObject.samlLogin.provider, + idp: userObject.samlLogin.idp, + }, + }, + }; + + if (customIdentifierAttributeName) { + newUser.services.saml[customIdentifierAttributeName] = userObject.attributeList.get(customIdentifierAttributeName); + } + + if (generateUsername === true) { + username = generateUsernameSuggestion(newUser); + } + + if (username) { + newUser.username = username; + newUser.name = newUser.name || SAML.guessNameFromUsername(username); + } + + if (userObject.language) { + const languages = TAPi18n.getLanguages(); + if (languages[userObject.language]) { + newUser.language = userObject.language; + } + } + + const userId = Accounts.insertUserDoc({}, newUser); + user = Users.findOne(userId); + + if (userObject.channels) { + SAML.subscribeToSAMLChannels(userObject.channels, user); + } + } + + // creating the token and adding to the user + const stampedToken = Accounts._generateStampedLoginToken(); + Users.addPersonalAccessTokenToUser({ + userId: user._id, + loginTokenObject: stampedToken, + }); + + const updateData: Record = { + 'services.saml.provider': userObject.samlLogin.provider, + 'services.saml.idp': userObject.samlLogin.idp, + 'services.saml.idpSession': userObject.samlLogin.idpSession, + 'services.saml.nameID': userObject.samlLogin.nameID, + }; + + // If the user was not found through the customIdentifier property, then update it's value + if (customIdentifierMatch === false && customIdentifierAttributeName) { + updateData[`services.saml.${ customIdentifierAttributeName }`] = userObject.attributeList.get(customIdentifierAttributeName); + } + + for (const [customField, value] of userObject.customFields) { + updateData[`customFields.${ customField }`] = value; + } + + // Overwrite mail if needed + if (mailOverwrite === true && (customIdentifierMatch === true || immutableProperty !== 'EMail')) { + updateData.emails = emails; + } + + // Overwrite fullname if needed + if (nameOverwrite === true) { + updateData.name = userObject.fullName; + } + + if (roleAttributeSync) { + updateData.roles = globalRoles; + } + + Users.update({ + _id: user._id, + }, { + $set: updateData, + }); + + if (username && username !== user.username) { + _setUsername(user._id, username); + } + + // sending token along with the userId + return { + userId: user._id, + token: stampedToken.token, + }; + } + + private static processMetadataAction(res: ServerResponse, service: IServiceProviderOptions): void { + try { + const serviceProvider = new SAMLServiceProvider(service); + + res.writeHead(200); + res.write(serviceProvider.generateServiceProviderMetadata()); + res.end(); + } catch (err) { + showErrorMessage(res, err); + } + } + + private static processLogoutAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void { + // This is where we receive SAML LogoutResponse + if (req.query.SAMLRequest) { + return this.processLogoutRequest(req, res, service); + } + + return this.processLogoutResponse(req, res, service); + } + + private static _logoutRemoveTokens(userId: string): void { + SAMLUtils.log(`Found user ${ userId }`); + + Users.unsetLoginTokens(userId); + Users.removeSamlServiceSession(userId); + } + + private static processLogoutRequest(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void { + const serviceProvider = new SAMLServiceProvider(service); + serviceProvider.validateLogoutRequest(req.query.SAMLRequest, (err, result) => { + if (err) { + console.error(err); + throw new Meteor.Error('Unable to Validate Logout Request'); + } + + if (!result) { + throw new Meteor.Error('Unable to process Logout Request: missing request data.'); + } + + let timeoutHandler: NodeJS.Timer | null = null; + const redirect = (url?: string | undefined): void => { + if (!timeoutHandler) { + // If the handler is null, then we already ended the response; + return; + } + + clearTimeout(timeoutHandler); + timeoutHandler = null; + + res.writeHead(302, { + Location: url || Meteor.absoluteUrl(), + }); + res.end(); + }; + + // Add a timeout to end the server response + timeoutHandler = setTimeout(() => { + // If we couldn't get a valid IdP url, let's redirect the user to our home so the browser doesn't hang on them. + redirect(); + }, 5000); + + fiber(() => { + try { + const cursor = Users.findBySAMLNameIdOrIdpSession(result.nameID, result.idpSession); + const count = cursor.count(); + if (count > 1) { + throw new Meteor.Error('Found multiple users matching SAML session'); + } + + if (count === 0) { + throw new Meteor.Error('Invalid logout request: no user associated with session.'); + } + + const loggedOutUser = cursor.fetch(); + this._logoutRemoveTokens(loggedOutUser[0]._id); + + const { response } = serviceProvider.generateLogoutResponse({ + nameID: result.nameID || '', + sessionIndex: result.idpSession || '', + inResponseToId: result.id || '', + }); + + serviceProvider.logoutResponseToUrl(response, (err, url) => { + if (err) { + console.error(err); + return redirect(); + } + + redirect(url); + }); + } catch (e) { + console.error(e); + redirect(); + } + }).run(); + }); + } + + private static processLogoutResponse(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void { + if (!req.query.SAMLResponse) { + SAMLUtils.error('Invalid LogoutResponse, missing SAMLResponse', req.query); + throw new Error('Invalid LogoutResponse received.'); + } + + const serviceProvider = new SAMLServiceProvider(service); + serviceProvider.validateLogoutResponse(req.query.SAMLResponse, (err, inResponseTo) => { + if (err) { + return; + } + + if (!inResponseTo) { + throw new Meteor.Error('Invalid logout request: no inResponseTo value.'); + } + + const logOutUser = (inResponseTo: string): void => { + SAMLUtils.log(`Logging Out user via inResponseTo ${ inResponseTo }`); + + const cursor = Users.findBySAMLInResponseTo(inResponseTo); + const count = cursor.count(); + if (count > 1) { + throw new Meteor.Error('Found multiple users matching SAML inResponseTo fields'); + } + + if (count === 0) { + throw new Meteor.Error('Invalid logout request: no user associated with inResponseTo.'); + } + + const loggedOutUser = cursor.fetch(); + this._logoutRemoveTokens(loggedOutUser[0]._id); + }; + + try { + fiber(() => logOutUser(inResponseTo)).run(); + } finally { + res.writeHead(302, { + Location: req.query.RelayState, + }); + res.end(); + } + }); + } + + private static processSLORedirectAction(req: IIncomingMessage, res: ServerResponse): void { + res.writeHead(302, { + // credentialToken here is the SAML LogOut Request that we'll send back to IDP + Location: req.query.redirect, + }); + res.end(); + } + + private static processAuthorizeAction(res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction): void { + service.id = samlObject.credentialToken; + + const serviceProvider = new SAMLServiceProvider(service); + serviceProvider.getAuthorizeUrl((err, url) => { + if (err) { + SAMLUtils.error('Unable to generate authorize url'); + SAMLUtils.error(err); + url = Meteor.absoluteUrl(); + } + + res.writeHead(302, { + Location: url, + }); + res.end(); + }); + } + + private static processValidateAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction): void { + const serviceProvider = new SAMLServiceProvider(service); + SAMLUtils.relayState = req.body.RelayState; + serviceProvider.validateResponse(req.body.SAMLResponse, (err, profile/* , loggedOut*/) => { + try { + if (err) { + SAMLUtils.error(err); + throw new Error('Unable to validate response url'); + } + + if (!profile) { + throw new Error('No user data collected from IdP response.'); + } + + let credentialToken = (profile.inResponseToId && profile.inResponseToId.value) || profile.inResponseToId || profile.InResponseTo || samlObject.credentialToken; + const loginResult = { + profile, + }; + + if (!credentialToken) { + // If the login was initiated by the IDP, then we don't have a credentialToken as there was no AuthorizeRequest on our side + // so we create a random token now to use the same url to end the login + // + // to test an IdP initiated login on localhost, use the following URL (assuming SimpleSAMLPHP on localhost:8080): + // http://localhost:8080/simplesaml/saml2/idp/SSOService.php?spentityid=http://localhost:3000/_saml/metadata/test-sp + credentialToken = Random.id(); + SAMLUtils.log('[SAML] Using random credentialToken: ', credentialToken); + } + + this.storeCredential(credentialToken, loginResult); + const url = `${ Meteor.absoluteUrl('home') }?saml_idp_credentialToken=${ credentialToken }`; + res.writeHead(302, { + Location: url, + }); + res.end(); + } catch (error) { + SAMLUtils.error(error); + res.writeHead(302, { + Location: Meteor.absoluteUrl(), + }); + res.end(); + } + }); + } + + private static findUser(username: string | undefined, emailRegex: RegExp): IUser | undefined { + const { globalSettings } = SAMLUtils; + + if (globalSettings.immutableProperty === 'Username') { + if (username) { + return Users.findOne({ + username, + }); + } + + return; + } + + return Users.findOne({ + 'emails.address': emailRegex, + }); + } + + private static guessNameFromUsername(username: string): string { + return username + .replace(/\W/g, ' ') + .replace(/\s(.)/g, (u) => u.toUpperCase()) + .replace(/^(.)/, (u) => u.toLowerCase()) + .replace(/^\w/, (u) => u.toUpperCase()); + } + + private static subscribeToSAMLChannels(channels: Array, user: IUser): void { + try { + for (let roomName of channels) { + roomName = roomName.trim(); + if (!roomName) { + continue; + } + + const room = Rooms.findOneByNameAndType(roomName, 'c', {}); + if (!room) { + // If the user doesn't have an username yet, we can't create new rooms for them + if (user.username) { + createRoom('c', roomName, user.username); + } + continue; + } + + addUserToRoom(room._id, user); + } + } catch (err) { + console.error(err); + } + } +} diff --git a/app/meteor-accounts-saml/server/lib/ServiceProvider.ts b/app/meteor-accounts-saml/server/lib/ServiceProvider.ts new file mode 100644 index 000000000000..3733821bcd55 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/ServiceProvider.ts @@ -0,0 +1,194 @@ +import zlib from 'zlib'; +import crypto from 'crypto'; +import querystring from 'querystring'; + +import { Meteor } from 'meteor/meteor'; + +import { SAMLUtils } from './Utils'; +import { AuthorizeRequest } from './generators/AuthorizeRequest'; +import { LogoutRequest } from './generators/LogoutRequest'; +import { LogoutResponse } from './generators/LogoutResponse'; +import { ServiceProviderMetadata } from './generators/ServiceProviderMetadata'; +import { LogoutRequestParser } from './parsers/LogoutRequest'; +import { LogoutResponseParser } from './parsers/LogoutResponse'; +import { ResponseParser } from './parsers/Response'; +import { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; +import { ISAMLRequest } from '../definition/ISAMLRequest'; +import { ILogoutResponse } from '../definition/ILogoutResponse'; +import { + ILogoutRequestValidateCallback, + ILogoutResponseValidateCallback, + IResponseValidateCallback, +} from '../definition/callbacks'; + +export class SAMLServiceProvider { + serviceProviderOptions: IServiceProviderOptions; + + constructor(serviceProviderOptions: IServiceProviderOptions) { + if (!serviceProviderOptions) { + throw new Error('SAMLServiceProvider instantiated without an options object'); + } + + this.serviceProviderOptions = serviceProviderOptions; + } + + private signRequest(xml: string): string { + const signer = crypto.createSign('RSA-SHA1'); + signer.update(xml); + return signer.sign(this.serviceProviderOptions.privateKey, 'base64'); + } + + public generateAuthorizeRequest(): string { + const identifiedRequest = AuthorizeRequest.generate(this.serviceProviderOptions); + return identifiedRequest.request; + } + + public generateLogoutResponse({ nameID, sessionIndex, inResponseToId }: { nameID: string; sessionIndex: string; inResponseToId: string }): ILogoutResponse { + return LogoutResponse.generate(this.serviceProviderOptions, nameID, sessionIndex, inResponseToId); + } + + public generateLogoutRequest({ nameID, sessionIndex }: { nameID: string; sessionIndex: string }): ISAMLRequest { + return LogoutRequest.generate(this.serviceProviderOptions, nameID, sessionIndex); + } + + /* + This method will generate the response URL with all the query string params and pass it to the callback + */ + public logoutResponseToUrl(response: string, callback: (err: string | object | null, url?: string) => void): void { + zlib.deflateRaw(response, (err, buffer) => { + if (err) { + return callback(err); + } + + try { + const base64 = buffer.toString('base64'); + let target = this.serviceProviderOptions.idpSLORedirectURL; + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // TBD. We should really include a proper RelayState here + const relayState = Meteor.absoluteUrl(); + + const samlResponse: Record = { + SAMLResponse: base64, + RelayState: relayState, + }; + + if (this.serviceProviderOptions.privateCert) { + samlResponse.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + samlResponse.Signature = this.signRequest(querystring.stringify(samlResponse)); + } + + target += querystring.stringify(samlResponse); + + return callback(null, target); + } catch (error) { + return callback(error); + } + }); + } + + /* + This method will generate the request URL with all the query string params and pass it to the callback + */ + public requestToUrl(request: string, operation: string, callback: (err: string | object | null, url?: string) => void): void { + zlib.deflateRaw(request, (err, buffer) => { + if (err) { + return callback(err); + } + + try { + const base64 = buffer.toString('base64'); + let target = this.serviceProviderOptions.entryPoint; + + if (operation === 'logout') { + if (this.serviceProviderOptions.idpSLORedirectURL) { + target = this.serviceProviderOptions.idpSLORedirectURL; + } + } + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // TBD. We should really include a proper RelayState here + let relayState; + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + relayState = Meteor.absoluteUrl(); + } else { + relayState = this.serviceProviderOptions.provider; + } + + const samlRequest: Record = { + SAMLRequest: base64, + RelayState: relayState, + }; + + if (this.serviceProviderOptions.privateCert) { + samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + samlRequest.Signature = this.signRequest(querystring.stringify(samlRequest)); + } + + target += querystring.stringify(samlRequest); + + SAMLUtils.log(`requestToUrl: ${ target }`); + + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + return callback(null, target); + } + callback(null, target); + } catch (error) { + callback(error); + } + }); + } + + public syncRequestToUrl(request: string, operation: string): void { + return Meteor.wrapAsync(this.requestToUrl, this)(request, operation); + } + + public getAuthorizeUrl(callback: (err: string | object | null, url?: string) => void): void { + const request = this.generateAuthorizeRequest(); + SAMLUtils.log('-----REQUEST------'); + SAMLUtils.log(request); + + this.requestToUrl(request, 'authorize', callback); + } + + public validateLogoutRequest(samlRequest: string, callback: ILogoutRequestValidateCallback): void { + SAMLUtils.inflateXml(samlRequest, (xml: string) => { + const parser = new LogoutRequestParser(this.serviceProviderOptions); + return parser.validate(xml, callback); + }, (err: string | object | null) => { + callback(err, null); + }); + } + + public validateLogoutResponse(samlResponse: string, callback: ILogoutResponseValidateCallback): void { + SAMLUtils.inflateXml(samlResponse, (xml: string) => { + const parser = new LogoutResponseParser(this.serviceProviderOptions); + return parser.validate(xml, callback); + }, (err: string | object | null) => { + callback(err, null); + }); + } + + public validateResponse(samlResponse: string, callback: IResponseValidateCallback): void { + const xml = new Buffer(samlResponse, 'base64').toString('utf8'); + + const parser = new ResponseParser(this.serviceProviderOptions); + return parser.validate(xml, callback); + } + + public generateServiceProviderMetadata(): string { + return ServiceProviderMetadata.generate(this.serviceProviderOptions); + } +} diff --git a/app/meteor-accounts-saml/server/lib/Utils.ts b/app/meteor-accounts-saml/server/lib/Utils.ts new file mode 100644 index 000000000000..bcce0575352d --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/Utils.ts @@ -0,0 +1,478 @@ +import zlib from 'zlib'; + +import _ from 'underscore'; + +import { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; +import { ISAMLUser } from '../definition/ISAMLUser'; +import { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings'; +import { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping'; +import { StatusCode } from './constants'; + +// @ToDo remove this ts-ignore someday +// @ts-ignore skip checking if Logger exists to avoid having to import the Logger class here (it would bring a lot of baggage with its dependencies, affecting the unit tests) +type NullableLogger = Logger | Null; + +let providerList: Array = []; +let debug = false; +let relayState: string | null = null; +let logger: NullableLogger = null; + +const globalSettings: ISAMLGlobalSettings = { + generateUsername: false, + nameOverwrite: false, + mailOverwrite: false, + immutableProperty: 'EMail', + defaultUserRole: 'user', + roleAttributeName: '', + roleAttributeSync: false, + userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}', + usernameNormalize: 'None', +}; + +export class SAMLUtils { + public static get isDebugging(): boolean { + return debug; + } + + public static get globalSettings(): ISAMLGlobalSettings { + return globalSettings; + } + + public static get serviceProviders(): Array { + return providerList; + } + + public static get relayState(): string | null { + return relayState; + } + + public static set relayState(value: string | null) { + relayState = value; + } + + public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined { + this.log(providerName); + this.log(providerList); + + return _.find(providerList, (providerOptions) => providerOptions.provider === providerName); + } + + public static setServiceProvidersList(list: Array): void { + providerList = list; + } + + public static setLoggerInstance(instance: NullableLogger): void { + logger = instance; + } + + // TODO: Some of those should probably not be global + public static updateGlobalSettings(samlConfigs: Record): void { + debug = Boolean(samlConfigs.debug); + + globalSettings.generateUsername = Boolean(samlConfigs.generateUsername); + globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite); + globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite); + globalSettings.roleAttributeSync = Boolean(samlConfigs.roleAttributeSync); + + if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') { + globalSettings.immutableProperty = samlConfigs.immutableProperty; + } + + if (samlConfigs.usernameNormalize && typeof samlConfigs.usernameNormalize === 'string') { + globalSettings.usernameNormalize = samlConfigs.usernameNormalize; + } + + if (samlConfigs.defaultUserRole && typeof samlConfigs.defaultUserRole === 'string') { + globalSettings.defaultUserRole = samlConfigs.defaultUserRole; + } + + if (samlConfigs.roleAttributeName && typeof samlConfigs.roleAttributeName === 'string') { + globalSettings.roleAttributeName = samlConfigs.roleAttributeName; + } + + if (samlConfigs.userDataFieldMap && typeof samlConfigs.userDataFieldMap === 'string') { + globalSettings.userDataFieldMap = samlConfigs.userDataFieldMap; + } + } + + public static generateUniqueID(): string { + const chars = 'abcdef0123456789'; + let uniqueID = 'id-'; + for (let i = 0; i < 20; i++) { + uniqueID += chars.substr(Math.floor(Math.random() * 15), 1); + } + return uniqueID; + } + + public static generateInstant(): string { + return new Date().toISOString(); + } + + public static certToPEM(cert: string): string { + const lines = cert.match(/.{1,64}/g); + if (!lines) { + throw new Error('Invalid Certificate'); + } + + lines.splice(0, 0, '-----BEGIN CERTIFICATE-----'); + lines.push('-----END CERTIFICATE-----'); + + return lines.join('\n'); + } + + public static fillTemplateData(template: string, data: Record): string { + let newTemplate = template; + + for (const variable in data) { + if (variable in data) { + const key = `__${ variable }__`; + while (newTemplate.includes(key)) { + newTemplate = newTemplate.replace(key, data[variable]); + } + } + } + + return newTemplate; + } + + public static log(...args: Array): void { + if (debug && logger) { + logger.info(...args); + } + } + + public static error(...args: Array): void { + if (logger) { + logger.error(...args); + } + } + + public static inflateXml(base64Data: string, successCallback: (xml: string) => void, errorCallback: (err: string | object | null) => void): void { + const buffer = new Buffer(base64Data, 'base64'); + zlib.inflateRaw(buffer, (err, decoded) => { + if (err) { + this.log(`Error while inflating. ${ err }`); + return errorCallback(err); + } + + if (!decoded) { + return errorCallback('Failed to extract request data'); + } + + const xmlString = this.convertArrayBufferToString(decoded); + return successCallback(xmlString); + }); + } + + public static validateStatus(doc: Document): { success: boolean; message: string; statusCode: string } { + let successStatus = false; + let status = null; + let messageText = ''; + + const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'); + + if (statusNodes.length) { + const statusNode = statusNodes[0]; + const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0]; + + if (statusMessage && statusMessage.firstChild && statusMessage.firstChild.textContent) { + messageText = statusMessage.firstChild.textContent; + } + + status = statusNode.getAttribute('Value'); + + if (status === StatusCode.success) { + successStatus = true; + } + } + return { + success: successStatus, + message: messageText, + statusCode: status || '', + }; + } + + public static normalizeCert(cert: string): string { + if (!cert) { + return cert; + } + + return cert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '') + .replace(/-+END CERTIFICATE-+\r?\n?/, '') + .replace(/\r\n/g, '\n') + .trim(); + } + + public static getUserDataMapping(): IUserDataMap { + const { userDataFieldMap, immutableProperty } = globalSettings; + + let map: Record; + + try { + map = JSON.parse(userDataFieldMap); + } catch (e) { + SAMLUtils.log(userDataFieldMap); + SAMLUtils.log(e); + throw new Error('Failed to parse custom user field map'); + } + + const parsedMap: IUserDataMap = { + customFields: new Map(), + attributeList: new Set(), + email: { + fieldName: 'email', + }, + username: { + fieldName: 'username', + }, + name: { + fieldName: 'cn', + }, + identifier: { + type: '', + }, + }; + + let identifier = immutableProperty.toLowerCase(); + + for (const spFieldName in map) { + if (!map.hasOwnProperty(spFieldName)) { + continue; + } + + const attribute = map[spFieldName]; + if (typeof attribute !== 'string' && typeof attribute !== 'object') { + throw new Error(`SAML User Map: Invalid configuration for ${ spFieldName } field.`); + } + + if (spFieldName === '__identifier__') { + if (typeof attribute !== 'string') { + throw new Error('SAML User Map: Invalid identifier.'); + } + + identifier = attribute; + continue; + } + + + let attributeMap: IAttributeMapping | null = null; + + // If it's a complex type, let's check what's in it + if (typeof attribute === 'object') { + // A fieldName is mandatory for complex fields. If it's missing, let's skip this one. + if (!attribute.hasOwnProperty('fieldName') && !attribute.hasOwnProperty('fieldNames')) { + continue; + } + + const fieldName = attribute.fieldName || attribute.fieldNames; + const { regex, template } = attribute; + + if (Array.isArray(fieldName)) { + if (!fieldName.length) { + throw new Error(`SAML User Map: Invalid configuration for ${ spFieldName } field.`); + } + + for (const idpFieldName of fieldName) { + parsedMap.attributeList.add(idpFieldName); + } + } else { + parsedMap.attributeList.add(fieldName); + } + + if (regex && typeof regex !== 'string') { + throw new Error('SAML User Map: Invalid RegEx'); + } + + if (template && typeof template !== 'string') { + throw new Error('SAML User Map: Invalid Template'); + } + + attributeMap = { + fieldName, + ...regex && { regex }, + ...template && { template }, + }; + } else if (typeof attribute === 'string') { + attributeMap = { + fieldName: attribute, + }; + parsedMap.attributeList.add(attribute); + } + + if (attributeMap) { + if (spFieldName === 'email' || spFieldName === 'username' || spFieldName === 'name') { + parsedMap[spFieldName] = attributeMap; + } else { + parsedMap.customFields.set(spFieldName, attributeMap); + } + } + } + + if (identifier) { + const defaultTypes = [ + 'email', + 'username', + ]; + + if (defaultTypes.includes(identifier)) { + parsedMap.identifier.type = identifier; + } else { + parsedMap.identifier.type = 'custom'; + parsedMap.identifier.attribute = identifier; + parsedMap.attributeList.add(identifier); + } + } + + return parsedMap; + } + + public static getProfileValue(profile: Record, mapping: IAttributeMapping): any { + const values: Record = { + regex: '', + }; + const fieldNames = this.ensureArray(mapping.fieldName); + + let mainValue; + for (const fieldName of fieldNames) { + values[fieldName] = profile[fieldName]; + + if (!mainValue) { + mainValue = profile[fieldName]; + } + } + + let shouldRunTemplate = false; + if (typeof mapping.template === 'string') { + // unless the regex result is used on the template, we process the template first + if (mapping.template.includes('__regex__')) { + shouldRunTemplate = true; + } else { + mainValue = this.fillTemplateData(mapping.template, values); + } + } + + if (mapping.regex && mainValue && mainValue.match) { + let regexValue; + const match = mainValue.match(new RegExp(mapping.regex)); + if (match && match.length) { + if (match.length >= 2) { + regexValue = match[1]; + } else { + regexValue = match[0]; + } + } + + if (regexValue) { + values.regex = regexValue; + if (!shouldRunTemplate) { + mainValue = regexValue; + } + } + } + + if (shouldRunTemplate && typeof mapping.template === 'string') { + mainValue = this.fillTemplateData(mapping.template, values); + } + + return mainValue; + } + + public static convertArrayBufferToString(buffer: ArrayBuffer, encoding = 'utf8'): string { + return Buffer.from(buffer).toString(encoding); + } + + public static normalizeUsername(name: string): string { + const { globalSettings } = this; + + switch (globalSettings.usernameNormalize) { + case 'Lowercase': + name = name.toLowerCase(); + break; + } + + return name; + } + + public static ensureArray(param: T | Array): Array { + const emptyArray: Array = []; + return emptyArray.concat(param); + } + + public static mapProfileToUserObject(profile: Record): ISAMLUser { + const userDataMap = this.getUserDataMapping(); + SAMLUtils.log('parsed userDataMap', userDataMap); + const { defaultUserRole = 'user', roleAttributeName } = this.globalSettings; + + if (userDataMap.identifier.type === 'custom') { + if (!userDataMap.identifier.attribute) { + throw new Error('SAML User Data Map: invalid Identifier configuration received.'); + } + if (!profile[userDataMap.identifier.attribute]) { + throw new Error(`SAML Profile did not have the expected identifier (${ userDataMap.identifier.attribute }).`); + } + } + + const attributeList = new Map(); + for (const attributeName of userDataMap.attributeList) { + if (profile[attributeName] === undefined) { + this.log(`SAML user profile is missing the attribute ${ attributeName }.`); + continue; + } + attributeList.set(attributeName, profile[attributeName]); + } + + const email = this.getProfileValue(profile, userDataMap.email); + const profileUsername = this.getProfileValue(profile, userDataMap.username); + const name = this.getProfileValue(profile, userDataMap.name); + + // Even if we're not using the email to identify the user, it is still mandatory because it's a mandatory information on Rocket.Chat + if (!email) { + throw new Error('SAML Profile did not contain an email address'); + } + + const userObject: ISAMLUser = { + customFields: new Map(), + samlLogin: { + provider: this.relayState, + idp: profile.issuer, + idpSession: profile.sessionIndex, + nameID: profile.nameID, + }, + emailList: this.ensureArray(email), + fullName: name || profile.displayName || profile.username, + roles: this.ensureArray(defaultUserRole.split(',')), + eppn: profile.eppn, + attributeList, + identifier: userDataMap.identifier, + }; + + if (profileUsername) { + userObject.username = this.normalizeUsername(profileUsername); + } + + if (roleAttributeName && profile[roleAttributeName]) { + userObject.roles = this.ensureArray((profile[roleAttributeName] || '').split(',')); + } + + if (profile.language) { + userObject.language = profile.language; + } + + if (profile.channels) { + if (Array.isArray(profile.channels)) { + userObject.channels = profile.channels; + } else { + userObject.channels = profile.channels.split(','); + } + } + + for (const [fieldName, customField] of userDataMap.customFields) { + const value = this.getProfileValue(profile, customField); + if (value) { + userObject.customFields.set(fieldName, value); + } + } + + return userObject; + } +} diff --git a/app/meteor-accounts-saml/server/lib/constants.ts b/app/meteor-accounts-saml/server/lib/constants.ts new file mode 100644 index 000000000000..31c9b7e962e1 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/constants.ts @@ -0,0 +1,52 @@ +export const defaultAuthnContextTemplate = ` + + __authnContext__ + +`; + +export const defaultAuthRequestTemplate = ` + __issuer__ + __identifierFormatTag__ + __authnContextTag__ +`; + +export const defaultLogoutResponseTemplate = ` + __issuer__ + +`; + +export const defaultLogoutRequestTemplate = ` + __issuer__ + __nameID__ + __sessionIndex__ +`; + +export const defaultMetadataCertificateTemplate = ` + + + + __certificate__ + + + + + + `; + +export const defaultMetadataTemplate = ` + + __certificateTag__ + + __identifierFormat__ + + +`; + +export const defaultNameIDTemplate = ''; +export const defaultIdentifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; +export const defaultAuthnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; + +export const StatusCode = { + success: 'urn:oasis:names:tc:SAML:2.0:status:Success', + responder: 'urn:oasis:names:tc:SAML:2.0:status:Responder', +}; diff --git a/app/meteor-accounts-saml/server/lib/generators/AuthorizeRequest.ts b/app/meteor-accounts-saml/server/lib/generators/AuthorizeRequest.ts new file mode 100644 index 000000000000..3b8f3d090537 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/generators/AuthorizeRequest.ts @@ -0,0 +1,76 @@ +import { SAMLUtils } from '../Utils'; +import { + defaultIdentifierFormat, + defaultAuthnContext, + defaultAuthRequestTemplate, + defaultNameIDTemplate, + defaultAuthnContextTemplate, +} from '../constants'; +import { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import { ISAMLRequest } from '../../definition/ISAMLRequest'; +import { IAuthorizeRequestVariables } from '../../definition/IAuthorizeRequestVariables'; + +/* + An Authorize Request is used to show the Identity Provider login form when the user clicks on the Rocket.Chat SAML login button +*/ +export class AuthorizeRequest { + public static generate(serviceProviderOptions: IServiceProviderOptions): ISAMLRequest { + const data = this.getDataForNewRequest(serviceProviderOptions); + const request = SAMLUtils.fillTemplateData(this.authorizeRequestTemplate(serviceProviderOptions), data); + + return { + request, + id: data.newId, + }; + } + + // The AuthorizeRequest template is split into three parts + // This way, users don't need to change the template when all they want to do is remove the NameID Policy or the AuthnContext. + // This also ensures compatibility with providers that were configured before the templates existed. + private static authorizeRequestTemplate(serviceProviderOptions: IServiceProviderOptions): string { + const data = { + identifierFormatTag: this.identifierFormatTagTemplate(serviceProviderOptions), + authnContextTag: this.authnContextTagTemplate(serviceProviderOptions), + }; + + const template = serviceProviderOptions.authRequestTemplate || defaultAuthRequestTemplate; + return SAMLUtils.fillTemplateData(template, data); + } + + private static identifierFormatTagTemplate(serviceProviderOptions: IServiceProviderOptions): string { + if (!serviceProviderOptions.identifierFormat) { + return ''; + } + + return serviceProviderOptions.nameIDPolicyTemplate || defaultNameIDTemplate; + } + + private static authnContextTagTemplate(serviceProviderOptions: IServiceProviderOptions): string { + if (!serviceProviderOptions.customAuthnContext) { + return ''; + } + + return serviceProviderOptions.authnContextTemplate || defaultAuthnContextTemplate; + } + + private static getDataForNewRequest(serviceProviderOptions: IServiceProviderOptions): IAuthorizeRequestVariables { + let id = `_${ SAMLUtils.generateUniqueID() }`; + const instant = SAMLUtils.generateInstant(); + + // Post-auth destination + if (serviceProviderOptions.id) { + id = serviceProviderOptions.id; + } + + return { + newId: id, + instant, + callbackUrl: serviceProviderOptions.callbackUrl, + entryPoint: serviceProviderOptions.entryPoint, + issuer: serviceProviderOptions.issuer, + identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat, + authnContextComparison: serviceProviderOptions.authnContextComparison || 'exact', + authnContext: serviceProviderOptions.customAuthnContext || defaultAuthnContext, + }; + } +} diff --git a/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts b/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts new file mode 100644 index 000000000000..87085893da98 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/generators/LogoutRequest.ts @@ -0,0 +1,45 @@ +import { SAMLUtils } from '../Utils'; +import { + defaultIdentifierFormat, + defaultLogoutRequestTemplate, +} from '../constants'; +import { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import { ISAMLRequest } from '../../definition/ISAMLRequest'; +import { ILogoutRequestVariables } from '../../definition/ILogoutRequestVariables'; + +/* + A Logout Request is used when the user is logged out of Rocket.Chat and the Service Provider is configured to also logout from the Identity Provider. +*/ +export class LogoutRequest { + static generate(serviceProviderOptions: IServiceProviderOptions, nameID: string, sessionIndex: string): ISAMLRequest { + const data = this.getDataForNewRequest(serviceProviderOptions, nameID, sessionIndex); + const request = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutRequestTemplate || defaultLogoutRequestTemplate, data); + + SAMLUtils.log('------- SAML Logout request -----------'); + SAMLUtils.log(request); + + return { + request, + id: data.newId, + }; + } + + static getDataForNewRequest(serviceProviderOptions: IServiceProviderOptions, nameID: string, sessionIndex: string): ILogoutRequestVariables { + // nameId: + // sessionIndex: sessionIndex + // --- NO SAMLsettings: = {}; + + if (response.hasAttribute('InResponseTo')) { + profile.inResponseToId = response.getAttribute('InResponseTo'); + } + + try { + issuer = this.getIssuer(assertion); + } catch (e) { + return callback(e, null, false); + } + + if (issuer) { + profile.issuer = issuer.textContent; + } + + const subject = this.getSubject(assertion); + if (subject) { + const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; + if (nameID) { + profile.nameID = nameID.textContent; + + if (nameID.hasAttribute('Format')) { + profile.nameIDFormat = nameID.getAttribute('Format'); + } + } + + try { + this.validateSubjectConditions(subject); + } catch (e) { + return callback(e, null, false); + } + } + + try { + this.validateAssertionConditions(assertion); + } catch (e) { + return callback(e, null, false); + } + + const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; + + if (authnStatement) { + if (authnStatement.hasAttribute('SessionIndex')) { + profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); + SAMLUtils.log(`Session Index: ${ profile.sessionIndex }`); + } else { + SAMLUtils.log('No Session Index Found'); + } + } else { + SAMLUtils.log('No AuthN Statement found'); + } + + const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0]; + if (attributeStatement) { + this.mapAttributes(attributeStatement, profile); + } else { + SAMLUtils.log('No Attribute Statement found in SAML response.'); + } + + if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) { + profile.email = profile.nameID; + } + + const profileKeys = Object.keys(profile); + for (let i = 0; i < profileKeys.length; i++) { + const key = profileKeys[i]; + + if (key.match(/\./)) { + profile[key.replace(/\./g, '-')] = profile[key]; + delete profile[key]; + } + } + + SAMLUtils.log(`NameID: ${ JSON.stringify(profile) }`); + return callback(null, profile, false); + } + + private _checkLogoutResponse(doc: Document, callback: IResponseValidateCallback): void { + const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); + if (!logoutResponse.length) { + return callback(new Error('Unknown SAML response message'), null, false); + } + + SAMLUtils.log('Verify status'); + const statusValidateObj = SAMLUtils.validateStatus(doc); + if (!statusValidateObj.success) { + return callback(new Error(`Status is: ${ statusValidateObj.statusCode }`), null, false); + } + SAMLUtils.log('Status ok'); + + // @ToDo: Check if this situation is still used + return callback(null, null, true); + } + + private getAssertion(response: Element, xml: string): ISAMLAssertion { + const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); + const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion'); + + if (allAssertions.length + allEncrypedAssertions.length > 1) { + throw new Error('Too many SAML assertions'); + } + + let assertion: XmlParent = allAssertions[0]; + const encAssertion = allEncrypedAssertions[0]; + let newXml = null; + + if (typeof encAssertion !== 'undefined') { + const options = { key: this.serviceProviderOptions.privateKey }; + const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0]; + xmlenc.decrypt(encData, options, function(err: Error, result: string) { + if (err) { + console.error(err); + } + + const document = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + if (!document) { + throw new Error('Failed to decrypt SAML assertion'); + } + + const decryptedAssertions = document.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); + if (decryptedAssertions.length) { + assertion = decryptedAssertions[0]; + } + + newXml = result; + }); + } + + if (!assertion) { + throw new Error('Missing SAML assertion'); + } + + return { + assertion, + xml: newXml || xml, + }; + } + + private verifySignatures(response: Element, assertionData: ISAMLAssertion, xml: string): void { + if (!this.serviceProviderOptions.cert) { + return; + } + + const signatureType = this.serviceProviderOptions.signatureValidationType; + + const checkEither = signatureType === 'Either'; + const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither; + const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither; + let anyValidSignature = false; + + if (checkResponse) { + SAMLUtils.log('Verify Document Signature'); + if (!this.validateResponseSignature(xml, this.serviceProviderOptions.cert, response)) { + if (!checkEither) { + SAMLUtils.log('Document Signature WRONG'); + throw new Error('Invalid Signature'); + } + } else { + anyValidSignature = true; + } + SAMLUtils.log('Document Signature OK'); + } + + if (checkAssertion) { + SAMLUtils.log('Verify Assertion Signature'); + if (!this.validateAssertionSignature(assertionData.xml, this.serviceProviderOptions.cert, assertionData.assertion)) { + if (!checkEither) { + SAMLUtils.log('Assertion Signature WRONG'); + throw new Error('Invalid Assertion signature'); + } + } else { + anyValidSignature = true; + } + SAMLUtils.log('Assertion Signature OK'); + } + + if (checkEither && !anyValidSignature) { + SAMLUtils.log('No Valid Signature'); + throw new Error('No valid SAML Signature found'); + } + } + + private validateResponseSignature(xml: string, cert: string, response: Element): boolean { + return this.validateSignatureChildren(xml, cert, response); + } + + private validateAssertionSignature(xml: string, cert: string, assertion: XmlParent): boolean { + return this.validateSignatureChildren(xml, cert, assertion); + } + + private validateSignatureChildren(xml: string, cert: string, parent: XmlParent): boolean { + const xpathSigQuery = ".//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"; + const signatures = xmlCrypto.xpath(parent, xpathSigQuery) as Array; + let signature = null; + + for (const sign of signatures) { + if (sign.parentNode !== parent) { + continue; + } + + // Too many signatures + if (signature) { + SAMLUtils.log('Failed to validate SAML signature: Too Many Signatures'); + return false; + } + + signature = sign; + } + + if (!signature) { + SAMLUtils.log('Failed to validate SAML signature: Signature not found'); + return false; + } + + return this.validateSignature(xml, cert, signature); + } + + private validateSignature(xml: string, cert: string, signature: Element): any { + const sig = new xmlCrypto.SignedXml(); + + sig.keyInfoProvider = { + getKeyInfo: (/* key*/): string => '', + // @ts-ignore - the definition file must be wrong + getKey: (/* keyInfo*/): string => SAMLUtils.certToPEM(cert), + }; + + sig.loadSignature(signature); + + const result = sig.checkSignature(xml); + if (!result && sig.validationErrors) { + SAMLUtils.log(sig.validationErrors); + } + + return result; + } + + private getIssuer(assertion: XmlParent): any { + const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer'); + if (issuers.length > 1) { + throw new Error('Too many Issuers'); + } + + return issuers[0]; + } + + private getSubject(assertion: XmlParent): XmlParent { + let subject: XmlParent = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; + const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; + + if (typeof encSubject !== 'undefined') { + const options = { key: this.serviceProviderOptions.privateKey }; + xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err: Error, result: string) { + if (err) { + console.error(err); + } + subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + return subject; + } + + + private validateSubjectConditions(subject: XmlParent): void { + const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0]; + if (subjectConfirmation) { + const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmationData')[0]; + if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } + } + } + + private validateNotBeforeNotOnOrAfterAssertions(element: Element): boolean { + const sysnow = new Date(); + const allowedclockdrift = this.serviceProviderOptions.allowedClockDrift; + + const now = new Date(sysnow.getTime() + allowedclockdrift); + + if (element.hasAttribute('NotBefore')) { + const notBefore: string | null = element.getAttribute('NotBefore'); + + if (!notBefore) { + return false; + } + + const date = new Date(notBefore); + if (now < date) { + return false; + } + } + + if (element.hasAttribute('NotOnOrAfter')) { + const notOnOrAfter: string | null = element.getAttribute('NotOnOrAfter'); + if (!notOnOrAfter) { + return false; + } + + const date = new Date(notOnOrAfter); + + if (now >= date) { + return false; + } + } + + return true; + } + + private validateAssertionConditions(assertion: XmlParent): void { + const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; + if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } + } + + private mapAttributes(attributeStatement: Element, profile: Record): void { + SAMLUtils.log(`Attribute Statement found in SAML response: ${ attributeStatement }`); + const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); + SAMLUtils.log(`Attributes will be processed: ${ attributes.length }`); + + if (attributes) { + for (let i = 0; i < attributes.length; i++) { + const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue'); + let value; + if (values.length === 1) { + value = values[0].textContent; + } else { + value = []; + for (let j = 0; j < values.length; j++) { + value.push(values[j].textContent); + } + } + + const key = attributes[i].getAttribute('Name'); + if (key) { + SAMLUtils.log(`Name: ${ attributes[i] }`); + SAMLUtils.log(`Adding attribute from SAML response to profile: ${ key } = ${ value }`); + profile[key] = value; + } + } + } else { + SAMLUtils.log('No Attributes found in SAML attribute statement.'); + } + + if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) { + // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs + profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3']; + } + + if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) { + profile.email = profile['urn:oid:1.2.840.113549.1.9.1']; + } + + if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) { + profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241']; + } + + if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) { + profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']; + } + + if (!profile.email && profile.mail) { + profile.email = profile.mail; + } + + if (!profile.cn && profile['urn:oid:2.5.4.3']) { + profile.cn = profile['urn:oid:2.5.4.3']; + } + } +} diff --git a/app/meteor-accounts-saml/server/lib/settings.ts b/app/meteor-accounts-saml/server/lib/settings.ts new file mode 100644 index 000000000000..e25bb79a2224 --- /dev/null +++ b/app/meteor-accounts-saml/server/lib/settings.ts @@ -0,0 +1,404 @@ +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { settings } from '../../../settings/server'; +import { SettingComposedValue } from '../../../settings/lib/settings'; +import { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; +import { SAMLUtils } from './Utils'; +import { + defaultAuthnContextTemplate, + defaultAuthRequestTemplate, + defaultLogoutResponseTemplate, + defaultLogoutRequestTemplate, + defaultNameIDTemplate, + defaultIdentifierFormat, + defaultAuthnContext, + defaultMetadataTemplate, + defaultMetadataCertificateTemplate, +} from './constants'; + +export const getSamlConfigs = function(service: string): Record { + return { + buttonLabelText: settings.get(`${ service }_button_label_text`), + buttonLabelColor: settings.get(`${ service }_button_label_color`), + buttonColor: settings.get(`${ service }_button_color`), + clientConfig: { + provider: settings.get(`${ service }_provider`), + }, + entryPoint: settings.get(`${ service }_entry_point`), + idpSLORedirectURL: settings.get(`${ service }_idp_slo_redirect_url`), + usernameNormalize: settings.get(`${ service }_username_normalize`), + immutableProperty: settings.get(`${ service }_immutable_property`), + generateUsername: settings.get(`${ service }_generate_username`), + debug: settings.get(`${ service }_debug`), + nameOverwrite: settings.get(`${ service }_name_overwrite`), + mailOverwrite: settings.get(`${ service }_mail_overwrite`), + issuer: settings.get(`${ service }_issuer`), + logoutBehaviour: settings.get(`${ service }_logout_behaviour`), + customAuthnContext: settings.get(`${ service }_custom_authn_context`), + authnContextComparison: settings.get(`${ service }_authn_context_comparison`), + defaultUserRole: settings.get(`${ service }_default_user_role`), + roleAttributeName: settings.get(`${ service }_role_attribute_name`), + roleAttributeSync: settings.get(`${ service }_role_attribute_sync`), + secret: { + privateKey: settings.get(`${ service }_private_key`), + publicCert: settings.get(`${ service }_public_cert`), + // People often overlook the instruction to remove the header and footer of the certificate on this specific setting, so let's do it for them. + cert: SAMLUtils.normalizeCert(settings.get(`${ service }_cert`) as string || ''), + }, + signatureValidationType: settings.get(`${ service }_signature_validation_type`), + userDataFieldMap: settings.get(`${ service }_user_data_fieldmap`), + allowedClockDrift: settings.get(`${ service }_allowed_clock_drift`), + identifierFormat: settings.get(`${ service }_identifier_format`), + nameIDPolicyTemplate: settings.get(`${ service }_NameId_template`), + authnContextTemplate: settings.get(`${ service }_AuthnContext_template`), + authRequestTemplate: settings.get(`${ service }_AuthRequest_template`), + logoutResponseTemplate: settings.get(`${ service }_LogoutResponse_template`), + logoutRequestTemplate: settings.get(`${ service }_LogoutRequest_template`), + metadataCertificateTemplate: settings.get(`${ service }_MetadataCertificate_template`), + metadataTemplate: settings.get(`${ service }_Metadata_template`), + }; +}; + +export const configureSamlService = function(samlConfigs: Record): IServiceProviderOptions { + let privateCert = null; + let privateKey = null; + + if (samlConfigs.secret.privateKey && samlConfigs.secret.publicCert) { + privateKey = samlConfigs.secret.privateKey; + privateCert = samlConfigs.secret.publicCert; + } else if (samlConfigs.secret.privateKey || samlConfigs.secret.publicCert) { + SAMLUtils.error('SAML Service: You must specify both cert and key files.'); + } + + SAMLUtils.updateGlobalSettings(samlConfigs); + + return { + provider: samlConfigs.clientConfig.provider, + entryPoint: samlConfigs.entryPoint, + idpSLORedirectURL: samlConfigs.idpSLORedirectURL, + issuer: samlConfigs.issuer, + cert: samlConfigs.secret.cert, + privateCert, + privateKey, + customAuthnContext: samlConfigs.customAuthnContext, + authnContextComparison: samlConfigs.authnContextComparison, + defaultUserRole: samlConfigs.defaultUserRole, + roleAttributeName: samlConfigs.roleAttributeName, + roleAttributeSync: samlConfigs.roleAttributeSync, + allowedClockDrift: parseInt(samlConfigs.allowedClockDrift) || 0, + signatureValidationType: samlConfigs.signatureValidationType, + identifierFormat: samlConfigs.identifierFormat, + nameIDPolicyTemplate: samlConfigs.nameIDPolicyTemplate, + authnContextTemplate: samlConfigs.authnContextTemplate, + authRequestTemplate: samlConfigs.authRequestTemplate, + logoutResponseTemplate: samlConfigs.logoutResponseTemplate, + logoutRequestTemplate: samlConfigs.logoutRequestTemplate, + metadataCertificateTemplate: samlConfigs.metadataCertificateTemplate, + metadataTemplate: samlConfigs.metadataTemplate, + callbackUrl: Meteor.absoluteUrl(`_saml/validate/${ samlConfigs.clientConfig.provider }`), + }; +}; + +export const loadSamlServiceProviders = function(): void { + const serviceName = 'saml'; + const services = settings.get(/^(SAML_Custom_)[a-z]+$/i) as SettingComposedValue[] | undefined; + + if (!services) { + return SAMLUtils.setServiceProvidersList([]); + } + + const providers = services.map((service) => { + if (service.value === true) { + const samlConfigs = getSamlConfigs(service.key); + SAMLUtils.log(service.key); + ServiceConfiguration.configurations.upsert({ + service: serviceName.toLowerCase(), + }, { + $set: samlConfigs, + }); + return configureSamlService(samlConfigs); + } + ServiceConfiguration.configurations.remove({ + service: serviceName.toLowerCase(), + }); + return false; + }).filter((e) => e) as IServiceProviderOptions[]; + + SAMLUtils.setServiceProvidersList(providers); +}; + +export const addSamlService = function(name: string): void { + settings.add(`SAML_Custom_${ name }`, false, { + type: 'boolean', + group: 'SAML', + i18nLabel: 'Accounts_OAuth_Custom_Enable', + }); + settings.add(`SAML_Custom_${ name }_provider`, 'provider-name', { + type: 'string', + group: 'SAML', + i18nLabel: 'SAML_Custom_Provider', + }); + settings.add(`SAML_Custom_${ name }_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', { + type: 'string', + group: 'SAML', + i18nLabel: 'SAML_Custom_Entry_point', + }); + settings.add(`SAML_Custom_${ name }_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', { + type: 'string', + group: 'SAML', + i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL', + }); + settings.add(`SAML_Custom_${ name }_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', { + type: 'string', + group: 'SAML', + i18nLabel: 'SAML_Custom_Issuer', + }); + settings.add(`SAML_Custom_${ name }_debug`, false, { + type: 'boolean', + group: 'SAML', + i18nLabel: 'SAML_Custom_Debug', + }); + + // UI Settings + settings.add(`SAML_Custom_${ name }_button_label_text`, 'SAML', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_1_User_Interface', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + }); + settings.add(`SAML_Custom_${ name }_button_label_color`, '#FFFFFF', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_1_User_Interface', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + }); + settings.add(`SAML_Custom_${ name }_button_color`, '#1d74f5', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_1_User_Interface', + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + }); + + // Certificate settings + settings.add(`SAML_Custom_${ name }_cert`, '', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_2_Certificate', + i18nLabel: 'SAML_Custom_Cert', + multiline: true, + secret: true, + }); + settings.add(`SAML_Custom_${ name }_public_cert`, '', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_2_Certificate', + multiline: true, + i18nLabel: 'SAML_Custom_Public_Cert', + }); + settings.add(`SAML_Custom_${ name }_signature_validation_type`, 'All', { + type: 'select', + values: [ + { key: 'Response', i18nLabel: 'SAML_Custom_signature_validation_response' }, + { key: 'Assertion', i18nLabel: 'SAML_Custom_signature_validation_assertion' }, + { key: 'Either', i18nLabel: 'SAML_Custom_signature_validation_either' }, + { key: 'All', i18nLabel: 'SAML_Custom_signature_validation_all' }, + ], + group: 'SAML', + section: 'SAML_Section_2_Certificate', + i18nLabel: 'SAML_Custom_signature_validation_type', + i18nDescription: 'SAML_Custom_signature_validation_type_description', + }); + settings.add(`SAML_Custom_${ name }_private_key`, '', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_2_Certificate', + multiline: true, + i18nLabel: 'SAML_Custom_Private_Key', + secret: true, + }); + + // Settings to customize behavior + settings.add(`SAML_Custom_${ name }_generate_username`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_Generate_Username', + }); + settings.add(`SAML_Custom_${ name }_username_normalize`, 'None', { + type: 'select', + values: [ + { key: 'None', i18nLabel: 'SAML_Custom_Username_Normalize_None' }, + { key: 'Lowercase', i18nLabel: 'SAML_Custom_Username_Normalize_Lowercase' }, + ], + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_Username_Normalize', + }); + settings.add(`SAML_Custom_${ name }_immutable_property`, 'EMail', { + type: 'select', + values: [ + { key: 'Username', i18nLabel: 'SAML_Custom_Immutable_Property_Username' }, + { key: 'EMail', i18nLabel: 'SAML_Custom_Immutable_Property_EMail' }, + ], + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_Immutable_Property', + }); + settings.add(`SAML_Custom_${ name }_name_overwrite`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_name_overwrite', + }); + settings.add(`SAML_Custom_${ name }_mail_overwrite`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_mail_overwrite', + }); + settings.add(`SAML_Custom_${ name }_logout_behaviour`, 'SAML', { + type: 'select', + values: [ + { key: 'SAML', i18nLabel: 'SAML_Custom_Logout_Behaviour_Terminate_SAML_Session' }, + { key: 'Local', i18nLabel: 'SAML_Custom_Logout_Behaviour_End_Only_RocketChat' }, + ], + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_Logout_Behaviour', + }); + + // Roles Settings + settings.add(`SAML_Custom_${ name }_default_user_role`, 'user', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_4_Roles', + i18nLabel: 'SAML_Default_User_Role', + i18nDescription: 'SAML_Default_User_Role_Description', + }); + settings.add(`SAML_Custom_${ name }_role_attribute_name`, '', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_4_Roles', + i18nLabel: 'SAML_Role_Attribute_Name', + i18nDescription: 'SAML_Role_Attribute_Name_Description', + }); + settings.add(`SAML_Custom_${ name }_role_attribute_sync`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_4_Roles', + i18nLabel: 'SAML_Role_Attribute_Sync', + i18nDescription: 'SAML_Role_Attribute_Sync_Description', + }); + + + // Data Mapping Settings + settings.add(`SAML_Custom_${ name }_user_data_fieldmap`, '{"username":"username", "email":"email", "name": "cn"}', { + type: 'string', + group: 'SAML', + section: 'SAML_Section_5_Mapping', + i18nLabel: 'SAML_Custom_user_data_fieldmap', + i18nDescription: 'SAML_Custom_user_data_fieldmap_description', + multiline: true, + }); + + // Advanced settings + settings.add(`SAML_Custom_${ name }_allowed_clock_drift`, 0, { + type: 'int', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_Allowed_Clock_Drift', + i18nDescription: 'SAML_Allowed_Clock_Drift_Description', + }); + settings.add(`SAML_Custom_${ name }_identifier_format`, defaultIdentifierFormat, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_Identifier_Format', + i18nDescription: 'SAML_Identifier_Format_Description', + }); + + settings.add(`SAML_Custom_${ name }_NameId_template`, defaultNameIDTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_NameIdPolicy_Template', + i18nDescription: 'SAML_NameIdPolicy_Template_Description', + multiline: true, + }); + + settings.add(`SAML_Custom_${ name }_custom_authn_context`, defaultAuthnContext, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_Custom_Authn_Context', + i18nDescription: 'SAML_Custom_Authn_Context_description', + }); + settings.add(`SAML_Custom_${ name }_authn_context_comparison`, 'exact', { + type: 'select', + values: [ + { key: 'better', i18nLabel: 'Better' }, + { key: 'exact', i18nLabel: 'Exact' }, + { key: 'maximum', i18nLabel: 'Maximum' }, + { key: 'minimum', i18nLabel: 'Minimum' }, + ], + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_Custom_Authn_Context_Comparison', + }); + + settings.add(`SAML_Custom_${ name }_AuthnContext_template`, defaultAuthnContextTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_AuthnContext_Template', + i18nDescription: 'SAML_AuthnContext_Template_Description', + multiline: true, + }); + + + settings.add(`SAML_Custom_${ name }_AuthRequest_template`, defaultAuthRequestTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_AuthnRequest_Template', + i18nDescription: 'SAML_AuthnRequest_Template_Description', + multiline: true, + }); + + settings.add(`SAML_Custom_${ name }_LogoutResponse_template`, defaultLogoutResponseTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_LogoutResponse_Template', + i18nDescription: 'SAML_LogoutResponse_Template_Description', + multiline: true, + }); + + settings.add(`SAML_Custom_${ name }_LogoutRequest_template`, defaultLogoutRequestTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_LogoutRequest_Template', + i18nDescription: 'SAML_LogoutRequest_Template_Description', + multiline: true, + }); + + settings.add(`SAML_Custom_${ name }_MetadataCertificate_template`, defaultMetadataCertificateTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_MetadataCertificate_Template', + i18nDescription: 'SAML_Metadata_Certificate_Template_Description', + multiline: true, + }); + + settings.add(`SAML_Custom_${ name }_Metadata_template`, defaultMetadataTemplate, { + type: 'string', + group: 'SAML', + section: 'SAML_Section_6_Advanced', + i18nLabel: 'SAML_Metadata_Template', + i18nDescription: 'SAML_Metadata_Template_Description', + multiline: true, + }); +}; diff --git a/app/meteor-accounts-saml/server/listener.ts b/app/meteor-accounts-saml/server/listener.ts new file mode 100644 index 000000000000..4edae1cb301a --- /dev/null +++ b/app/meteor-accounts-saml/server/listener.ts @@ -0,0 +1,81 @@ +import { IncomingMessage, ServerResponse } from 'http'; + +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { RoutePolicy } from 'meteor/routepolicy'; +import bodyParser from 'body-parser'; +import fiber from 'fibers'; + +import { SAML } from './lib/SAML'; +import { SAMLUtils } from './lib/Utils'; +import { ISAMLAction } from './definition/ISAMLAction'; +import { IIncomingMessage } from '../../../definition/IIncomingMessage'; + +RoutePolicy.declare('/_saml/', 'network'); + +const samlUrlToObject = function(url: string | undefined): ISAMLAction | null { + // req.url will be '/_saml///' + if (!url) { + return null; + } + + const splitUrl = url.split('?'); + const splitPath = splitUrl[0].split('/'); + + // Any non-saml request will continue down the default + // middlewares. + if (splitPath[1] !== '_saml') { + return null; + } + + const result = { + actionName: splitPath[2], + serviceName: splitPath[3], + credentialToken: splitPath[4], + }; + + SAMLUtils.log(result); + return result; +}; + +const middleware = function(req: IIncomingMessage, res: ServerResponse, next: (err?: any) => void): void { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + const samlObject = samlUrlToObject(req.url); + if (!samlObject || !samlObject.serviceName) { + next(); + return; + } + + if (!samlObject.actionName) { + throw new Error('Missing SAML action'); + } + + const service = SAMLUtils.getServiceProviderOptions(samlObject.serviceName); + if (!service) { + console.error(`${ samlObject.serviceName } service provider not found`); + throw new Error('SAML Service Provider not found.'); + } + + SAML.processRequest(req, res, service, samlObject); + } catch (err) { + // @ToDo: Ideally we should send some error message to the client, but there's no way to do it on a redirect right now. + console.log(err); + + const url = Meteor.absoluteUrl('home'); + res.writeHead(302, { + Location: url, + }); + res.end(); + } +}; + +// Listen to incoming SAML http requests +WebApp.connectHandlers.use(bodyParser.json()).use(function(req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) { + // Need to create a fiber since we're using synchronous http calls and nothing + // else is wrapping this in a fiber automatically + fiber(function() { + middleware(req as IIncomingMessage, res, next); + }).run(); +}); diff --git a/app/meteor-accounts-saml/server/loginHandler.ts b/app/meteor-accounts-saml/server/loginHandler.ts new file mode 100644 index 000000000000..3ea506922b96 --- /dev/null +++ b/app/meteor-accounts-saml/server/loginHandler.ts @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { SAMLUtils } from './lib/Utils'; +import { SAML } from './lib/SAML'; + +const makeError = (message: string): Record => ({ + type: 'saml', + // @ts-ignore - LoginCancelledError does in fact exist + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, message), +}); + +Accounts.registerLoginHandler('saml', function(loginRequest) { + if (!loginRequest.saml || !loginRequest.credentialToken) { + return undefined; + } + + const loginResult = SAML.retrieveCredential(loginRequest.credentialToken); + SAMLUtils.log(`RESULT :${ JSON.stringify(loginResult) }`); + + if (!loginResult) { + return makeError('No matching login attempt found'); + } + + if (!loginResult.profile) { + return makeError('No profile information found'); + } + + try { + const userObject = SAMLUtils.mapProfileToUserObject(loginResult.profile); + + return SAML.insertOrUpdateSAMLUser(userObject); + } catch (error) { + console.error(error); + return { + type: 'saml', + error, + }; + } +}); diff --git a/app/meteor-accounts-saml/server/methods/addSamlService.ts b/app/meteor-accounts-saml/server/methods/addSamlService.ts new file mode 100644 index 000000000000..27d24ae491e5 --- /dev/null +++ b/app/meteor-accounts-saml/server/methods/addSamlService.ts @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { addSamlService } from '../lib/settings'; + +Meteor.methods({ + addSamlService(name) { + addSamlService(name); + }, +}); diff --git a/app/meteor-accounts-saml/server/methods/samlLogout.ts b/app/meteor-accounts-saml/server/methods/samlLogout.ts new file mode 100644 index 000000000000..ceedc2de0cb1 --- /dev/null +++ b/app/meteor-accounts-saml/server/methods/samlLogout.ts @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; + +import { Users } from '../../../models/server'; +import { SAMLServiceProvider } from '../lib/ServiceProvider'; +import { SAMLUtils } from '../lib/Utils'; +import { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; + +/** + * Fetch SAML provider configs for given 'provider'. + */ +function getSamlServiceProviderOptions(provider: string): IServiceProviderOptions { + if (!provider) { + throw new Meteor.Error('no-saml-provider', 'SAML internal error', { + method: 'getSamlServiceProviderOptions', + }); + } + + const providers = SAMLUtils.serviceProviders; + + const samlProvider = function(element: IServiceProviderOptions): boolean { + return element.provider === provider; + }; + + return providers.filter(samlProvider)[0]; +} + +Meteor.methods({ + samlLogout(provider: string) { + // Make sure the user is logged in before we initiate SAML Logout + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'samlLogout' }); + } + const providerConfig = getSamlServiceProviderOptions(provider); + + SAMLUtils.log(`Logout request from ${ JSON.stringify(providerConfig) }`); + // This query should respect upcoming array of SAML logins + const user = Users.getSAMLByIdAndSAMLProvider(Meteor.userId(), provider); + if (!user || !user.services || !user.services.saml) { + return; + } + + const { nameID, idpSession } = user.services.saml; + SAMLUtils.log(`NameID for user ${ Meteor.userId() } found: ${ JSON.stringify(nameID) }`); + + const _saml = new SAMLServiceProvider(providerConfig); + + const request = _saml.generateLogoutRequest({ + nameID: nameID || idpSession, + sessionIndex: idpSession, + }); + + SAMLUtils.log('----Logout Request----'); + SAMLUtils.log(request); + + // request.request: actual XML SAML Request + // request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse + + Users.setSamlInResponseTo(Meteor.userId(), request.id); + + const result = _saml.syncRequestToUrl(request.request, 'logout'); + SAMLUtils.log(`SAML Logout Request ${ result }`); + + return result; + }, +}); diff --git a/app/meteor-accounts-saml/server/saml_rocketchat.js b/app/meteor-accounts-saml/server/saml_rocketchat.js deleted file mode 100644 index b41651a311a6..000000000000 --- a/app/meteor-accounts-saml/server/saml_rocketchat.js +++ /dev/null @@ -1,342 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Accounts } from 'meteor/accounts-base'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { Logger } from '../../logger'; -import { settings } from '../../settings'; - -const logger = new Logger('steffo:meteor-accounts-saml', { - methods: { - updated: { - type: 'info', - }, - }, -}); - -settings.addGroup('SAML'); - -Meteor.methods({ - addSamlService(name) { - settings.add(`SAML_Custom_${ name }`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'Accounts_OAuth_Custom_Enable', - }); - settings.add(`SAML_Custom_${ name }_provider`, 'provider-name', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Provider', - }); - settings.add(`SAML_Custom_${ name }_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Entry_point', - }); - settings.add(`SAML_Custom_${ name }_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL', - }); - settings.add(`SAML_Custom_${ name }_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Issuer', - }); - settings.add(`SAML_Custom_${ name }_cert`, '', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Cert', - multiline: true, - secret: true, - }); - settings.add(`SAML_Custom_${ name }_public_cert`, '', { - type: 'string', - group: 'SAML', - section: name, - multiline: true, - i18nLabel: 'SAML_Custom_Public_Cert', - }); - settings.add(`SAML_Custom_${ name }_signature_validation_type`, 'All', { - type: 'select', - values: [ - { key: 'Response', i18nLabel: 'SAML_Custom_signature_validation_response' }, - { key: 'Assertion', i18nLabel: 'SAML_Custom_signature_validation_assertion' }, - { key: 'Either', i18nLabel: 'SAML_Custom_signature_validation_either' }, - { key: 'All', i18nLabel: 'SAML_Custom_signature_validation_all' }, - ], - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_signature_validation_type', - i18nDescription: 'SAML_Custom_signature_validation_type_description', - }); - settings.add(`SAML_Custom_${ name }_private_key`, '', { - type: 'string', - group: 'SAML', - section: name, - multiline: true, - i18nLabel: 'SAML_Custom_Private_Key', - secret: true, - }); - settings.add(`SAML_Custom_${ name }_button_label_text`, '', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', - }); - settings.add(`SAML_Custom_${ name }_button_label_color`, '#FFFFFF', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', - }); - settings.add(`SAML_Custom_${ name }_button_color`, '#1d74f5', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'Accounts_OAuth_Custom_Button_Color', - }); - settings.add(`SAML_Custom_${ name }_generate_username`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Generate_Username', - }); - settings.add(`SAML_Custom_${ name }_username_normalize`, 'None', { - type: 'select', - values: [ - { key: 'None', i18nLabel: 'SAML_Custom_Username_Normalize_None' }, - { key: 'Lowercase', i18nLabel: 'SAML_Custom_Username_Normalize_Lowercase' }, - ], - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Username_Normalize', - }); - settings.add(`SAML_Custom_${ name }_immutable_property`, 'EMail', { - type: 'select', - values: [ - { key: 'Username', i18nLabel: 'SAML_Custom_Immutable_Property_Username' }, - { key: 'EMail', i18nLabel: 'SAML_Custom_Immutable_Property_EMail' }, - ], - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Immutable_Property', - }); - settings.add(`SAML_Custom_${ name }_debug`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Debug', - }); - settings.add(`SAML_Custom_${ name }_name_overwrite`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_name_overwrite', - }); - settings.add(`SAML_Custom_${ name }_mail_overwrite`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_mail_overwrite', - }); - settings.add(`SAML_Custom_${ name }_logout_behaviour`, 'SAML', { - type: 'select', - values: [ - { key: 'SAML', i18nLabel: 'SAML_Custom_Logout_Behaviour_Terminate_SAML_Session' }, - { key: 'Local', i18nLabel: 'SAML_Custom_Logout_Behaviour_End_Only_RocketChat' }, - ], - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Logout_Behaviour', - }); - settings.add(`SAML_Custom_${ name }_custom_authn_context`, 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Authn_Context', - i18nDescription: 'SAML_Custom_Authn_Context_description', - }); - settings.add(`SAML_Custom_${ name }_user_data_fieldmap`, '{"username":"username", "email":"email", "cn": "name"}', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_user_data_fieldmap', - i18nDescription: 'SAML_Custom_user_data_fieldmap_description', - }); - settings.add(`SAML_Custom_${ name }_authn_context_comparison`, 'exact', { - type: 'select', - values: [ - { key: 'better', i18nLabel: 'Better' }, - { key: 'exact', i18nLabel: 'Exact' }, - { key: 'maximum', i18nLabel: 'Maximum' }, - { key: 'minimum', i18nLabel: 'Minimum' }, - ], - group: 'SAML', - section: name, - i18nLabel: 'SAML_Custom_Authn_Context_Comparison', - }); - - settings.add(`SAML_Custom_${ name }_default_user_role`, 'user', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Default_User_Role', - i18nDescription: 'SAML_Default_User_Role_Description', - }); - settings.add(`SAML_Custom_${ name }_role_attribute_name`, '', { - type: 'string', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Role_Attribute_Name', - i18nDescription: 'SAML_Role_Attribute_Name_Description', - }); - - settings.add(`SAML_Custom_${ name }_role_attribute_sync`, false, { - type: 'boolean', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Role_Attribute_Sync', - i18nDescription: 'SAML_Role_Attribute_Sync_Description', - }); - - settings.add(`SAML_Custom_${ name }_allowed_clock_drift`, 0, { - type: 'int', - group: 'SAML', - section: name, - i18nLabel: 'SAML_Allowed_Clock_Drift', - i18nDescription: 'SAML_Allowed_Clock_Drift_Description', - }); - }, -}); - -const normalizeCert = function(cert) { - if (typeof cert === 'string') { - return cert.replace('-----BEGIN CERTIFICATE-----', '').replace('-----END CERTIFICATE-----', '').trim(); - } - - return cert; -}; - -const getSamlConfigs = function(service) { - return { - buttonLabelText: settings.get(`${ service.key }_button_label_text`), - buttonLabelColor: settings.get(`${ service.key }_button_label_color`), - buttonColor: settings.get(`${ service.key }_button_color`), - clientConfig: { - provider: settings.get(`${ service.key }_provider`), - }, - entryPoint: settings.get(`${ service.key }_entry_point`), - idpSLORedirectURL: settings.get(`${ service.key }_idp_slo_redirect_url`), - usernameNormalize: settings.get(`${ service.key }_username_normalize`), - immutableProperty: settings.get(`${ service.key }_immutable_property`), - generateUsername: settings.get(`${ service.key }_generate_username`), - debug: settings.get(`${ service.key }_debug`), - nameOverwrite: settings.get(`${ service.key }_name_overwrite`), - mailOverwrite: settings.get(`${ service.key }_mail_overwrite`), - issuer: settings.get(`${ service.key }_issuer`), - logoutBehaviour: settings.get(`${ service.key }_logout_behaviour`), - customAuthnContext: settings.get(`${ service.key }_custom_authn_context`), - authnContextComparison: settings.get(`${ service.key }_authn_context_comparison`), - defaultUserRole: settings.get(`${ service.key }_default_user_role`), - roleAttributeName: settings.get(`${ service.key }_role_attribute_name`), - roleAttributeSync: settings.get(`${ service.key }_role_attribute_sync`), - secret: { - privateKey: settings.get(`${ service.key }_private_key`), - publicCert: settings.get(`${ service.key }_public_cert`), - // People often overlook the instruction to remove the header and footer of the certificate on this specific setting, so let's do it for them. - cert: normalizeCert(settings.get(`${ service.key }_cert`)), - }, - signatureValidationType: settings.get(`${ service.key }_signature_validation_type`), - userDataFieldMap: settings.get(`${ service.key }_user_data_fieldmap`), - allowedClockDrift: settings.get(`${ service.key }_allowed_clock_drift`), - }; -}; - -const debounce = (fn, delay) => { - let timer = null; - return () => { - if (timer != null) { - Meteor.clearTimeout(timer); - } - timer = Meteor.setTimeout(fn, delay); - return timer; - }; -}; -const serviceName = 'saml'; - -const configureSamlService = function(samlConfigs) { - let privateCert = false; - let privateKey = false; - if (samlConfigs.secret.privateKey && samlConfigs.secret.publicCert) { - privateKey = samlConfigs.secret.privateKey; - privateCert = samlConfigs.secret.publicCert; - } else if (samlConfigs.secret.privateKey || samlConfigs.secret.publicCert) { - logger.error('You must specify both cert and key files.'); - } - // TODO: the function configureSamlService is called many times and Accounts.saml.settings.generateUsername keeps just the last value - Accounts.saml.settings.generateUsername = samlConfigs.generateUsername; - Accounts.saml.settings.nameOverwrite = samlConfigs.nameOverwrite; - Accounts.saml.settings.mailOverwrite = samlConfigs.mailOverwrite; - Accounts.saml.settings.immutableProperty = samlConfigs.immutableProperty; - Accounts.saml.settings.userDataFieldMap = samlConfigs.userDataFieldMap; - Accounts.saml.settings.usernameNormalize = samlConfigs.usernameNormalize; - Accounts.saml.settings.debug = samlConfigs.debug; - Accounts.saml.settings.defaultUserRole = samlConfigs.defaultUserRole; - Accounts.saml.settings.roleAttributeName = samlConfigs.roleAttributeName; - Accounts.saml.settings.roleAttributeSync = samlConfigs.roleAttributeSync; - - return { - provider: samlConfigs.clientConfig.provider, - entryPoint: samlConfigs.entryPoint, - idpSLORedirectURL: samlConfigs.idpSLORedirectURL, - issuer: samlConfigs.issuer, - cert: samlConfigs.secret.cert, - privateCert, - privateKey, - customAuthnContext: samlConfigs.customAuthnContext, - authnContextComparison: samlConfigs.authnContextComparison, - defaultUserRole: samlConfigs.defaultUserRole, - roleAttributeName: samlConfigs.roleAttributeName, - roleAttributeSync: samlConfigs.roleAttributeSync, - allowedClockDrift: samlConfigs.allowedClockDrift, - signatureValidationType: samlConfigs.signatureValidationType, - }; -}; - -const updateServices = debounce(() => { - const services = settings.get(/^(SAML_Custom_)[a-z]+$/i); - Accounts.saml.settings.providers = services.map((service) => { - if (service.value === true) { - const samlConfigs = getSamlConfigs(service); - logger.updated(service.key); - ServiceConfiguration.configurations.upsert({ - service: serviceName.toLowerCase(), - }, { - $set: samlConfigs, - }); - return configureSamlService(samlConfigs); - } - return ServiceConfiguration.configurations.remove({ - service: serviceName.toLowerCase(), - }); - }).filter((e) => e); -}, 2000); - - -settings.get(/^SAML_.+/, updateServices); - -Meteor.startup(() => Meteor.call('addSamlService', 'Default')); - -export { - updateServices, - configureSamlService, - getSamlConfigs, - debounce, - logger, -}; diff --git a/app/meteor-accounts-saml/server/saml_server.js b/app/meteor-accounts-saml/server/saml_server.js deleted file mode 100644 index eb89fd9fb68a..000000000000 --- a/app/meteor-accounts-saml/server/saml_server.js +++ /dev/null @@ -1,712 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Accounts } from 'meteor/accounts-base'; -import { Random } from 'meteor/random'; -import { WebApp } from 'meteor/webapp'; -import { RoutePolicy } from 'meteor/routepolicy'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import bodyParser from 'body-parser'; -import fiber from 'fibers'; -import _ from 'underscore'; -import s from 'underscore.string'; - -import { SAML } from './saml_utils'; -import { settings } from '../../settings/server'; -import { Users, Rooms, CredentialTokens } from '../../models/server'; -import { generateUsernameSuggestion } from '../../lib'; -import { _setUsername, createRoom } from '../../lib/server/functions'; - -if (!Accounts.saml) { - Accounts.saml = { - settings: { - debug: false, - generateUsername: false, - nameOverwrite: false, - mailOverwrite: false, - providers: [], - }, - }; -} - -RoutePolicy.declare('/_saml/', 'network'); - -/** - * Fetch SAML provider configs for given 'provider'. - */ -function getSamlProviderConfig(provider) { - if (!provider) { - throw new Meteor.Error('no-saml-provider', - 'SAML internal error', - { method: 'getSamlProviderConfig' }); - } - const samlProvider = function(element) { - return element.provider === provider; - }; - return Accounts.saml.settings.providers.filter(samlProvider)[0]; -} - -Meteor.methods({ - samlLogout(provider) { - // Make sure the user is logged in before initiate SAML SLO - if (!Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'samlLogout' }); - } - const providerConfig = getSamlProviderConfig(provider); - - if (Accounts.saml.settings.debug) { - console.log(`Logout request from ${ JSON.stringify(providerConfig) }`); - } - // This query should respect upcoming array of SAML logins - const user = Users.getSAMLByIdAndSAMLProvider(Meteor.userId(), provider); - if (!user || !user.services || !user.services.saml) { - return; - } - - const { nameID } = user.services.saml; - const sessionIndex = user.services.saml.idpSession; - - if (Accounts.saml.settings.debug) { - console.log(`NameID for user ${ Meteor.userId() } found: ${ JSON.stringify(nameID) }`); - } - - const _saml = new SAML(providerConfig); - - const request = _saml.generateLogoutRequest({ - nameID, - sessionIndex, - }); - - // request.request: actual XML SAML Request - // request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse - - Meteor.users.update({ - _id: Meteor.userId(), - }, { - $set: { - 'services.saml.inResponseTo': request.id, - }, - }); - - const _syncRequestToUrl = Meteor.wrapAsync(_saml.requestToUrl, _saml); - const result = _syncRequestToUrl(request.request, 'logout'); - if (Accounts.saml.settings.debug) { - console.log(`SAML Logout Request ${ result }`); - } - - return result; - }, -}); - -Accounts.normalizeUsername = function(name) { - switch (Accounts.saml.settings.usernameNormalize) { - case 'Lowercase': - name = name.toLowerCase(); - break; - } - - return name; -}; - -function debugLog(content) { - if (Accounts.saml.settings.debug) { - console.log(content); - } -} - -function getUserDataMapping() { - const { userDataFieldMap } = Accounts.saml.settings; - - let map; - - try { - map = JSON.parse(userDataFieldMap); - } catch (e) { - map = {}; - } - - let emailField = 'email'; - let usernameField = 'username'; - let nameField = 'cn'; - const newMapping = {}; - const regexes = {}; - - const applyField = function(samlFieldName, targetFieldName) { - if (typeof targetFieldName === 'object') { - regexes[targetFieldName.field] = targetFieldName.regex; - targetFieldName = targetFieldName.field; - } - - if (targetFieldName === 'email') { - emailField = samlFieldName; - return; - } - - if (targetFieldName === 'username') { - usernameField = samlFieldName; - return; - } - - if (targetFieldName === 'name') { - nameField = samlFieldName; - return; - } - - newMapping[samlFieldName] = map[samlFieldName]; - }; - - for (const field in map) { - if (!map.hasOwnProperty(field)) { - continue; - } - - const targetFieldName = map[field]; - - if (Array.isArray(targetFieldName)) { - for (const item of targetFieldName) { - applyField(field, item); - } - } else { - applyField(field, targetFieldName); - } - } - - return { emailField, usernameField, nameField, userDataFieldMap: newMapping, regexes }; -} - -function overwriteData(user, fullName, eppnMatch, emailList) { - // Overwrite fullname if needed - if (Accounts.saml.settings.nameOverwrite === true) { - Meteor.users.update({ - _id: user._id, - }, { - $set: { - name: fullName, - }, - }); - } - - // Overwrite mail if needed - if (Accounts.saml.settings.mailOverwrite === true && eppnMatch === true) { - Meteor.users.update({ - _id: user._id, - }, { - $set: { - emails: emailList.map((email) => ({ - address: email, - verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), - })), - }, - }); - } -} - -function getProfileValue(profile, samlFieldName, regex) { - const value = profile[samlFieldName]; - - if (!regex) { - return value; - } - - if (!value || !value.match) { - return; - } - - const match = value.match(new RegExp(regex)); - if (!match || !match.length) { - return; - } - - if (match.length >= 2) { - return match[1]; - } - - return match[0]; -} - -const guessNameFromUsername = (username) => - username - .replace(/\W/g, ' ') - .replace(/\s(.)/g, (u) => u.toUpperCase()) - .replace(/^(.)/, (u) => u.toLowerCase()) - .replace(/^\w/, (u) => u.toUpperCase()); - -const findUser = (username, emailRegex) => { - if (Accounts.saml.settings.immutableProperty === 'Username') { - if (username) { - return Meteor.users.findOne({ - username, - }); - } - - return null; - } - - return Meteor.users.findOne({ - 'emails.address': emailRegex, - }); -}; - -Accounts.registerLoginHandler(function(loginRequest) { - if (!loginRequest.saml || !loginRequest.credentialToken) { - return undefined; - } - - const loginResult = Accounts.saml.retrieveCredential(loginRequest.credentialToken); - debugLog(`RESULT :${ JSON.stringify(loginResult) }`); - - if (loginResult === undefined) { - return { - type: 'saml', - error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'No matching login attempt found'), - }; - } - const { emailField, usernameField, nameField, userDataFieldMap, regexes } = getUserDataMapping(); - const { defaultUserRole = 'user', roleAttributeName, roleAttributeSync } = Accounts.saml.settings; - - if (loginResult && loginResult.profile && loginResult.profile[emailField]) { - try { - const emailList = Array.isArray(loginResult.profile[emailField]) ? loginResult.profile[emailField] : [loginResult.profile[emailField]]; - const emailRegex = new RegExp(emailList.map((email) => `^${ RegExp.escape(email) }$`).join('|'), 'i'); - - const eduPersonPrincipalName = loginResult.profile.eppn; - const profileFullName = getProfileValue(loginResult.profile, nameField, regexes.name); - const fullName = profileFullName || loginResult.profile.displayName || loginResult.profile.username; - - let eppnMatch = false; - let user = null; - - // Check eppn - if (eduPersonPrincipalName) { - user = Meteor.users.findOne({ - eppn: eduPersonPrincipalName, - }); - - if (user) { - eppnMatch = true; - } - } - - let username; - if (loginResult.profile[usernameField]) { - const profileUsername = getProfileValue(loginResult.profile, usernameField, regexes.username); - if (profileUsername) { - username = Accounts.normalizeUsername(profileUsername); - } - } - - // If eppn is not exist - if (!user) { - user = findUser(username, emailRegex); - } - - const emails = emailList.map((email) => ({ - address: email, - verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), - })); - - let globalRoles; - if (roleAttributeName && loginResult.profile[roleAttributeName]) { - globalRoles = [].concat(loginResult.profile[roleAttributeName]); - } else { - globalRoles = [].concat(defaultUserRole.split(',')); - } - - if (!user) { - const newUser = { - name: fullName, - active: true, - eppn: eduPersonPrincipalName, - globalRoles, - emails, - services: {}, - }; - - if (Accounts.saml.settings.generateUsername === true) { - username = generateUsernameSuggestion(newUser); - } - - if (username) { - newUser.username = username; - newUser.name = newUser.name || guessNameFromUsername(username); - } - - const languages = TAPi18n.getLanguages(); - if (languages[loginResult.profile.language]) { - newUser.language = loginResult.profile.language; - } - - const userId = Accounts.insertUserDoc({}, newUser); - user = Meteor.users.findOne(userId); - - if (loginResult.profile.channels) { - const channels = loginResult.profile.channels.split(','); - Accounts.saml.subscribeToSAMLChannels(channels, user); - } - } - - // If eppn is not exist then update - if (eppnMatch === false) { - Meteor.users.update({ - _id: user._id, - }, { - $set: { - eppn: eduPersonPrincipalName, - }, - }); - } - - // creating the token and adding to the user - const stampedToken = Accounts._generateStampedLoginToken(); - Meteor.users.update(user, { - $push: { - 'services.resume.loginTokens': stampedToken, - }, - }); - - const samlLogin = { - provider: Accounts.saml.RelayState, - idp: loginResult.profile.issuer, - idpSession: loginResult.profile.sessionIndex, - nameID: loginResult.profile.nameID, - }; - - const updateData = { - // TBD this should be pushed, otherwise we're only able to SSO into a single IDP at a time - 'services.saml': samlLogin, - }; - - for (const field in userDataFieldMap) { - if (!userDataFieldMap.hasOwnProperty(field)) { - continue; - } - - if (loginResult.profile[field]) { - const rcField = userDataFieldMap[field]; - const value = getProfileValue(loginResult.profile, field, regexes[rcField]); - updateData[`customFields.${ rcField }`] = value; - } - } - - if (Accounts.saml.settings.immutableProperty !== 'EMail') { - updateData.emails = emails; - } - - if (roleAttributeSync) { - updateData.roles = globalRoles; - } - - Meteor.users.update({ - _id: user._id, - }, { - $set: updateData, - }); - - if (username) { - _setUsername(user._id, username); - } - - overwriteData(user, fullName, eppnMatch, emailList); - - // sending token along with the userId - const result = { - userId: user._id, - token: stampedToken.token, - }; - - return result; - } catch (error) { - console.error(error); - return { - type: 'saml', - error, - }; - } - } - throw new Error('SAML Profile did not contain an email address'); -}); - - -Accounts.saml.subscribeToSAMLChannels = function(channels, user) { - try { - for (let roomName of channels) { - roomName = roomName.trim(); - if (!roomName) { - continue; - } - - let room = Rooms.findOneByNameAndType(roomName, 'c'); - if (!room) { - room = createRoom('c', roomName, user.username); - } - } - } catch (err) { - console.error(err); - } -}; - -Accounts.saml.hasCredential = function(credentialToken) { - return CredentialTokens.findOneById(credentialToken) != null; -}; - -Accounts.saml.retrieveCredential = function(credentialToken) { - // The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check. - const data = CredentialTokens.findOneById(credentialToken); - if (data) { - return data.userInfo; - } -}; - -Accounts.saml.storeCredential = function(credentialToken, loginResult) { - CredentialTokens.create(credentialToken, loginResult); -}; - -const samlUrlToObject = function(url) { - // req.url will be '/_saml///' - if (!url) { - return null; - } - - const splitUrl = url.split('?'); - const splitPath = splitUrl[0].split('/'); - - // Any non-saml request will continue down the default - // middlewares. - if (splitPath[1] !== '_saml') { - return null; - } - - const result = { - actionName: splitPath[2], - serviceName: splitPath[3], - credentialToken: splitPath[4], - }; - if (Accounts.saml.settings.debug) { - console.log(result); - } - return result; -}; - -const logoutRemoveTokens = function(userId) { - if (Accounts.saml.settings.debug) { - console.log(`Found user ${ userId }`); - } - - Meteor.users.update({ - _id: userId, - }, { - $set: { - 'services.resume.loginTokens': [], - }, - }); - - Meteor.users.update({ - _id: userId, - }, { - $unset: { - 'services.saml': '', - }, - }); -}; - -const showErrorMessage = function(res, err) { - res.writeHead(200, { - 'Content-Type': 'text/html', - }); - const content = `

Sorry, an annoying error occured

${ s.escapeHTML(err) }
`; - res.end(content, 'utf-8'); -}; - -const middleware = function(req, res, next) { - // Make sure to catch any exceptions because otherwise we'd crash - // the runner - try { - const samlObject = samlUrlToObject(req.url); - if (!samlObject || !samlObject.serviceName) { - next(); - return; - } - - if (!samlObject.actionName) { - throw new Error('Missing SAML action'); - } - - if (Accounts.saml.settings.debug) { - console.log(Accounts.saml.settings.providers); - console.log(samlObject.serviceName); - } - const service = _.find(Accounts.saml.settings.providers, function(samlSetting) { - return samlSetting.provider === samlObject.serviceName; - }); - - // Skip everything if there's no service set by the saml middleware - if (!service) { - if (samlObject.actionName === 'metadata') { - showErrorMessage(res, `Unexpected SAML service ${ samlObject.serviceName }`); - return; - } - - throw new Error(`Unexpected SAML service ${ samlObject.serviceName }`); - } - - let _saml; - switch (samlObject.actionName) { - case 'metadata': - try { - _saml = new SAML(service); - service.callbackUrl = Meteor.absoluteUrl(`_saml/validate/${ service.provider }`); - } catch (err) { - showErrorMessage(res, err); - return; - } - - res.writeHead(200); - res.write(_saml.generateServiceProviderMetadata(service.callbackUrl)); - res.end(); - break; - case 'logout': - // This is where we receive SAML LogoutResponse - _saml = new SAML(service); - if (req.query.SAMLRequest) { - _saml.validateLogoutRequest(req.query.SAMLRequest, function(err, result) { - if (err) { - console.error(err); - throw new Meteor.Error('Unable to Validate Logout Request'); - } - - const logOutUser = function(samlInfo) { - const loggedOutUser = Meteor.users.find({ - $or: [ - { 'services.saml.nameID': samlInfo.nameID }, - { 'services.saml.idpSession': samlInfo.idpSession }, - ], - }).fetch(); - - if (loggedOutUser.length === 1) { - logoutRemoveTokens(loggedOutUser[0]._id); - } - }; - - fiber(function() { - logOutUser(result); - }).run(); - - const { response } = _saml.generateLogoutResponse({ - nameID: result.nameID, - sessionIndex: result.idpSession, - }); - - _saml.logoutResponseToUrl(response, function(err, url) { - if (err) { - console.error(err); - throw new Meteor.Error('Unable to generate SAML logout Response Url'); - } - - res.writeHead(302, { - Location: url, - }); - res.end(); - }); - }); - } else { - _saml.validateLogoutResponse(req.query.SAMLResponse, function(err, result) { - if (!err) { - const logOutUser = function(inResponseTo) { - if (Accounts.saml.settings.debug) { - console.log(`Logging Out user via inResponseTo ${ inResponseTo }`); - } - const loggedOutUser = Meteor.users.find({ - 'services.saml.inResponseTo': inResponseTo, - }).fetch(); - if (loggedOutUser.length === 1) { - logoutRemoveTokens(loggedOutUser[0]._id); - } else { - throw new Meteor.Error('Found multiple users matching SAML inResponseTo fields'); - } - }; - - fiber(function() { - logOutUser(result); - }).run(); - - - res.writeHead(302, { - Location: req.query.RelayState, - }); - res.end(); - } - }); - } - break; - case 'sloRedirect': - res.writeHead(302, { - // credentialToken here is the SAML LogOut Request that we'll send back to IDP - Location: req.query.redirect, - }); - res.end(); - break; - case 'authorize': - service.callbackUrl = Meteor.absoluteUrl(`_saml/validate/${ service.provider }`); - service.id = samlObject.credentialToken; - _saml = new SAML(service); - _saml.getAuthorizeUrl(req, function(err, url) { - if (err) { - throw new Error('Unable to generate authorize url'); - } - res.writeHead(302, { - Location: url, - }); - res.end(); - }); - break; - case 'validate': - _saml = new SAML(service); - Accounts.saml.RelayState = req.body.RelayState; - _saml.validateResponse(req.body.SAMLResponse, req.body.RelayState, function(err, profile/* , loggedOut*/) { - if (err) { - throw new Error(`Unable to validate response url: ${ err }`); - } - - let credentialToken = (profile.inResponseToId && profile.inResponseToId.value) || profile.inResponseToId || profile.InResponseTo || samlObject.credentialToken; - const loginResult = { - profile, - }; - - if (!credentialToken) { - // No credentialToken in IdP-initiated SSO - credentialToken = Random.id(); - - if (Accounts.saml.settings.debug) { - console.log('[SAML] Using random credentialToken: ', credentialToken); - } - } - - Accounts.saml.storeCredential(credentialToken, loginResult); - const url = `${ Meteor.absoluteUrl('home') }?saml_idp_credentialToken=${ credentialToken }`; - res.writeHead(302, { - Location: url, - }); - res.end(); - }); - break; - default: - throw new Error(`Unexpected SAML action ${ samlObject.actionName }`); - } - } catch (err) { - // #ToDo: Ideally we should send some error message to the client, but there's no way to do it on a redirect right now. - console.log(err); - - const url = Meteor.absoluteUrl('home'); - res.writeHead(302, { - Location: url, - }); - res.end(); - } -}; - -// Listen to incoming SAML http requests -WebApp.connectHandlers.use(bodyParser.json()).use(function(req, res, next) { - // Need to create a fiber since we're using synchronous http calls and nothing - // else is wrapping this in a fiber automatically - fiber(function() { - middleware(req, res, next); - }).run(); -}); diff --git a/app/meteor-accounts-saml/server/saml_utils.js b/app/meteor-accounts-saml/server/saml_utils.js deleted file mode 100644 index 80cadf2d3779..000000000000 --- a/app/meteor-accounts-saml/server/saml_utils.js +++ /dev/null @@ -1,844 +0,0 @@ -import zlib from 'zlib'; -import crypto from 'crypto'; -import querystring from 'querystring'; - -import { Meteor } from 'meteor/meteor'; -import xmlCrypto from 'xml-crypto'; -import xmldom from 'xmldom'; -import xmlbuilder from 'xmlbuilder'; -import array2string from 'arraybuffer-to-string'; -import xmlenc from 'xml-encryption'; -// var prefixMatch = new RegExp(/(?!xmlns)^.*:/); - - -export const SAML = function(options) { - this.options = this.initialize(options); -}; - -function debugLog(...args) { - if (Meteor.settings.debug) { - console.log.apply(this, args); - } -} - -// var stripPrefix = function(str) { -// return str.replace(prefixMatch, ''); -// }; - -SAML.prototype.initialize = function(options) { - if (!options) { - options = {}; - } - - if (!options.protocol) { - options.protocol = 'https://'; - } - - if (!options.path) { - options.path = '/saml/consume'; - } - - if (!options.issuer) { - options.issuer = 'onelogin_saml'; - } - - if (options.identifierFormat === undefined) { - options.identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; - } - - if (options.authnContext === undefined) { - options.authnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; - } - - options.allowedClockDrift = parseInt(options.allowedClockDrift) || 0; - - return options; -}; - -SAML.prototype.generateUniqueID = function() { - const chars = 'abcdef0123456789'; - let uniqueID = 'id-'; - for (let i = 0; i < 20; i++) { - uniqueID += chars.substr(Math.floor(Math.random() * 15), 1); - } - return uniqueID; -}; - -SAML.prototype.generateInstant = function() { - return new Date().toISOString(); -}; - -SAML.prototype.signRequest = function(xml) { - const signer = crypto.createSign('RSA-SHA1'); - signer.update(xml); - return signer.sign(this.options.privateKey, 'base64'); -}; - -SAML.prototype.generateAuthorizeRequest = function(req) { - let id = `_${ this.generateUniqueID() }`; - const instant = this.generateInstant(); - - // Post-auth destination - let callbackUrl; - if (this.options.callbackUrl) { - callbackUrl = this.options.callbackUrl; - } else { - callbackUrl = this.options.protocol + req.headers.host + this.options.path; - } - - if (this.options.id) { - id = this.options.id; - } - - let request = `` - + `${ this.options.issuer }\n`; - - if (this.options.identifierFormat) { - request += `\n`; - } - - if (this.options.customAuthnContext) { - const authnContextComparison = this.options.authnContextComparison || 'exact'; - const authnContext = this.options.customAuthnContext; - request - += `` - + `${ authnContext }\n`; - } - - request += ''; - - return request; -}; - -SAML.prototype.generateLogoutResponse = function() { - const id = `_${ this.generateUniqueID() }`; - const instant = this.generateInstant(); - - - const response = `${ '' - + `${ this.options.issuer }` - + '' - + ''; - - debugLog('------- SAML Logout response -----------'); - debugLog(response); - - return { - response, - id, - }; -}; - -SAML.prototype.generateLogoutRequest = function(options) { - // options should be of the form - // nameId: - // sessionIndex: sessionIndex - // --- NO SAMLsettings: ' - + `${ this.options.issuer }` - + '${ - options.nameID }` - + `${ options.sessionIndex }` - + ''; - - debugLog('------- SAML Logout request -----------'); - debugLog(request); - - return { - request, - id, - }; -}; - -SAML.prototype.logoutResponseToUrl = function(response, callback) { - const self = this; - - zlib.deflateRaw(response, function(err, buffer) { - if (err) { - return callback(err); - } - - const base64 = buffer.toString('base64'); - let target = self.options.idpSLORedirectURL; - - if (target.indexOf('?') > 0) { - target += '&'; - } else { - target += '?'; - } - - // TBD. We should really include a proper RelayState here - const relayState = Meteor.absoluteUrl(); - - const samlResponse = { - SAMLResponse: base64, - RelayState: relayState, - }; - - if (self.options.privateCert) { - samlResponse.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; - samlResponse.Signature = self.signRequest(querystring.stringify(samlResponse)); - } - - target += querystring.stringify(samlResponse); - - return callback(null, target); - }); -}; - -SAML.prototype.requestToUrl = function(request, operation, callback) { - const self = this; - zlib.deflateRaw(request, function(err, buffer) { - if (err) { - return callback(err); - } - - const base64 = buffer.toString('base64'); - let target = self.options.entryPoint; - - if (operation === 'logout') { - if (self.options.idpSLORedirectURL) { - target = self.options.idpSLORedirectURL; - } - } - - if (target.indexOf('?') > 0) { - target += '&'; - } else { - target += '?'; - } - - // TBD. We should really include a proper RelayState here - let relayState; - if (operation === 'logout') { - // in case of logout we want to be redirected back to the Meteor app. - relayState = Meteor.absoluteUrl(); - } else { - relayState = self.options.provider; - } - - const samlRequest = { - SAMLRequest: base64, - RelayState: relayState, - }; - - if (self.options.privateCert) { - samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; - samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest)); - } - - target += querystring.stringify(samlRequest); - - debugLog(`requestToUrl: ${ target }`); - - if (operation === 'logout') { - // in case of logout we want to be redirected back to the Meteor app. - return callback(null, target); - } - callback(null, target); - }); -}; - -SAML.prototype.getAuthorizeUrl = function(req, callback) { - const request = this.generateAuthorizeRequest(req); - - this.requestToUrl(request, 'authorize', callback); -}; - -SAML.prototype.getLogoutUrl = function(req, callback) { - const request = this.generateLogoutRequest(req); - - this.requestToUrl(request, 'logout', callback); -}; - -SAML.prototype.certToPEM = function(cert) { - cert = cert.match(/.{1,64}/g).join('\n'); - cert = `-----BEGIN CERTIFICATE-----\n${ cert }`; - cert = `${ cert }\n-----END CERTIFICATE-----\n`; - return cert; -}; - -// functionfindChilds(node, localName, namespace) { -// var res = []; -// for (var i = 0; i < node.childNodes.length; i++) { -// var child = node.childNodes[i]; -// if (child.localName === localName && (child.namespaceURI === namespace || !namespace)) { -// res.push(child); -// } -// } -// return res; -// } - -SAML.prototype.validateStatus = function(doc) { - let successStatus = false; - let status = ''; - let messageText = ''; - const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'); - - if (statusNodes.length) { - const statusNode = statusNodes[0]; - const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0]; - - if (statusMessage) { - messageText = statusMessage.firstChild.textContent; - } - - status = statusNode.getAttribute('Value'); - - if (status === 'urn:oasis:names:tc:SAML:2.0:status:Success') { - successStatus = true; - } - } - return { - success: successStatus, - message: messageText, - statusCode: status, - }; -}; - -SAML.prototype.validateSignature = function(xml, cert, signature) { - const self = this; - const sig = new xmlCrypto.SignedXml(); - - sig.keyInfoProvider = { - getKeyInfo(/* key*/) { - return ''; - }, - getKey(/* keyInfo*/) { - return self.certToPEM(cert); - }, - }; - - sig.loadSignature(signature); - - return sig.checkSignature(xml); -}; - -SAML.prototype.validateSignatureChildren = function(xml, cert, parent) { - const xpathSigQuery = ".//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"; - const signatures = xmlCrypto.xpath(parent, xpathSigQuery); - let signature = null; - - for (const sign of signatures) { - if (sign.parentNode !== parent) { - continue; - } - - // Too many signatures - if (signature) { - return false; - } - - signature = sign; - } - - if (!signature) { - return false; - } - - return this.validateSignature(xml, cert, signature); -}; - -SAML.prototype.validateResponseSignature = function(xml, cert, response) { - return this.validateSignatureChildren(xml, cert, response); -}; - -SAML.prototype.validateAssertionSignature = function(xml, cert, assertion) { - return this.validateSignatureChildren(xml, cert, assertion); -}; - -SAML.prototype.validateLogoutRequest = function(samlRequest, callback) { - const compressedSAMLRequest = new Buffer(samlRequest, 'base64'); - zlib.inflateRaw(compressedSAMLRequest, function(err, decoded) { - if (err) { - debugLog(`Error while inflating. ${ err }`); - return callback(err, null); - } - - const xmlString = array2string(decoded); - debugLog(`LogoutRequest: ${ xmlString }`); - - const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml'); - if (!doc) { - return callback('No Doc Found'); - } - - const request = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutRequest')[0]; - if (!request) { - return callback('No Request Found'); - } - - try { - const sessionNode = request.getElementsByTagNameNS('*', 'SessionIndex')[0]; - const nameIdNode = request.getElementsByTagNameNS('*', 'NameID')[0]; - - if (!nameIdNode) { - throw new Error('SAML Logout Request: No NameID node found'); - } - - const idpSession = sessionNode.childNodes[0].nodeValue; - const nameID = nameIdNode.childNodes[0].nodeValue; - - return callback(null, { idpSession, nameID }); - } catch (e) { - console.error(e); - debugLog(`Caught error: ${ e }`); - - const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); - debugLog(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${ msg }`); - - return callback(e, null); - } - }); -}; - -SAML.prototype.validateLogoutResponse = function(samlResponse, callback) { - const self = this; - const compressedSAMLResponse = new Buffer(samlResponse, 'base64'); - zlib.inflateRaw(compressedSAMLResponse, function(err, decoded) { - if (err) { - debugLog(`Error while inflating. ${ err }`); - return callback(err, null); - } - - debugLog(`LogoutResponse: ${ decoded }`); - const doc = new xmldom.DOMParser().parseFromString(array2string(decoded), 'text/xml'); - if (!doc) { - return callback('No Doc Found'); - } - - const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse')[0]; - if (!response) { - return callback('No Response Found', null); - } - - // TBD. Check if this msg corresponds to one we sent - let inResponseTo; - try { - inResponseTo = response.getAttribute('InResponseTo'); - debugLog(`In Response to: ${ inResponseTo }`); - } catch (e) { - debugLog(`Caught error: ${ e }`); - const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); - debugLog(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${ msg }`); - } - - const statusValidateObj = self.validateStatus(doc); - if (!statusValidateObj.success) { - return callback('Error. Logout not confirmed by IDP', null); - } - return callback(null, inResponseTo); - }); -}; - -SAML.prototype.mapAttributes = function(attributeStatement, profile) { - debugLog(`Attribute Statement found in SAML response: ${ attributeStatement }`); - const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); - debugLog(`Attributes will be processed: ${ attributes.length }`); - - if (attributes) { - for (let i = 0; i < attributes.length; i++) { - const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue'); - let value; - if (values.length === 1) { - value = values[0].textContent; - } else { - value = []; - for (let j = 0; j < values.length; j++) { - value.push(values[j].textContent); - } - } - - const key = attributes[i].getAttribute('Name'); - - debugLog(`Name: ${ attributes[i] }`); - debugLog(`Adding attribute from SAML response to profile: ${ key } = ${ value }`); - profile[key] = value; - } - } else { - debugLog('No Attributes found in SAML attribute statement.'); - } - - if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) { - // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs - profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3']; - } - - if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) { - profile.email = profile['urn:oid:1.2.840.113549.1.9.1']; - } - - if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) { - profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241']; - } - - if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) { - profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']; - } - - if (!profile.email && profile.mail) { - profile.email = profile.mail; - } - - if (!profile.cn && profile['urn:oid:2.5.4.3']) { - profile.cn = profile['urn:oid:2.5.4.3']; - } -}; - -SAML.prototype.validateAssertionConditions = function(assertion) { - const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; - if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) { - throw new Error('NotBefore / NotOnOrAfter assertion failed'); - } -}; - -SAML.prototype.validateSubjectConditions = function(subject) { - const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0]; - if (subjectConfirmation) { - const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmationData')[0]; - if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) { - throw new Error('NotBefore / NotOnOrAfter assertion failed'); - } - } -}; - -SAML.prototype.validateNotBeforeNotOnOrAfterAssertions = function(element) { - const sysnow = new Date(); - const allowedclockdrift = this.options.allowedClockDrift; - - const now = new Date(sysnow.getTime() + allowedclockdrift); - - if (element.hasAttribute('NotBefore')) { - const notBefore = element.getAttribute('NotBefore'); - - const date = new Date(notBefore); - if (now < date) { - return false; - } - } - - if (element.hasAttribute('NotOnOrAfter')) { - const notOnOrAfter = element.getAttribute('NotOnOrAfter'); - const date = new Date(notOnOrAfter); - - if (now >= date) { - return false; - } - } - - return true; -}; - -SAML.prototype.getAssertion = function(response) { - const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); - const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion'); - - if (allAssertions.length + allEncrypedAssertions.length > 1) { - throw new Error('Too many SAML assertions'); - } - - let assertion = allAssertions[0]; - const encAssertion = allEncrypedAssertions[0]; - - - if (typeof encAssertion !== 'undefined') { - const options = { key: this.options.privateKey }; - xmlenc.decrypt(encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { - assertion = new xmldom.DOMParser().parseFromString(result, 'text/xml'); - }); - } - - if (!assertion) { - throw new Error('Missing SAML assertion'); - } - - return assertion; -}; - -SAML.prototype.verifySignatures = function(response, assertion, xml) { - if (!this.options.cert) { - return; - } - - const signatureType = this.options.signatureValidationType; - - const checkEither = signatureType === 'Either'; - const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither; - const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither; - let anyValidSignature = false; - - if (checkResponse) { - debugLog('Verify Document Signature'); - if (!this.validateResponseSignature(xml, this.options.cert, response)) { - if (!checkEither) { - debugLog('Document Signature WRONG'); - throw new Error('Invalid Signature'); - } - } else { - anyValidSignature = true; - } - debugLog('Document Signature OK'); - } - - if (checkAssertion) { - debugLog('Verify Assertion Signature'); - if (!this.validateAssertionSignature(xml, this.options.cert, assertion)) { - if (!checkEither) { - debugLog('Assertion Signature WRONG'); - throw new Error('Invalid Assertion signature'); - } - } else { - anyValidSignature = true; - } - debugLog('Assertion Signature OK'); - } - - if (checkEither && !anyValidSignature) { - debugLog('No Valid Signature'); - throw new Error('No valid SAML Signature found'); - } -}; - -SAML.prototype.getSubject = function(assertion) { - let subject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; - const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; - - if (typeof encSubject !== 'undefined') { - const options = { key: this.options.privateKey }; - xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { - subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); - }); - } - - return subject; -}; - -SAML.prototype.getIssuer = function(assertion) { - const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer'); - - if (issuers.length > 1) { - throw new Error('Too many Issuers'); - } - - return issuers[0]; -}; - -SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { - const self = this; - const xml = new Buffer(samlResponse, 'base64').toString('utf8'); - // We currently use RelayState to save SAML provider - debugLog(`Validating response with relay state: ${ xml }`); - - const doc = new xmldom.DOMParser().parseFromString(xml, 'text/xml'); - if (!doc) { - return callback('No Doc Found'); - } - - debugLog('Verify status'); - const statusValidateObj = self.validateStatus(doc); - - if (!statusValidateObj.success) { - return callback(new Error(`Status is: ${ statusValidateObj.statusCode }`), null, false); - } - debugLog('Status ok'); - - const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response'); - if (allResponses.length !== 1) { - return callback(new Error('Too many SAML responses'), null, false); - } - - const response = allResponses[0]; - if (!response) { - const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); - - if (!logoutResponse) { - return callback(new Error('Unknown SAML response message'), null, false); - } - return callback(null, null, true); - } - debugLog('Got response'); - - let assertion; - let issuer; - - try { - assertion = this.getAssertion(response, callback); - - this.verifySignatures(response, assertion, xml); - } catch (e) { - return callback(e, null, false); - } - - const profile = {}; - - if (response.hasAttribute('InResponseTo')) { - profile.inResponseToId = response.getAttribute('InResponseTo'); - } - - try { - issuer = this.getIssuer(assertion); - } catch (e) { - return callback(e, null, false); - } - - if (issuer) { - profile.issuer = issuer.textContent; - } - - const subject = this.getSubject(assertion); - - if (subject) { - const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; - if (nameID) { - profile.nameID = nameID.textContent; - - if (nameID.hasAttribute('Format')) { - profile.nameIDFormat = nameID.getAttribute('Format'); - } - } - - try { - this.validateSubjectConditions(subject); - } catch (e) { - return callback(e, null, false); - } - } - - try { - this.validateAssertionConditions(assertion); - } catch (e) { - return callback(e, null, false); - } - - const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; - - if (authnStatement) { - if (authnStatement.hasAttribute('SessionIndex')) { - profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); - debugLog(`Session Index: ${ profile.sessionIndex }`); - } else { - debugLog('No Session Index Found'); - } - } else { - debugLog('No AuthN Statement found'); - } - - const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0]; - if (attributeStatement) { - this.mapAttributes(attributeStatement, profile); - } else { - debugLog('No Attribute Statement found in SAML response.'); - } - - if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) { - profile.email = profile.nameID; - } - - const profileKeys = Object.keys(profile); - for (let i = 0; i < profileKeys.length; i++) { - const key = profileKeys[i]; - - if (key.match(/\./)) { - profile[key.replace(/\./g, '-')] = profile[key]; - delete profile[key]; - } - } - - debugLog(`NameID: ${ JSON.stringify(profile) }`); - return callback(null, profile, false); -}; - -let decryptionCert; -SAML.prototype.generateServiceProviderMetadata = function(callbackUrl) { - if (!decryptionCert) { - decryptionCert = this.options.privateCert; - } - - if (!this.options.callbackUrl && !callbackUrl) { - throw new Error( - 'Unable to generate service provider metadata when callbackUrl option is not set'); - } - - const metadata = { - EntityDescriptor: { - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:schemaLocation': 'urn:oasis:names:tc:SAML:2.0:metadata https://docs.oasis-open.org/security/saml/v2.0/saml-schema-metadata-2.0.xsd', - '@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata', - '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', - '@entityID': this.options.issuer, - SPSSODescriptor: { - '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', - }, - }, - }; - - if (this.options.privateKey) { - if (!decryptionCert) { - throw new Error( - 'Missing decryptionCert while generating metadata for decrypting service provider'); - } - - decryptionCert = decryptionCert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, ''); - decryptionCert = decryptionCert.replace(/-+END CERTIFICATE-+\r?\n?/, ''); - decryptionCert = decryptionCert.replace(/\r\n/g, '\n'); - - metadata.EntityDescriptor.SPSSODescriptor.KeyDescriptor = { - 'ds:KeyInfo': { - 'ds:X509Data': { - 'ds:X509Certificate': { - '#text': decryptionCert, - }, - }, - }, - EncryptionMethod: [ - // this should be the set that the xmlenc library supports - { - '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', - }, - { - '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', - }, - { - '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc', - }, - ], - }; - } - - metadata.EntityDescriptor.SPSSODescriptor.SingleLogoutService = { - '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - '@Location': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/`, - '@ResponseLocation': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/`, - }; - metadata.EntityDescriptor.SPSSODescriptor.NameIDFormat = this.options.identifierFormat; - metadata.EntityDescriptor.SPSSODescriptor.AssertionConsumerService = { - '@index': '1', - '@isDefault': 'true', - '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - '@Location': callbackUrl, - }; - - return xmlbuilder.create(metadata).end({ - pretty: true, - indent: ' ', - newline: '\n', - }); -}; diff --git a/app/meteor-accounts-saml/server/startup.ts b/app/meteor-accounts-saml/server/startup.ts new file mode 100644 index 000000000000..0bd09acc8a0c --- /dev/null +++ b/app/meteor-accounts-saml/server/startup.ts @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { settings } from '../../settings/server'; +import { loadSamlServiceProviders, addSamlService } from './lib/settings'; +import { Logger } from '../../logger/server'; +import { SAMLUtils } from './lib/Utils'; + +settings.addGroup('SAML'); + +export const logger = new Logger('steffo:meteor-accounts-saml', {}); +SAMLUtils.setLoggerInstance(logger); + +const updateServices = _.debounce(Meteor.bindEnvironment(() => { + loadSamlServiceProviders(); +}), 2000); + + +settings.get(/^SAML_.+/, updateServices); + +Meteor.startup(() => addSamlService('Default')); diff --git a/app/meteor-accounts-saml/tests/data.ts b/app/meteor-accounts-saml/tests/data.ts new file mode 100644 index 000000000000..b74f59f0ed2c --- /dev/null +++ b/app/meteor-accounts-saml/tests/data.ts @@ -0,0 +1,384 @@ +export const serviceProviderOptions = { + provider: '[test-provider]', + entryPoint: '[entry-point]', + idpSLORedirectURL: '[idpSLORedirectURL]', + issuer: '[issuer]', + cert: '', + privateCert: '', + privateKey: '', + customAuthnContext: 'Password', + authnContextComparison: 'Whatever', + defaultUserRole: 'user', + roleAttributeName: 'role', + roleAttributeSync: false, + allowedClockDrift: 0, + signatureValidationType: 'All', + identifierFormat: 'email', + nameIDPolicyTemplate: '', + authnContextTemplate: '__authnContext__', + authRequestTemplate: '__identifierFormatTag__ __authnContextTag__ ', + logoutResponseTemplate: '[logout-response-template]', + logoutRequestTemplate: '[logout-request-template]', + metadataCertificateTemplate: '', + metadataTemplate: '', + callbackUrl: '[callback-url]', +}; + +export const simpleMetadata = ` + + + + email + + +`; + +export const metadataWithCertificate = ` + + + + + + [CERTIFICATE_CONTENT] + + + + + + + + email + + +`; + +export const invalidXml = 'not a xml file'; + +export const randomXml = ` + + + Value +`; + +export const simpleLogoutRequest = ` + http://localhost:8080/simplesaml/saml2/idp/metadata.php + _ab7e1d9a603473e92148d569d50176bafa60bcb2e9 + _d6ad0e25459aaddd0433a81e159aa79e55dc52c280 +`; + +export const invalidLogoutRequest = ` + http://localhost:8080/simplesaml/saml2/idp/metadata.php + _d6ad0e25459aaddd0433a81e159aa79e55dc52c280 +`; + +export const simpleLogoutResponse = ` + [IssuerName] + + + +`; + +export const invalidLogoutResponse = ` + [IssuerName] +`; + + +const samlResponseStatus = ` + + `; + +const samlResponseAssertion = ` + [ISSUER] + + [NAMEID] + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + channel1 + pets + random + + + `; + +const samlResponseHeader = ''; +const samlResponseFooter = ''; +const samlResponseIssuer = '[ISSUER]'; + +export const simpleSamlResponse = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseStatus } + ${ samlResponseAssertion } +${ samlResponseFooter }`; + +export const samlResponseMissingStatus = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseAssertion } +${ samlResponseFooter }`; + +export const samlResponseMissingAssertion = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseStatus } +${ samlResponseFooter }`; + +export const samlResponseFailedStatus = `${ samlResponseHeader } + ${ samlResponseIssuer } + + + + ${ samlResponseAssertion } +${ samlResponseFooter }`; + +export const samlResponseMultipleAssertions = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseStatus } + ${ samlResponseAssertion } + ${ samlResponseAssertion } +${ samlResponseFooter }`; + +export const samlResponseMultipleIssuers = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseStatus } + + [ISSUER] + [ISSUER] + + [NAMEID] + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + +${ samlResponseFooter }`; + +const samlResponseSignature = ` + + + + + + + + + + S2qmjxIC0ncXw+7n6ptxy9p24oc= + + + ECRjbLzq2QbPRfhBSJRhjCR/3hxt/uUN8zjUmBIN2LMvytG8FGsuWzC57pVMDBNpwdKKSwv0U1PieLWU9tMoESKGOhoHLzK4w9otlhgQDfy9qjqYBVv9Bp67D2Tx+dU2S11y2GnKH749fbNnmASYynQumkFxB6nunaCNXmVu842PK0jlJQUufOCb4nMZZHgK6RYir49K8lROXqHn02+L0iJAxJggr5eWHftBsxJWh32pE0T5DTuhu9qm8sq5aSSl5ybJhE9N4L1TOXmWmgeM8qa/MwV4+sNDKIKo32EbLeo1ybEmg9GEzo2vakm5zcFYALxt5egtx29iSrX2qIH75Q== + + + MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ== + + + `; + +const samlResponseFullAssertion = ` + ${ samlResponseIssuer } + + + + + + + + + + + 7VshRWNbgdIesGGSQiwS7tuhkzg= + + + EYVTttrq3Yxkp/I+U271CYKpeYMHPEb9oZm/ZKyGzCkMI8GNvwh7YOhT/+M7NwLOVjpdvAZlXQFeyxearlVDgPvyZtUNz8LwpnQEu5LkV8jxzQczW+x71OnantwKecpz3eyAEvvEjjWtZf1m8IoH5UtGVqW6SzIkxWN2ixudRInAUMgSq7IXp0x1BjL4N69Y3IsW48PCdKTpuAcsdefvsR8tLgeWOk3smigfsu72Sp5sVh/n3AHiCXJ5fgLYfLiY8cXwQzZ8JSjFp7H2lyrl0Tth2TCBe1DemBRzCQ2t2ZbAjwUrsI1Xy8GAshq1nNplXMSs53HEqSay40USqqTZ9w== + + + MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ== + + + + + [NAMEID] + + + + + + + http://localhost:3000/_saml/metadata/test-sp + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + `; + +export const samlResponseValidSignatures = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + afI/fG2Gkj4Nlu5vC+PdYon1aNk=Gndt6TSrAaSs/BK84bqVXUMz3cvl34dIpHEZ2o7uqAf66SCF3qjLLm5fV/bFaSMOPwnVNJFjXpxmKdZI9mwBKBMYutxd43wkBvkp+3MYVZcRTpuU2Wo6iQLy9rhScB5MLRMEe3lKpwBRCKGEBUn1V/WaVUWlReHNHtwCnXD6FhtG4PBfd5p4dGePRQxFd9a0Pfm0wN4AposjLNNzGLf8yFTPTmGlZJ44U2IEUlxtOeH0MP7v7yAvwsjOk1PUJcUB4jgM8Y4WPCgEN7ntBdkSH8Q79tS6gyn/gAN9PW8QPcWNf3FVUnRfL8WRUeqUfohTUscRftj4Ff60Ob/FOeoIlQ== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; + +export const samlResponseValidAssertionSignature = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + afI/fG2Gkj4Nlu5vC+PdYon1aNk=invalid signature +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; + +export const samlResponse = `${ samlResponseHeader } + ${ samlResponseIssuer } + ${ samlResponseSignature } + ${ samlResponseStatus } + ${ samlResponseFullAssertion } +${ samlResponseFooter }`; + +export const duplicatedSamlResponse = `${ simpleSamlResponse }${ simpleSamlResponse }`; + +export const encryptedResponse = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + a9/HYUoNaz3fCqNEWASnblQ2Boc=uj/NBTAqxYarAS+V+in8aQ7/ZoJZapE81HD+v+RR0q5LSRfpFoqy52R6YcSvV4d+eYe365SXpGGHedRVZ9UbzvLyR2XOlopIf/SweFOYXaPBd30W4KxRAlcaauF5kvYjmshDZE0YkGJUcB3x1yNaEyW8UuGA8Bq6be/ytEa6ZRsb2tC/81nR+LOAQwNdfLmsturHDXHSZltobm7MQSLC1oGnS8ha+/7N5laeTWsgQuuYRbUkSP4yTf/2fdg4U5LH7RD/Hhha+kO8geWM/dC1TdME/KtYT7AseHJxAa0CRvOCW2KLACllM24xU/5oLf6Wt447bzQj9Xt2LI9D2g1nNw== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==jVRjHcdQc21xw0vRKUDtPScv+GPY6mxOJiPmiaBFxBkrCDHjlfLmcOi8badR5ZPiloDFfEc9SSVkqEOoJLVHNttnpWxTxP6ySwSD4hD1yAyDm/YBQkQkMkNVsRB9dBkXSx15g9wQk13zfcjzaLIohWqAdau4ISaWyObmMDZjDS8tLa9vE93e/VPwVp8rB/lSDn9OMymtZHWmPHkR7tB6zYGGgusUeUujb4d9LJ795nLst++0QQHbh0C78BvCwV9fUoK8WPBfwVLnSDM15pbWsFPUCwvziBdCh/jQX92S2aeMXbiNWmIsVT3IacItKjYlbHaAFCEfmGQP0ALOg3cFlw== + + GpdyHBIP86f8BCD5coOMxWdz0R7U+t1ICgLvUHwX1aKWXbCLtjWwi2Ke1fcFylUUwOJFvNWR+nIQjHDiv89nUSvSOWtdZJE+eeE8Vo/V1M50+lIfTlVwKJPNnpBbFsAjDqiJzQXEYbEfQM9uvt8zHnaJuR57CxoDFliPNoXmrcQwwpzhZpxFbzh/KDNayqB2W7AgswFAGorfrzCcl4yCaR7V6Yb7mTlD28F7h6zGsNIDX4wBQv2WxsaA8+aLutIQv3Bw0Q212yJVct5ik8sGnP6rZitG4hl4heAgVLY9Svk8PcRfYC/hrd4tpykQWMhOzqPKrUYG/ZDshkfk7EhJyT21XWrV9GrGClk5lBu0TAmhyxF1eBs82RghJ5SFL1TH1GKgTIuUSs+jLPA6G0O7PnWAvMI7D28S14x2mBG/lzN6SGJy4ALzIm6evnKweKsEjm+bGrOoGUdkOrMIrPHLXwOMujssryuqFqjfjhtefLaKCcM+oVrdOZe31bB0TzHxEmOTeBW9aD2KzczZfHQ02taTDc5UW4Elm6hqWnTj5uJtVPB8vDNBrEtc50Cuj7M2GEjkUbdFT35nHUXfSV5YatCQKNYy5FrEmN9o50KADIhDeNULLo8k/1GkypSS2wvrSyJB80HjGJd8+LOIStR5f+iqWrJp+N1kBUGCkyOnmzLkSxBEwK11F+ErqT+tYSMkAiE17540NB+sxpz+J0JsZorztWkVceDFUKPN/mEzEyMaykqc/7Ux1+ixJRuA9BWUT6q7wbYR3QuOlHZZF+jQBwNSOYSu2uYzqUf7GwVpwlzHQtzJryA50ePlP6UghKkSV/nTGXKhzOJ4EAFVrtm07TnjifCZQ57PM6G0yXa/Rm0Poyy3B5UGWBuSWl979tKYxt9q9T4ZBipo+PLmPyFm9iYK0V2le/ebx3BruBXzeYM+929cXhRhqL2otS2Ev2RPB477kBppmrmR2wwq2FkQ3c8q0CQyj7ndj8m6tFJnw8T4C6V2kyNbrVEeJDotoRyln6GYu3bpeGJDWrY+ThEY1vvhWN+U4kY5c1Pwcj2cqdKiG/ljrDJa3Vu+CrJyv8tjs5zAnWn4d8Sr0NZf5IC9X60rFjVFs98OHwo7+fBadlcckSUKUqvq4L1sh2YqjxzMPJbq8Mre7/hxS7VlOe/pDYPH5d4+KuPpLRUhGVxXkWVWlYc1i7vNFDAWQZfQ2zjhesB4+OytELWPKbsoMA+5QjnE0B98gwVPWE94eaLzkwVBYPRBqkZAINGC5GnvDnyMO8fJL/kS6vMRirnA845rzCU9WwQNoKwu6kIyp1h7ls2tPHZuBtSvLdkaLB/bwrr9wdRQwoQ7DcBUzZqSLEPNmiKwc/UX9mmM6ZLdfzyjLeTEnyoz5rUQydz3/gs3MiFoRNkkh84ZMoqaqewX0OGKrN+KCOYEgxEgt5wBhKdOVOIFResUYH6lNLLWstTn0QXHT9uuAUz46juBOlZIxO7j+rU+C70/OLxqVF3zN7sIdzOfUiXLm5g43eYskCp3erwDjtZBRt87uUT5OwH2LgF57mXdXqPmkYsIleY3VcYfDZcaGpxovKsZVfGao6XajFCZGI643yuUFsEw5B944EHbzPPWrJUS1kHakmwExzv/jpJn++255iTpQQCYRX0AOaHsUxJSTKnMU6p1r/4P0pRM8kzZOlpVKx0iCkqb44SJX82uFWzzJ0MLDiOvH0c4NUCz7RIBgbHgwKaEAYeTfWXGAsgQ8ySozoxHyvnzPr2EfsXeVXT2QueZkjpeEQnI1VyWQLK8T70KsfL7Ckzw9jBv9VxRDEjp4VQ0EL2Grn5tcmc2NojLCRwYGYKz23SSgwYfIfGLqKKnj1N35G1iHDuDnKK1rUf2fapDw1C6f+NzsPqp/I4aascZTbaQRVrxwKXAfOTBKDPsCA5vEJ+zd+KhqazqsG3/IrhEe0k2fbitQwEG3ElYDTS0VRKstrSsDRv9Su6aEtGWkWgDjGCI/zSBwVIoScWLIkLY5KcH6Nf1SJHfMbTjJ49LAQBkoVgkA8J7EbYrCEj9GHcKZ/utY2K734d+UzC4QKN0L7LFLNTHRZLDaD/eVvsgcCumcJXLFv+WQVnG5+UWJVtTgsnQqizLTi4HqUt+AjqxB8tpnOcGuP12ElT0w3oSiVFwgDVqITyXqKciKlx2TrOgbIC006qoLYiMBJwDWld0nS8M5HbfGi8tk11+WpnuszrNoBNU8zhpBeTK9NTmeaVU5/Hilboka40IuAYFDaPSmM6yMlVOJWWFrVs8G/yvF+eIrQRDiukXO1ysualbE6W/3H/gTqMQh424ZyUmJBH4GQ+xMn18HMykcnBvKBrP+t2fyCuZgVoH/CbMMiDKvNhOtdgSPQnbVd4Z7AKZSL0UB1ElDijVoGYF5RzTM1nKCvxM6ueEn2w3fFZNRq2j4lRQdULTNXzBiHD8ho/jeHElk05AE2hxBnluFNxo1rH+Y52bTQQhwxnYQUTGrt7ORpAhTQbsP9osWzbGUmBYu6oDjd4yQI4Y+cOgDmrNLlonj862mq3urohNJoZm3m12mlttwF9pzXMn7SQ9fsx5jp/3XssEGG9OlNtNZBv9oRdvppIrdVY/SExLTzlrGlSNpIXZncIjJxM3fB4EzTwWaSz+vGVfjBfOhiWOzMy97YrDbyojH5EnIkkjxJf7GtcLYmgsjIj3GIqTDjxs7Y6MckPMNkD3riv12fizJPTFnRkJm1mA6E7XhF3dqpGtoA8Ut6gPwEzoswus+Gy+M1Jx0cAT+Gbzsyoh6CrRjGj/lpnneDLxnH4fkeKqOa3p9HacEcKswETw9nCV3hYBR3erXyUJLO18VI+uG+NzyqRTePBDJpaGMFQAirJDbS+Y05F/S6xJzsacvlk1OavN8OrG/ie185NnV7uN5weAv+8qVt0mo8nNLhufKktn/NC7XeLh9WWJCa1uxBZVfhvKcj7plqc3g9i4WDKFS+ZguxUw60ApAVma1yD4GzMZl36xLDqkUrpU/4DLNswsyxx9ssA12U2Kvg+CMNjGdyBEHHdvUF28OzsiT8cr82YUjgmKd7YG6G0L1V3mPXC1JwPt2fcMQ/91OiuUuTtR7WLZ+JThxYPx1H51fkqPQg5n6ePygmCFI1o/1YHhelfl3a4zWrT/zo9+A5ow5CtaV6GMbOx3CwfWa07dXvtMujjc+bWeyxrlkq0m2Li8aIgRzQX/KRREVWVR9gUNbdPsAu/9lm38UDnrrWyQOP7yA7PfJ+UKI+ZBcGWmQodcVYrAkWxx2YhOwkrA/3cp/tzR9i14Lmhdw7tGz7H6rjcb2i8jHIFY8qCbRvfhbsYklelmsRGYrfeqbKR5J5BioGXQRL9v0z92kG9svJKnuKmjkTZb8MDmp2V9vYw0laYKqymx7AFtzC9FB6nkajfXAQ444ZdLoZSbsEg94/gHwVvGa1vXWg9QR3+cYx2h+0j+Kh5v6rzVyKMd+Vp8TFYA7VXrljJwvvP7YG8Mp5yEIXukJIWCGC2JuJVI6xsWtp6xzbYOn9BmWzOBuscI6PscEyFB4MZsJsgSGrDX0X1GiXUOAX9h0Fof0jVYheLGNmxD/KV3fAifvSPWIZ3VEMM2jhauAaNetAjzIpnHo9HPg0jLC5oIJMNpOeo34qx4Pffs56CuSGCrEq7YJ7LAmiXnykAiDH2Rpkfpu/fNmEZ0+EoOI3zvVgfxBOffMW+6P2ViDE8ZLa+br15Mb+NauX4+ZV1oCkaX79MyzISuddPbdKg+66MUlFeL5jyZt2N8mWPVZxl59nSXNUgTQ+7T14zuLDQ03gzvuphcbZy6mXOMl96e+BJLDKLQPlaDUFjq+UqUHVhomjDWqyZ5kLEl8wrZ7v//1MHZymOm7uxpn7Ulctkac/TzDkMa/AWi7vnBRjY8PuNiKTaUIdbLa8Fp3Y5L0Xiu8h/ssTT97LTuxitA98XsqOmipAiVhMzljmmLMY12kVajnRf3plzXvkVqxeq72e+Hm2yFSXNEfO8fbrw2vts5fQ/txf0PK4ua2uZMmT2d/yWVdbXCP//1ImY3PvYZyofKg6CdD2IjTRJxxfWm0QQa+HzT5XgqdoYslu+EWO2kZDKZmQMrIv0j5/PHh8ahAc8KVCHP44uQlGclLda/+bCWT9kZ7wbTzUJHKSDkwUIb6Af9wwaWrLAGhPokGI9pcgKxPgRNuK05DrLke4KibX1TbCl/OPBfzPN++uzydI4XIM9Kktqm23+e7IEkHx1mdxQMZTnJVia6XENtSghboiltXWyTet5TiQqXtAXJZiQXSMIQIEuRwRhB9UcVS2Z62lvI2WPYdCsdlrSJXfm13BgzyRib/+LMqZ8IKF6Wh26iUgL1VFH9a+h0NdhXTHP9po3Wzn0dAUQWgon6Mc86PC8yOvHd+NTb5qRBIHS3ZkGIAL0mniSCqqiNCI+/wESshCKmnd8AgKV5MBY5bXuAOK6ZaJj2thO0wae/XGwJ9lx/DEaKB4UwQPuot3MfdGSYeTEJnGIZBZ43N9sCJaPXOYO2yYBX9tiaa/YYPluzhdBPTRRfSYYjqZfTPh4CsPAscrkkIyM71KbtAr9TNHoFfA+aK6Bh/fUZXRhR23ar9IoSUs51TW2zcKhad08WghERCIvP6Df6NhNbrVoiYJrnWpmZTr2lqpOF9CmsdUcyQerdUT+Gqgoto38ALGdNEQ6wx/iLkZt6UIWG4KtPRl5NGnJ8yQmb1YXHM50kpOQLWLkC+NRvCPLBODPfBOE6g3l42DL+qIuoAfJH9BSZrBXMIwxSgC/K+DIHir8RVMjR/mHywwTM4OtmrNCoVpTC4h4Kpjok8x22AHmFdMbFAhgdFPgguvp2+ggz/5jwonfdqIgDzKFbjum1mJCVZlz18qIwPxFttVT/k7rPCM8F+cSiKarhb2RewX7s2MYHl05ODpKOSRSTIYGvlPGYyigDLFY/fXU5CDa7kxQfZO3Y8P+asraQ2VuOiMG6XMW9JrIVRl8w0FotFRBfxUhHrvS1X2svfaZFucikQ+VMOWRH0/8Ro+goM0gK6ba+zfeWElAU8UFShQp179yMqAzm1dyv/6cbwjKSC0ExrSKKbjVr7eOq21lL5JWltwstmQC0B3GuEi1Wiie3rsT9I6GYW0zjREnfmxyNEYingL1eLaJki7UOCD9cHLDy++zTZLUj8lgOK0wrVqHqdsG74/iknGb7UDORIoCEfNy87DC7Hzp8C3uQ0qoxPWO3K1Y6N2QI+4zc9/2xtug+f3o+9UdsnxOP+Z/zIaV2VOy/MySjwiVOgFJu7/xb5L69F4A8WTrkW6ROuQORC26aD3Nt2XDiUy7Dqtg/yT0LXMewSt0Y1wa4McTJh4AXC1QhBGh9SrA5YS0fMgggVVbA1gUcNSM8Qle237z7l9a7Jx4gPDyZ/D2Vqq7WPzHudagAMyu3dSb/Fvw6S8+5rfZ7AN4CKXxhODOXnGgh4MC4cTBxx5yxNkPgAASfoG8MD4do1p8BWvWNHgEzvvzGw9T2oPrHJLZCoXS3yjIC5Ne1BPn0P+NYiZyNbG1a9v7mI2sezvJu+5pgApT/mKVIjDq33XQoM3//22nM9Ki5pSZS4IMomq2pKsHfGI6s1ZvS9j8UcQPMreKm3P/9QvDQkm8KMpgqkIP/aigzgAo2XSjU2S5AttHHHxSlHJBxVP6h4elu4oBywMl+btK01kdFt4jIIub5Xj0JOztLRUhhLKHGzIBHpvaBqkFaq+tjaGYqWeBANkcTAJYbfqT+yIXhWiXiBa9or9HTfAmOEXOlazE5f6jGUBW/DRgrepm+/bFBRIl656+xsJV75ma8a7+vxsLz+avkruh/rTH2SW+UcBmwEzluL5ZI0SjeSmOzIATTO60c4XiXq5sdFvquhaFc9/6dtF7J15C7xeraj3H8yF5/3SHYh1H/TFIO9eVtbPeC6RfIDV8Pug5WYk7staBysv2u+jMw2ECpTVohqWPpyf6mUWRA0uMysem/pqdtV93fJ1+svlWKsdgChlb4RkW1WLH8zbyqnZtBlzpJOZ2vGz7E4yAM85nLzAkRRIxRyxFOi5j12hke4HW3oVi/FN/0gLP/2fukPTO6W9Ms2hvBuMX2AVWEuglr9yBNq2M2rhxlMP8CtggyhcTfydnvFkBhQa7zUdHWH0oBwRXloTJEcbKS3oY3uhujcnyT0DiE3NiOAV8eGMQMiVt1nMxN3+eM/+enu1eEQuhD8be6cNnSKWDnRMk= + +`; + +export const profile = { + issuer: '[IssuerName]', + sessionIndex: '[SessionIndex]', + nameID: '[nameID]', + displayName: '[DisplayName]', + anotherName: '[AnotherName]', + username: '[username]', + anotherUsername: '[AnotherUserName]', + roles: 'user,ruler,admin,king,president,governor,mayor', + otherRoles: 'user,customer,client', + language: 'ptbr', + channels: 'pets,pics,funny,random,babies', + multipleChannels: ['pets', 'pics', 'funny', 'random', 'babies'], + customField1: 'value1', + customField2: 'value2', + customField3: 'value3', + singleEmail: 'testing@server.com', +}; + +export const certificate = `MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ==`; + +export const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCi8vclbfAabX0D +LRQYqXCTiJVL+I2uAViMuZxTbxY8Yzx+FKFjSyqitMUycpuEaM7+DMkb3S7fx3MH +7Bbpq9UJ0CB8zFi+Y174TLQmncdtegl8DmhYTOoPqSHBDP5m/gH/0KJuCB7fHbf5 +aMBHOMMUMnJnJ3QliRLFeDjFj/I/SzvQL+QIkqkaEf0kXuQ1E2lvdgvvJFved19M +U0d5ao9WKmtDYiQEDTM+BhY74UXHUFRvHj23LpqYkwYX6HJ+a/LRfrT0rbV86+YJ +nR/PkKgNTl7wtRU1kcKgyaip/sFQwFPOVUrMlWd/Ejm/Eh0MFv6UricyLYBCbyp8 +C9cWHVrBAgMBAAECggEAFhgHdqW/ZnXt+15DWUywHPDp/VEINM2t6fbIwW9QfoOe +EiJN956be1AzZLGxcHSdjEjDg+mrj2AFss9KFAjea+QyY3l5lub2W4ha7Nl7ztY7 +LvztHPvgyJrQHtLaM7DBKKRrQawMM4heB40ydPW3TafBZ0csMmKxjuDMIc1wtTAQ +2wb2MQt7QwO7H5he/JNArgMOAmZvkPowqIqL/YsWfVz0TdiPgQ8t1JZxBavwMZkn ++wE47aVN+K0ksy67pF5DDtygyMydSJV8iGWRbe9VQqsOwpjNYbGfDiTT7+wsANJm +nTwNQ0vWwzBSA04aRv/40L9cyR642JHV1kg5GZGB2QKBgQDNTHitGmFkAvH+1/zo +JXrSCe3J3xMxTOzSBKOU7B8Fr1VAVh1EcVGEG3/1FB2eyXB4sgtaSDkMdltvV/Ml +ZxnlTTcTPtTNgABofKGQOx0kOdDargM8n+zzuQ0cRTjobpusR2LIKJCllmLM2Q2g +9rsSX/j1tM8KmE2SeJ+9w8s7zwKBgQDLMQm/QZOAQB7zxgfLZ0jtGMqxHBi3HhSP +KQ5Zh0WLXgTFNOv8gjwIc1rtPNM2l0Yzi3ytpnB3jDQXk29t2CxUPXm2aXZRdA2g +TFTf+OqUExebB6XraV0b4/6if2O5gpmFfCdGVSoXuzFg7LKtmpD+rkc2blg833dv +GcbO/9nUbwKBgQCpTJ/TuIaJ8DfaPgms84OGhHOY3yI3rMU7KGIx5Eps6Ls39Avs +rjpX5EmwNKd8k4fxsHnWOOr60Pv0JSY5OP3M79E0SMM6uI0dnXGqvGT6w8btH0VC +EGxaTMd4Acm9O8Ga37+hanpmY08UuQYZMH7y1zw6e6GljhWibWDmH/mQVwKBgBpY +53ynUisFJX5SpVwYrnogBthkXkgQXHYbysKNKdVigZfYvujlMkeePaIZiwG/J9kz +Mx2JQXge8/pCoeZKa6UYu5mNn0v8km/Athi8vB4rQ5pUqY0XAn3FWJVVk2bQqnuG +l8kk7epZ2ZNJ3flo23hKvO0v7b0m9OOxIfhhcKt9AoGAaRibkQ+Xheyx2osMmx/5 +f1+NzddMnmaVanLz9NbodpeiTv7Ny8Ig+Y9GywhFnXyodB/cQ7pvDWtlCgjWoo0j +taYugaU6N5bKTyRKYY7wSsbCXUyC2z8pS3ZGbJNFbZ9lYptFn9LpYXqaAzsx/ad7 +V1v/OC73qmP95kdcjAfjnWY= +-----END PRIVATE KEY-----`; + +export const privateKeyCert = `-----BEGIN CERTIFICATE----- +MIIEIDCCAwigAwIBAgIUL+MGp9n7+oPlQOaFrUZyqxoKakcwDQYJKoZIhvcNAQEL +BQAwgaAxCzAJBgNVBAYTAkJSMRowGAYDVQQIDBFSaW8gR3JhbmRlIGRvIFN1bDES +MBAGA1UEBwwJSWdyZWppbmhhMRQwEgYDVQQKDAtSb2NrZXQuQ2hhdDEQMA4GA1UE +CwwHQmFja2VuZDEPMA0GA1UEAwwGUGllcnJlMSgwJgYJKoZIhvcNAQkBFhlwaWVy +cmUubGVobmVuQHJvY2tldC5jaGF0MB4XDTIwMDYwMzIxMzIyMVoXDTIxMDYwMzIx +MzIyMVowgaAxCzAJBgNVBAYTAkJSMRowGAYDVQQIDBFSaW8gR3JhbmRlIGRvIFN1 +bDESMBAGA1UEBwwJSWdyZWppbmhhMRQwEgYDVQQKDAtSb2NrZXQuQ2hhdDEQMA4G +A1UECwwHQmFja2VuZDEPMA0GA1UEAwwGUGllcnJlMSgwJgYJKoZIhvcNAQkBFhlw +aWVycmUubGVobmVuQHJvY2tldC5jaGF0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAovL3JW3wGm19Ay0UGKlwk4iVS/iNrgFYjLmcU28WPGM8fhShY0sq +orTFMnKbhGjO/gzJG90u38dzB+wW6avVCdAgfMxYvmNe+Ey0Jp3HbXoJfA5oWEzq +D6khwQz+Zv4B/9Cibgge3x23+WjARzjDFDJyZyd0JYkSxXg4xY/yP0s70C/kCJKp +GhH9JF7kNRNpb3YL7yRb3ndfTFNHeWqPViprQ2IkBA0zPgYWO+FFx1BUbx49ty6a +mJMGF+hyfmvy0X609K21fOvmCZ0fz5CoDU5e8LUVNZHCoMmoqf7BUMBTzlVKzJVn +fxI5vxIdDBb+lK4nMi2AQm8qfAvXFh1awQIDAQABo1AwTjAdBgNVHQ4EFgQUiVZJ +ITXceZdB0zdmBprHCdAYmhwwHwYDVR0jBBgwFoAUiVZJITXceZdB0zdmBprHCdAY +mhwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEACfbUixYh/DdTWw7W +wDJZ9lA2ygx8i/mMUzi656LD/p79WRY0XhiM+3ecEEnsctjCHVeRF9ncIsx7TfZ0 +XkrfQQN+InFQnyVgI/NxPQZQMheKvRFzcmIorcaCJWsPDyurK+h7sUFqn4Ax7R7x +IAcxgTaVmD0A1oQbYKjWApKiC+3sDJZIDE78zUneqa+zkH6+7W6H6ZyzwauVSYxq +bysEYp2E/oHvqazQiFwfxWTTrgqKS4VYhN4eV2BQeNRD93UqK8/YUdiNLugc06HH +fLBO7gBdTXqPlRt4IpjNSrtAEvwdhoUO51uvbRiTVoBkbeEbbkXd5cb3IlD3GIb3 +PUdU7A== +-----END CERTIFICATE-----`; diff --git a/app/meteor-accounts-saml/tests/server.tests.ts b/app/meteor-accounts-saml/tests/server.tests.ts new file mode 100644 index 000000000000..af9c791fe40d --- /dev/null +++ b/app/meteor-accounts-saml/tests/server.tests.ts @@ -0,0 +1,975 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; + +import chai from 'chai'; + +import '../../lib/tests/server.mocks.js'; +import { AuthorizeRequest } from '../server/lib/generators/AuthorizeRequest'; +import { LogoutRequest } from '../server/lib/generators/LogoutRequest'; +import { LogoutResponse } from '../server/lib/generators/LogoutResponse'; +import { ServiceProviderMetadata } from '../server/lib/generators/ServiceProviderMetadata'; +import { LogoutRequestParser } from '../server/lib/parsers/LogoutRequest'; +import { LogoutResponseParser } from '../server/lib/parsers/LogoutResponse'; +import { ResponseParser } from '../server/lib/parsers/Response'; +import { SAMLUtils } from '../server/lib/Utils'; +import { + serviceProviderOptions, + simpleMetadata, + metadataWithCertificate, + simpleLogoutRequest, + invalidXml, + randomXml, + invalidLogoutRequest, + simpleLogoutResponse, + invalidLogoutResponse, + simpleSamlResponse, + samlResponse, + duplicatedSamlResponse, + samlResponseMissingStatus, + samlResponseFailedStatus, + samlResponseMultipleAssertions, + samlResponseMissingAssertion, + samlResponseMultipleIssuers, + samlResponseValidSignatures, + samlResponseValidAssertionSignature, + encryptedResponse, + profile, + certificate, + privateKeyCert, + privateKey, +} from './data'; +import '../../../definition/xml-encryption'; + +const { expect } = chai; + +describe('SAML', () => { + describe('[AuthorizeRequest]', () => { + describe('AuthorizeRequest.generate', () => { + it('should use the custom templates to generate the request', () => { + const authorizeRequest = AuthorizeRequest.generate(serviceProviderOptions); + expect(authorizeRequest.request).to.be.equal(' Password '); + }); + + it('should include the unique ID on the request', () => { + const customOptions = { + ...serviceProviderOptions, + authRequestTemplate: '__newId__', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions); + expect(authorizeRequest.request).to.be.equal(authorizeRequest.id); + }); + + it('should include the custom options on the request', () => { + const customOptions = { + ...serviceProviderOptions, + authRequestTemplate: '__callbackUrl__ __entryPoint__ __issuer__', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions); + expect(authorizeRequest.request).to.be.equal('[callback-url] [entry-point] [issuer]'); + }); + }); + }); + + describe('[LogoutRequest]', () => { + describe('LogoutRequest.generate', () => { + it('should use the custom template to generate the request', () => { + const logoutRequest = LogoutRequest.generate(serviceProviderOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal('[logout-request-template]'); + }); + + it('should include the unique ID on the request', () => { + const customOptions = { + ...serviceProviderOptions, + logoutRequestTemplate: '__newId__', + }; + + const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal(logoutRequest.id); + }); + + it('should include the custom options on the request', () => { + const customOptions = { + ...serviceProviderOptions, + logoutRequestTemplate: '__idpSLORedirectURL__ __issuer__ __identifierFormat__ __nameID__ __sessionIndex__', + }; + + const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal('[idpSLORedirectURL] [issuer] email NameID sessionIndex'); + }); + }); + + describe('LogoutRequest.validate', () => { + it('should extract the idpSession and nameID from the request', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + + parser.validate(simpleLogoutRequest, (err, data) => { + expect(err).to.be.null; + expect(data).to.be.an('object'); + expect(data).to.have.property('idpSession'); + expect(data).to.have.property('nameID'); + // @ts-ignore -- chai already ensured the object exists + expect(data.idpSession).to.be.equal('_d6ad0e25459aaddd0433a81e159aa79e55dc52c280'); + // @ts-ignore -- chai already ensured the object exists + expect(data.nameID).to.be.equal('_ab7e1d9a603473e92148d569d50176bafa60bcb2e9'); + }); + }); + + it('should fail to parse an invalid xml', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + parser.validate(invalidXml, (err, data) => { + expect(err).to.exist; + expect(data).to.not.exist; + }); + }); + + it('should fail to parse a xml without any LogoutRequest tag', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + parser.validate(randomXml, (err, data) => { + expect(err).to.be.equal('No Request Found'); + expect(data).to.not.exist; + }); + }); + + it('should fail to parse a request with no NameId', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + + parser.validate(invalidLogoutRequest, (err, data) => { + expect(err).to.be.an('error').that.has.property('message').equal('SAML Logout Request: No NameID node found'); + expect(data).to.not.exist; + }); + }); + }); + }); + + describe('[LogoutResponse]', () => { + describe('LogoutResponse.generate', () => { + it('should use the custom template to generate the response', () => { + const logoutResponse = LogoutResponse.generate(serviceProviderOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal('[logout-response-template]'); + }); + + it('should include the unique ID on the response', () => { + const customOptions = { + ...serviceProviderOptions, + logoutResponseTemplate: '__newId__', + }; + + const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal(logoutResponse.id); + }); + + it('should include the custom options on the response', () => { + const customOptions = { + ...serviceProviderOptions, + logoutResponseTemplate: '__idpSLORedirectURL__ __issuer__', + }; + + const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal('[idpSLORedirectURL] [issuer]'); + }); + }); + + describe('LogoutResponse.validate', () => { + it('should extract the inResponseTo from the response', () => { + const logoutResponse = simpleLogoutResponse + .replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success'); + const parser = new LogoutResponseParser(serviceProviderOptions); + + parser.validate(logoutResponse, (err, inResponseTo) => { + expect(err).to.be.null; + expect(inResponseTo).to.be.equal('_id-6530db3fcd23dc42a31c'); + }); + }); + + it('should reject a response with a non-success StatusCode', () => { + const logoutResponse = simpleLogoutResponse + .replace('[STATUSCODE]', 'Anything'); + const parser = new LogoutResponseParser(serviceProviderOptions); + + parser.validate(logoutResponse, (err, inResponseTo) => { + expect(err).to.be.equal('Error. Logout not confirmed by IDP'); + expect(inResponseTo).to.be.null; + }); + }); + + it('should fail to parse an invalid xml', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + parser.validate(invalidXml, (err, inResponseTo) => { + expect(err).to.exist; + expect(inResponseTo).to.not.exist; + }); + }); + + it('should fail to parse a xml without any LogoutResponse tag', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + parser.validate(randomXml, (err, inResponseTo) => { + expect(err).to.be.equal('No Response Found'); + expect(inResponseTo).to.not.exist; + }); + }); + + it('should fail to parse a xml without an inResponseTo attribute', () => { + const instant = new Date().toISOString(); + const logoutResponse = simpleLogoutResponse + .replace('[INSTANT]', instant) + .replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success') + .replace('InResponseTo=', 'SomethingElse='); + + const parser = new LogoutResponseParser(serviceProviderOptions); + parser.validate(logoutResponse, (err, inResponseTo) => { + expect(err).to.be.equal('Unexpected Response from IDP'); + expect(inResponseTo).to.not.exist; + }); + }); + + it('should reject a response with no status tag', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + + parser.validate(invalidLogoutResponse, (err, inResponseTo) => { + expect(err).to.be.equal('Error. Logout not confirmed by IDP'); + expect(inResponseTo).to.be.null; + }); + }); + }); + }); + + describe('[Metadata]', () => { + describe('[Metadata.generate]', () => { + it('should generate a simple metadata file when no certificate info is included', () => { + const metadata = ServiceProviderMetadata.generate(serviceProviderOptions); + expect(metadata).to.be.equal(simpleMetadata); + }); + + it('should include additional information when a certificate is provided', () => { + const customOptions = { + ...serviceProviderOptions, + privateCert: '[CERTIFICATE_CONTENT]', + privateKey: '[PRIVATE_KEY]', + }; + + const metadata = ServiceProviderMetadata.generate(customOptions); + expect(metadata).to.be.equal(metadataWithCertificate); + }); + }); + }); + + describe('[Response]', () => { + describe('[Response.validate]', () => { + it('should extract a profile from the response', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(response, (err, profile, loggedOut) => { + expect(err).to.be.null; + expect(profile).to.be.an('object'); + expect(profile).to.have.property('inResponseToId').equal('[INRESPONSETO]'); + expect(profile).to.have.property('issuer').equal('[ISSUER]'); + expect(profile).to.have.property('nameID').equal('[NAMEID]'); + expect(profile).to.have.property('sessionIndex').equal('[SESSIONINDEX]'); + expect(profile).to.have.property('uid').equal('1'); + expect(profile).to.have.property('eduPersonAffiliation').equal('group1'); + expect(profile).to.have.property('email').equal('user1@example.com'); + expect(profile).to.have.property('channels').that.is.an('array').that.is.deep.equal(['channel1', 'pets', 'random']); + expect(loggedOut).to.be.false; + }); + }); + + it('should respect NotOnOrAfter conditions', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const response = samlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', new Date().toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(response, (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(profile).to.be.null; + expect(loggedOut).to.be.false; + }); + }); + + it('should respect NotBefore conditions', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() + 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = samlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(response, (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(profile).to.be.null; + expect(loggedOut).to.be.false; + }); + }); + + + it('should fail to parse an invalid xml', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(invalidXml, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a xml without any Response tag', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(randomXml, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a xml with multiple responses', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(duplicatedSamlResponse, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many SAML responses'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a reponse with no Status tag', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(samlResponseMissingStatus, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing StatusCode'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a reponse with a failed status', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(samlResponseFailedStatus, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Status is: Failed'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a response with multiple assertions', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(samlResponseMultipleAssertions, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many SAML assertions'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a response with no assertions', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(samlResponseMissingAssertion, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing SAML assertion'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a document without signatures if the setting requires at least one', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Either', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(response, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('No valid SAML Signature found'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a document with multiple issuers', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(samlResponseMultipleIssuers, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many Issuers'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should decrypt an encrypted response', () => { + const options = { + ...serviceProviderOptions, + privateCert: privateKeyCert, + privateKey, + }; + + const parser = new ResponseParser(options); + parser.validate(encryptedResponse, (err, profile, loggedOut) => { + // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should validate signatures on an encrypted response', () => { + const options = { + ...serviceProviderOptions, + privateCert: privateKeyCert, + signatureValidationType: 'All', + privateKey, + }; + + const parser = new ResponseParser(options); + parser.validate(encryptedResponse, (err, profile, loggedOut) => { + // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + }); + + describe('[Validate Signatures]', () => { + it('should reject an unsigned assertion if the setting says so', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(response, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject an unsigned response if the setting says so', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(response, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject an assertion signed with an invalid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: certificate, + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = samlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(response, (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should accept an assertion with a valid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidAssertionSignature, (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with a valid response signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidSignatures, (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should reject a document with a valid signature of the wrong type', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidAssertionSignature, (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with both valid signatures', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'All', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidSignatures, (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should reject a document with a single signature when both are expected', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'All', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidAssertionSignature, (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with either valid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Either', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(samlResponseValidAssertionSignature, (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + }); + }); + + describe('[Login]', () => { + describe('UserMapping', () => { + it('should collect all appropriate data from the profile, respecting the fieldMap', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + customField1: 'customField1', + customField2: 'customField2', + customField3: 'customField3', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + globalSettings.roleAttributeName = 'roles'; + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('samlLogin').that.is.an('object'); + expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); + expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[IssuerName]'); + expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SessionIndex]'); + expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[nameID]'); + expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('testing@server.com'); + expect(userObject).to.have.property('fullName').that.is.equal('[AnotherName]'); + expect(userObject).to.have.property('username').that.is.equal('[AnotherUserName]'); + expect(userObject).to.have.property('roles').that.is.an('array').with.members(['user', 'ruler', 'admin', 'king', 'president', 'governor', 'mayor']); + expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny', 'random', 'babies']); + + const map = new Map(); + map.set('customField1', 'value1'); + map.set('customField2', 'value2'); + map.set('customField3', 'value3'); + + expect(userObject).to.have.property('customFields').that.is.a('Map').and.is.deep.equal(map); + }); + + // Channels support both a comma separated single value and an array of values + it('should support `channels` attribute with multiple values', () => { + const channelsProfile = { + ...profile, + channels: [ + 'pets', + 'pics', + 'funny', + ], + }; + + const userObject = SAMLUtils.mapProfileToUserObject(channelsProfile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny']); + }); + + it('should reject an userDataFieldMap without an email field', () => { + const { globalSettings } = SAMLUtils; + globalSettings.userDataFieldMap = JSON.stringify({}); + SAMLUtils.updateGlobalSettings(globalSettings); + + expect(() => { + SAMLUtils.mapProfileToUserObject(profile); + }).to.throw('SAML Profile did not contain an email address'); + }); + + it('should fail to map a profile that is missing the email field', () => { + const { globalSettings } = SAMLUtils; + const fieldMap = { + inexistentField: 'email', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + SAMLUtils.updateGlobalSettings(globalSettings); + + expect(() => { + SAMLUtils.mapProfileToUserObject(profile); + }).to.throw('SAML Profile did not contain an email address'); + }); + + it('should load data from the default fields when the field map is lacking', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + email: 'singleEmail', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName]'); + expect(userObject).to.have.property('username').that.is.equal('[username]'); + }); + + it('should assign the default role when the roleAttributeName is missing', () => { + const { globalSettings } = SAMLUtils; + globalSettings.roleAttributeName = ''; + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object').that.have.property('roles').that.is.an('array').with.members(['user']); + }); + + it('should assign the default role when the value of the role attribute is missing', () => { + const { globalSettings } = SAMLUtils; + globalSettings.roleAttributeName = 'inexistentField'; + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object').that.have.property('roles').that.is.an('array').with.members(['user']); + }); + + it('should run custom regexes when one is used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'singleEmail', + regex: '(.*)@.+$', + }, + email: 'singleEmail', + name: 'anotherName', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('username').that.is.equal('testing'); + }); + + it('should run custom templates when one is used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'anotherName', + template: 'user-__anotherName__', + }, + email: 'singleEmail', + name: { + fieldNames: [ + 'anotherName', + 'displayName', + ], + template: '__displayName__ (__anotherName__)', + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('username').that.is.equal('user-[AnotherName]'); + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] ([AnotherName])'); + }); + + it('should combine regexes and templates when both are used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'anotherName', + template: 'user-__anotherName__45@7', + regex: 'user-(.*)@', + }, + email: 'singleEmail', + name: { + fieldNames: [ + 'anotherName', + 'displayName', + ], + regex: '\\[(.*)\\]', + template: '__displayName__ (__regex__)', + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + // should run the template first, then the regex + expect(userObject).to.have.property('username').that.is.equal('[AnotherName]45'); + // for this one, should run the regex first, then the template + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] (AnotherName)'); + }); + + it('should collect the values of every attribute on the field map', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + others: { + fieldNames: [ + 'issuer', + 'sessionIndex', + 'nameID', + 'displayName', + 'username', + 'roles', + 'otherRoles', + 'language', + 'channels', + 'customField1', + ], + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('attributeList').that.is.a('Map').that.have.keys([ + 'anotherUsername', + 'singleEmail', + 'anotherName', + 'issuer', + 'sessionIndex', + 'nameID', + 'displayName', + 'username', + 'roles', + 'otherRoles', + 'language', + 'channels', + 'customField1', + ]); + + // Workaround because chai doesn't handle Maps very well + for (const [key, value] of userObject.attributeList) { + // @ts-ignore + expect(value).to.be.equal(profile[key]); + } + }); + + it('should use the immutable property as default identifier', () => { + const { globalSettings } = SAMLUtils; + + globalSettings.immutableProperty = 'EMail'; + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('email'); + + globalSettings.immutableProperty = 'Username'; + SAMLUtils.updateGlobalSettings(globalSettings); + + const newUserObject = SAMLUtils.mapProfileToUserObject(profile); + expect(newUserObject).to.be.an('object'); + expect(newUserObject).to.have.property('identifier').that.has.property('type').that.is.equal('username'); + }); + + it('should collect the identifier from the fieldset', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + __identifier__: 'customField3', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('custom'); + expect(userObject).to.have.property('identifier').that.has.property('attribute').that.is.equal('customField3'); + }); + }); + }); + + describe('Response Mapping', () => { + it('should extract a mapped user from the response', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(response, (err, profile, loggedOut) => { + expect(profile).to.be.an('object'); + expect(err).to.be.null; + expect(loggedOut).to.be.false; + + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'uid', + template: 'user-__uid__', + }, + email: 'email', + epa: 'eduPersonAffiliation', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + globalSettings.roleAttributeName = 'roles'; + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + + // @ts-ignore + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('samlLogin').that.is.an('object'); + expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); + expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[ISSUER]'); + expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SESSIONINDEX]'); + expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[NAMEID]'); + expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('user1@example.com'); + expect(userObject).to.have.property('username').that.is.equal('user-1'); + + const map = new Map(); + map.set('epa', 'group1'); + + expect(userObject).to.have.property('customFields').that.is.a('Map').and.is.deep.equal(map); + }); + }); + }); +}); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 6594098a0c35..6400aac6be3f 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -42,6 +42,7 @@ export class Users extends Base { this.tryEnsureIndex({ 'visitorEmails.address': 1 }); this.tryEnsureIndex({ federation: 1 }, { sparse: true }); this.tryEnsureIndex({ isRemote: 1 }, { sparse: true }); + this.tryEnsureIndex({ 'services.saml.inResponseTo': 1 }); } getLoginTokensByUserId(userId) { @@ -865,6 +866,21 @@ export class Users extends Base { }); } + findBySAMLNameIdOrIdpSession(nameID, idpSession) { + return this.find({ + $or: [ + { 'services.saml.nameID': nameID }, + { 'services.saml.idpSession': idpSession }, + ], + }); + } + + findBySAMLInResponseTo(inResponseTo) { + return this.find({ + 'services.saml.inResponseTo': inResponseTo, + }); + } + // UPDATE addImportIds(_id, importIds) { importIds = [].concat(importIds); @@ -1313,6 +1329,16 @@ export class Users extends Base { return this.update({ _id }, update); } + removeSamlServiceSession(_id) { + const update = { + $unset: { + 'services.saml.idpSession': '', + }, + }; + + return this.update({ _id }, update); + } + updateDefaultStatus(_id, statusDefault) { return this.update({ _id, @@ -1324,6 +1350,16 @@ export class Users extends Base { }); } + setSamlInResponseTo(_id, inResponseTo) { + this.update({ + _id, + }, { + $set: { + 'services.saml.inResponseTo': inResponseTo, + }, + }); + } + // INSERT create(data) { const user = { diff --git a/app/settings/server/functions/settings.ts b/app/settings/server/functions/settings.ts index 3f466ccd4596..92b032880fd8 100644 --- a/app/settings/server/functions/settings.ts +++ b/app/settings/server/functions/settings.ts @@ -76,6 +76,13 @@ export interface ISettingAddOptions { meteorSettingsValue?: SettingValue; value?: SettingValue; ts?: Date; + multiline?: boolean; + values?: Array; +} + +export interface ISettingSelectOption { + key: string; + i18nLabel: string; } export interface ISettingAddGroupOptions { @@ -213,7 +220,7 @@ class Settings extends SettingsBase { /* * Add a setting group */ - addGroup(_id: string, cb: addGroupCallback): boolean; + addGroup(_id: string, cb?: addGroupCallback): boolean; // eslint-disable-next-line no-dupe-class-members addGroup(_id: string, options: ISettingAddGroupOptions | addGroupCallback = {}, cb?: addGroupCallback): boolean { diff --git a/definition/IIncomingMessage.ts b/definition/IIncomingMessage.ts new file mode 100644 index 000000000000..bb6423e50ee0 --- /dev/null +++ b/definition/IIncomingMessage.ts @@ -0,0 +1,6 @@ +import { IncomingMessage } from 'http'; + +export interface IIncomingMessage extends IncomingMessage { + query: Record; + body: Record; +} diff --git a/definition/IUser.ts b/definition/IUser.ts index 3e7ff4caf239..fb1628d3b674 100644 --- a/definition/IUser.ts +++ b/definition/IUser.ts @@ -52,6 +52,13 @@ export interface IUserServices { changedAt: Date; }; emailCode: IUserEmailCode[]; + saml?: { + inResponseTo?: string; + provider?: string; + idp?: string; + idpSession?: string; + nameID?: string; + }; } export interface IUserEmail { diff --git a/definition/xml-encryption.ts b/definition/xml-encryption.ts new file mode 100644 index 000000000000..df9664d74b17 --- /dev/null +++ b/definition/xml-encryption.ts @@ -0,0 +1,23 @@ +declare module 'xml-encryption' { + export interface IDecryptOptions { + disallowDecryptionWithInsecureAlgorithm?: boolean; + warnInsecureAlgorithm?: boolean; + key: string; + } + + export function decrypt(xml: string | Element | Document, options: IDecryptOptions, callback: (err: Error, result: any) => void): string; + export function decryptKeyInfo(doc: string | Element |Document, options: IDecryptOptions): string; + + export interface IEncryptOptions { + rsa_pub: string; + pem: Buffer | string; + disallowEncryptionWithInsecureAlgorithm: boolean; + keyEncryptionAlgorithm: string; + encryptionAlgorithm: string; + input_encoding?: string; + warnInsecureAlgorithm: boolean; + } + + export function encrypt(content: string, options: IEncryptOptions, callback: (err: Error, result: any) => void): string; + export function encryptKeyInfo(symmetricKey: string, options: IEncryptOptions, callback: (err: Error, result: any) => void): string; +} diff --git a/package-lock.json b/package-lock.json index 6558791efd64..773865d56514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5941,6 +5941,11 @@ "@types/range-parser": "*" } }, + "@types/fibers": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/fibers/-/fibers-3.1.0.tgz", + "integrity": "sha512-1o3I9xtk2PZFxwaLCC6gTaBfBZ5rvw/DSZZPK89fwuwO6LNrzSbC6rEs1xI0bQ3fCRWmO+uNJQQeD2J56oTMDg==" + }, "@types/form-data": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", @@ -6219,6 +6224,14 @@ "integrity": "sha512-CjHWEMECc2/UxOZh0kpiz3lEyX2Px3rQS9HzD20lxMvx571ivOBQKeLnqEjxUY0BMgp6WJWo/pQLRBwMW5v4WQ==", "dev": true }, + "@types/underscore.string": { + "version": "0.0.38", + "resolved": "https://registry.npmjs.org/@types/underscore.string/-/underscore.string-0.0.38.tgz", + "integrity": "sha512-QPMttDInBYkulH/3nON0KnYpEd/RlyE5kUrhuts5d76B/stpjXpDticq+iTluoAsVnVXuGECFhPtuX+aDJdx+A==", + "requires": { + "@types/underscore": "*" + } + }, "@types/use-subscription": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/use-subscription/-/use-subscription-1.0.0.tgz", @@ -6280,6 +6293,20 @@ "@types/node": "*" } }, + "@types/xml-crypto": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@types/xml-crypto/-/xml-crypto-1.4.1.tgz", + "integrity": "sha512-w7pI4Gq1buWinzLsDopd4du0sUzlSltT7QfHqknLu+hVuFWTXLzJnAOmYKuD20ncx3XCkYNwSRr2sKeYiwCvZw==", + "requires": { + "@types/node": "*", + "xpath": "0.0.27" + } + }, + "@types/xmldom": { + "version": "0.1.29", + "resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.29.tgz", + "integrity": "sha1-xEKLDKhtO4gUdXJv2UmAs4onw4E=" + }, "@typescript-eslint/eslint-plugin": { "version": "2.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.18.0.tgz", @@ -31558,11 +31585,6 @@ } } }, - "xmlbuilder": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", - "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==" - }, "xmldom": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", diff --git a/package.json b/package.json index 71c536e8d446..96c9b0ef28cc 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,10 @@ "@rocket.chat/icons": "^0.6.3-dev.23", "@rocket.chat/ui-kit": "^0.6.3-dev.23", "@slack/client": "^4.8.0", + "@types/fibers": "^3.1.0", + "@types/underscore.string": "0.0.38", + "@types/xml-crypto": "^1.4.1", + "@types/xmldom": "^0.1.29", "@types/use-subscription": "^1.0.0", "adm-zip": "RocketChat/adm-zip", "apn": "2.2.0", @@ -240,7 +244,6 @@ "xml-crypto": "^1.0.2", "xml-encryption": "0.11.2", "xml2js": "0.4.19", - "xmlbuilder": "^10.1.1", "xmldom": "^0.1.27", "yaqrcode": "^0.2.1" }, diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index fc5841841f26..905e7027bbb7 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2989,9 +2989,13 @@ "Same_As_Token_Sent_Via": "Same as \"Token Sent Via\"", "Same_Style_For_Mentions": "Same style for mentions", "SAML": "SAML", + "SAML_AuthnContext_Template": "AuthnContext Template", + "SAML_AuthnContext_Template_Description": "You can use any variable from the AuthnRequest Template here.\n\n To add additional authn contexts, duplicate the __AuthnContextClassRef__ tag and replace the __\\_\\_authnContext\\_\\___ variable with the new context.", + "SAML_AuthnRequest_Template": "AuthnRequest Template", + "SAML_AuthnRequest_Template_Description": "The following variables are available:\n- **\\_\\_newId\\_\\_**: Randomly generated id string\n- **\\_\\_instant\\_\\_**: Current timestamp\n- **\\_\\_callbackUrl\\_\\_**: The Rocket.Chat callback URL.\n- **\\_\\_entryPoint\\_\\_**: The value of the __Custom Entry Point__ setting.\n- **\\_\\_issuer\\_\\_**: The value of the __Custom Issuer__ setting.\n- **\\_\\_identifierFormatTag\\_\\_**: The contents of the __NameID Policy Template__ if a valid __Identifier Format__ is configured.\n- **\\_\\_identifierFormat\\_\\_**: The value of the __Identifier Format__ setting.\n- **\\_\\_authnContextTag\\_\\_**: The contents of the __AuthnContext Template__ if a valid __Custom Authn Context__ is configured.\n- **\\_\\_authnContextComparison\\_\\_**: The value of the __Authn Context Comparison__ setting.\n- **\\_\\_authnContext\\_\\_**: The value of the __Custom Authn Context__ setting.", "SAML_Custom_Authn_Context": "Custom Authn Context", "SAML_Custom_Authn_Context_Comparison": "Authn Context Comparison", - "SAML_Custom_Authn_Context_description": "Leave this empty to omit the authn context from the request.", + "SAML_Custom_Authn_Context_description": "Leave this empty to omit the authn context from the request.\n\n To add multiple authn contexts, add the additional ones directly to the __AuthnContext Template__ setting.", "SAML_Custom_Cert": "Custom Certificate", "SAML_Custom_Debug": "Enable Debug", "SAML_Custom_Entry_point": "Custom Entry Point", @@ -3011,9 +3015,9 @@ "SAML_Custom_signature_validation_either": "Validate Either Signature", "SAML_Custom_signature_validation_response": "Validate Response Signature", "SAML_Custom_signature_validation_type": "Signature Validation Type", - "SAML_Custom_signature_validation_type_description": "This setting will be ignored if not Custom Certificate is provided.", + "SAML_Custom_signature_validation_type_description": "This setting will be ignored if no Custom Certificate is provided.", "SAML_Custom_user_data_fieldmap": "User Data Field Map", - "SAML_Custom_user_data_fieldmap_description": "Configure how user account fields (like email) are populated from a record in SAML (once found).
As an example, `{\"cn\":\"name\", \"mail\":\"email\"}` will choose a person's human readable name from the cn attribute, and their email from the mail attribute.
Available fields in Rocket.Chat: `name`, `email` and `username`, everything else will be saved as `customFields`.
You can also use a regex to get the field value, like this: `{\"NameID\": { \"field\": \"username\", \"regex\": \"(.*)@.+$\"}, \"email\": \"email\"}`", + "SAML_Custom_user_data_fieldmap_description": "Configure how user account fields (like email) are populated from a record in SAML (once found). \nAs an example, `{\"name\":\"cn\", \"email\":\"mail\"}` will choose a person's human readable name from the cn attribute, and their email from the mail attribute.\nAvailable fields in Rocket.Chat: `name`, `email` and `username`, everything else will be saved as `customFields`.\nAssign the name of an immutable attribute to the '__identifier__' key to use it as user identifier.\nYou can also use regexes and templates. Templates will be processed first except when they reference the result of the regex.\n```{\n \"email\": \"mail\",\n \"username\": {\n \"fieldName\": \"mail\",\n \"regex\": \"(.*)@.+$\",\n \"template\": \"user-__regex__\"\n },\n \"name\": {\n \"fieldNames\": [\n \"firstName\",\n \"lastName\"\n ],\n \"template\": \"__firstName__ __lastName__\"\n },\n \"__identifier__\": \"uid\"\n}```\n", "SAML_Custom_Username_Field": "Username field name", "SAML_Custom_Username_Normalize": "Normalize username", "SAML_Custom_Username_Normalize_None": "No normalization", @@ -3024,10 +3028,28 @@ "SAML_Custom_Public_Cert": "Public Cert Contents", "SAML_Default_User_Role": "Default User Role", "SAML_Default_User_Role_Description": "You can specify multiple roles, separating them with commas.", + "SAML_Identifier_Format": "Identifier Format", + "SAML_Identifier_Format_Description": "Leave this empty to omit the NameID Policy from the request.", + "SAML_LogoutResponse_Template": "Logout Response Template", + "SAML_LogoutRequest_Template": "Logout Request Template", + "SAML_LogoutRequest_Template_Description": "The following variables are available:\n- **\\_\\_newId\\_\\_**: Randomly generated id string\n- **\\_\\_instant\\_\\_**: Current timestamp\n- **\\_\\_idpSLORedirectURL\\_\\_**: The IDP Single LogOut URL to redirect to.\n- **\\_\\_issuer\\_\\_**: The value of the __Custom Issuer__ setting.\n- **\\_\\_identifierFormat\\_\\_**: The value of the __Identifier Format__ setting.\n- **\\_\\_nameID\\_\\_**: The NameID received from the IdP when the user logged in.\n- **\\_\\_sessionIndex\\_\\_**: The sessionIndex received from the IdP when the user logged in.", + "SAML_LogoutResponse_Template_Description": "The following variables are available:\n- **\\_\\_newId\\_\\_**: Randomly generated id string\n- **\\_\\_inResponseToId\\_\\_**: The ID of the Logout Request received from the IdP\n- **\\_\\_instant\\_\\_**: Current timestamp\n- **\\_\\_idpSLORedirectURL\\_\\_**: The IDP Single LogOut URL to redirect to.\n- **\\_\\_issuer\\_\\_**: The value of the __Custom Issuer__ setting.\n- **\\_\\_identifierFormat\\_\\_**: The value of the __Identifier Format__ setting.\n- **\\_\\_nameID\\_\\_**: The NameID received from the IdP Logout Request.\n- **\\_\\_sessionIndex\\_\\_**: The sessionIndex received from the IdP Logout Request.", + "SAML_MetadataCertificate_Template": "Metadata Certificate Template", + "SAML_Metadata_Template": "Metadata Template", + "SAML_Metadata_Template_Description": "The following variables are available:\n- **\\_\\_sloLocation\\_\\_**: The Rocket.Chat Single LogOut URL.\n- **\\_\\_issuer\\_\\_**: The value of the __Custom Issuer__ setting.\n- **\\_\\_identifierFormat\\_\\_**: The value of the __Identifier Format__ setting.\n- **\\_\\_certificateTag\\_\\_**: If a private certificate is configured, this will include the __Metadata Certificate Template__, otherwise it will be ignored.\n- **\\_\\_callbackUrl\\_\\_**: The Rocket.Chat callback URL.", + "SAML_Metadata_Certificate_Template_Description": "The following variables are available:\n- **\\_\\_certificate\\_\\_**: The private certificate for assertion encryption.", + "SAML_NameIdPolicy_Template": "NameID Policy Template", + "SAML_NameIdPolicy_Template_Description": "You can use any variable from the Authorize Request Template here.", "SAML_Role_Attribute_Name": "Role Attribute Name", "SAML_Role_Attribute_Name_Description": "If this attribute is found on the SAML response, it's values will be used as role names for new users.", "SAML_Role_Attribute_Sync": "Sync User Roles", "SAML_Role_Attribute_Sync_Description": "Sync SAML user roles on login (overwrites local user roles).", + "SAML_Section_1_User_Interface": "User Interface", + "SAML_Section_2_Certificate": "Certificate", + "SAML_Section_3_Behavior": "Behavior", + "SAML_Section_4_Roles": "Roles", + "SAML_Section_5_Mapping": "Mapping", + "SAML_Section_6_Advanced": "Advanced", "SAML_Allowed_Clock_Drift": "Allowed clock drift from Identity Provider", "SAML_Allowed_Clock_Drift_Description": "The clock of the Identity Provider may drift slightly ahead of your system clocks. You can allow for a small amount of clock drift. Its value must be given in a number of milliseconds (ms). The value given is added to the current time at which the response is validated.", "Saturday": "Saturday", diff --git a/server/main.d.ts b/server/main.d.ts index 2d8a01c20b04..33f7c642a1f6 100644 --- a/server/main.d.ts +++ b/server/main.d.ts @@ -12,6 +12,10 @@ declare module 'meteor/accounts-base' { function _bcryptRounds(): number; function _getLoginToken(connectionId: string): string | undefined; + + function insertUserDoc(options: Record, user: Record): string; + + function _generateStampedLoginToken(): {token: string; when: Date}; } } @@ -42,6 +46,12 @@ declare module 'meteor/ddp-common' { } } +declare module 'meteor/routepolicy' { + export class RoutePolicy { + static declare(urlPrefix: string, type: string): void; + } +} + declare module 'meteor/rocketchat:tap-i18n' { namespace TAPi18n { function __(s: string, options: { lng: string }): string; diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 9009fcc9bb00..1bc64d304e1b 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -190,4 +190,5 @@ import './v190'; import './v191'; import './v192'; import './v193'; +import './v194'; import './xrun'; diff --git a/server/startup/migrations/v194.js b/server/startup/migrations/v194.js new file mode 100644 index 000000000000..4875d6b2a2e3 --- /dev/null +++ b/server/startup/migrations/v194.js @@ -0,0 +1,112 @@ +import { + Settings, + Users, +} from '../../../app/models/server'; +import { Migrations } from '../../../app/migrations/server'; + +function updateFieldMap() { + const _id = 'SAML_Custom_Default_user_data_fieldmap'; + const setting = Settings.findOne({ _id }); + if (!setting.value) { + return; + } + + // Check if there's any user with an 'eppn' attribute. This is a custom identifier that was hardcoded on the old version + // If there's any user with the eppn attribute stored on mongo, we will include it on the new json so that it'll continue to be used. + const usedEppn = Boolean(Users.findOne({ eppn: { $exists: true } }, { fields: { eppn: 1 } })); + + // if it's using the old default value, simply switch to the new default + if (setting.value === '{"username":"username", "email":"email", "cn": "name"}') { + // include de eppn identifier if it was used + const value = `{"username":"username", "email":"email", "name": "cn"${ usedEppn ? ', "__identifier__": "eppn"' : '' }}`; + Settings.update({ _id }, { + $set: { + value, + }, + }); + return; + } + + let oldMap; + + try { + oldMap = JSON.parse(setting.value); + } catch (e) { + // If the current value wasn't even a proper JSON, we don't need to worry about changing it. + return; + } + + const newMap = {}; + for (const key in oldMap) { + if (!oldMap.hasOwnProperty(key)) { + continue; + } + + const value = oldMap[key]; + // A simple idpField->spField is converted to spField->idpField + if (typeof value === 'string') { + newMap[value] = key; + } else if (typeof value === 'object') { + const { field, regex } = value; + + // If it didn't have a 'field' attribute, it was ignored by SAML, but let's keep it on the JSON anyway + if (!field) { + newMap[`_${ key }`] = { + attribute: key, + regex, + }; + continue; + } + + // { idpField: { field: spField, regex} } becomes { spField: { attribute: idpField, regex } } + newMap[field] = { + attribute: key, + regex, + }; + } + } + + // eppn was a hardcoded custom identifier, we need to add it to the fieldmap to ensure any existing instances won't break + if (usedEppn) { + newMap.__identifier__ = 'eppn'; + } + + const value = JSON.stringify(newMap); + + Settings.update({ _id }, { + $set: { + value, + }, + }); +} + +function updateIdentifierLocation() { + Users.update( + { eppn: { $exists: 1 } }, + { $rename: { eppn: 'services.saml.eppn' } }, + { multi: true }, + ); +} + +function setOldLogoutResponseTemplate() { + // For existing users, use a template compatible with the old SAML implementation instead of the default + Settings.upsert({ + _id: 'SAML_Custom_Default_LogoutResponse_template', + }, { + $set: { + value: ` + __issuer__ + +`, + }, + }); +} + +Migrations.add({ + version: 194, + up() { + updateFieldMap(); + updateIdentifierLocation(); + setOldLogoutResponseTemplate(); + }, +}); diff --git a/typings.d.ts b/typings.d.ts index 905575c445af..c653f645ee80 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -1,2 +1,4 @@ declare module 'meteor/rocketchat:tap-i18n'; declare module 'meteor/ddp-common'; +declare module 'meteor/routepolicy'; +declare module 'xml-encryption';