snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,615 @@
import type { ServerResponse } from 'node:http';
import type { IUser, IIncomingMessage, IPersonalAccessToken, IRole } from '@rocket.chat/core-typings';
import { CredentialTokens, Rooms, Users, Roles, SamlUsedAssertions } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { escapeRegExp, escapeHTML } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { SAMLServiceProvider } from './ServiceProvider';
import { SAMLUtils } from './Utils';
import { getSAMLEnvelope } from './getSAMLEnvelope';
import { i18n } from '../../../../app/utils/lib/i18n';
import { ensureArray } from '../../../../lib/utils/arrayUtils';
import { settings } from '../../../settings';
import { SystemLogger } from '../../logger/system';
import { addUserToRoom } from '../../rooms/addUserToRoom';
import { createRoom } from '../../rooms/createRoom';
import { generateUsernameSuggestion } from '../../users/getUsernameSuggestion';
import { saveUserIdentity } from '../../users/saveUserIdentity';
import type { ISAMLAction } from '../definition/ISAMLAction';
import type { ISAMLUser } from '../definition/ISAMLUser';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
const showErrorMessage = function (res: ServerResponse, err: string): void {
res.writeHead(200, {
'Content-Type': 'text/html',
});
const content = `<html><body><h2>Sorry, an annoying error occured</h2><div>${escapeHTML(err)}</div></body></html>`;
res.end(content, 'utf-8');
};
const convertRoleNamesToIds = async (roleNamesOrIds: string[]): Promise<IRole['_id'][]> => {
const normalizedRoleNamesOrIds = roleNamesOrIds.map((role) => role.trim()).filter((role) => role.length > 0);
if (!normalizedRoleNamesOrIds.length) {
throw new Error(`No valid role names or ids provided for conversion: ${roleNamesOrIds.join(', ')}`);
}
const roles = (await Roles.findInIdsOrNames(normalizedRoleNamesOrIds).toArray()).map((role) => role._id);
if (roles.length !== normalizedRoleNamesOrIds.length) {
SystemLogger.warn({
msg: 'Failed to convert some role names to ids',
roles: normalizedRoleNamesOrIds,
});
}
if (!roles.length) {
throw new Error(`We should have at least one existing role to create the user: ${normalizedRoleNamesOrIds.join(', ')}`);
}
return roles;
};
export class SAML {
public static async processRequest(
req: IIncomingMessage,
res: ServerResponse,
service: IServiceProviderOptions,
samlObject: ISAMLAction,
): Promise<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, service);
case 'authorize':
return this.processAuthorizeAction(req, res, service, samlObject);
case 'validate':
return this.processValidateAction(req, res, service, samlObject);
default:
throw new Error(`Unexpected SAML action ${samlObject.actionName}`);
}
}
public static async hasCredential(credentialToken: string): Promise<boolean> {
return (await CredentialTokens.findOneNotExpiredById(credentialToken)) != null;
}
public static async retrieveCredential(credentialToken: string): Promise<Record<string, any> | undefined> {
// The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check.
const data = await CredentialTokens.findOneNotExpiredById(credentialToken);
if (data) {
return data.userInfo;
}
}
public static async storeCredential(credentialToken: string, loginResult: { profile: Record<string, any> }): Promise<void> {
await CredentialTokens.create(credentialToken, loginResult);
}
public static async insertOrUpdateSAMLUser(userObject: ISAMLUser): Promise<{ userId: string; token: string }> {
const { generateUsername, immutableProperty, nameOverwrite, mailOverwrite, channelsAttributeUpdate, defaultUserRole } =
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<string, any> = {};
query[`services.saml.${customIdentifierAttributeName}`] = userObject.attributeList.get(customIdentifierAttributeName);
user = await 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 = await SAML.findUser(userObject.username, emailRegex);
}
const emails = userObject.emailList.map((email) => ({
address: email,
verified: settings.get('Accounts_Verify_Email_For_External_Accounts'),
}));
let { username } = userObject;
const { fullName } = userObject;
const active = !settings.get('Accounts_ManuallyApproveNewUsers');
if (!user) {
let roleNamesOrIds: string[] = [];
if (userObject.roles && userObject.roles.length > 0) {
roleNamesOrIds = userObject.roles;
} else if (defaultUserRole) {
roleNamesOrIds = ensureArray<string>(defaultUserRole.split(','));
}
const roles = roleNamesOrIds.length > 0 ? await convertRoleNamesToIds(roleNamesOrIds) : [];
const newUser: Record<string, any> = {
name: fullName,
active,
globalRoles: roles,
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 = await generateUsernameSuggestion(newUser);
}
if (username) {
newUser.username = username;
newUser.name = newUser.name || SAML.guessNameFromUsername(username);
}
if (userObject.language) {
if (i18n.languages?.includes(userObject.language)) {
newUser.language = userObject.language;
}
}
// only set skipAuthServiceDefaultRoles if SAML is providing its own roles
// otherwise, leave it as false to fallback to generic auth service default roles
// from Accounts_Registration_AuthenticationServices_Default_Roles
const skipAuthServiceDefaultRoles = roleNamesOrIds.length > 0;
const userId = await Accounts.insertUserDoc({ skipAuthServiceDefaultRoles, skipNewUserRolesSetting: true }, newUser);
user = await Users.findOneById(userId);
if (user && userObject.channels && channelsAttributeUpdate !== true) {
await SAML.subscribeToSAMLChannels(userObject.channels, user);
}
}
if (!user) {
throw new Error('Failed to create user');
}
// creating the token and adding to the user
const stampedToken = Accounts._generateStampedLoginToken();
await Users.addPersonalAccessTokenToUser({
userId: user._id,
loginTokenObject: stampedToken as unknown as IPersonalAccessToken,
});
const updateData: Record<string, any> = {
'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);
}
// Overwrite mail if needed
if (mailOverwrite === true && (customIdentifierMatch === true || immutableProperty !== 'EMail')) {
updateData.emails = emails;
}
// When updating an user, we only update the roles if we received them from the mapping
if (userObject.roles?.length) {
const roles = await convertRoleNamesToIds(userObject.roles);
updateData.roles = roles;
}
if (userObject.channels && channelsAttributeUpdate === true) {
await SAML.subscribeToSAMLChannels(userObject.channels, user);
}
await Users.updateOne(
{
_id: user._id,
},
{
$set: updateData,
},
);
if ((username && username !== user.username) || (nameOverwrite && fullName && fullName !== user.name)) {
await saveUserIdentity({ _id: user._id, name: nameOverwrite ? fullName || undefined : user.name, 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: any) {
showErrorMessage(res, err);
}
}
private static async processLogoutAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise<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 async _logoutRemoveTokens(userId: string): Promise<void> {
SAMLUtils.log({ msg: 'Found user', userId });
await Users.unsetLoginTokens(userId);
await Users.removeSamlServiceSession(userId);
}
private static async processLogoutRequest(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise<void> {
const errorHandler = (err: unknown) => {
SystemLogger.error({ err });
throw new Meteor.Error('Unable to Validate Logout Request');
};
const envelope = await getSAMLEnvelope(req, 'SAMLRequest', 'HTTP-Redirect').catch(errorHandler);
if (!envelope) {
errorHandler('No envelope found for SAML Logout Request');
return;
}
const serviceProvider = new SAMLServiceProvider(service);
await serviceProvider.validateLogoutRequest(envelope, async (err, result) => {
if (err) {
errorHandler(err);
}
if (!result?.nameID || !result?.idpSession) {
throw new Meteor.Error('Unable to process Logout Request: missing request data.');
}
let timeoutHandler: NodeJS.Timeout | undefined = undefined;
const redirect = (url?: string): void => {
if (!timeoutHandler) {
// If the handler is null, then we already ended the response;
return;
}
clearTimeout(timeoutHandler);
timeoutHandler = undefined;
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);
try {
// limit 2: only need to know whether the match is missing, unique, or ambiguous
const loggedOutUsers = await Users.findBySAMLNameIdOrIdpSession(result.nameID, result.idpSession, {
projection: { _id: 1 },
limit: 2,
}).toArray();
if (loggedOutUsers.length > 1) {
throw new Meteor.Error('Found multiple users matching SAML session');
}
if (loggedOutUsers.length === 0) {
throw new Meteor.Error('Invalid logout request: no user associated with session.');
}
await this._logoutRemoveTokens(loggedOutUsers[0]._id);
const { response } = serviceProvider.generateLogoutResponse({
nameID: result.nameID || '',
sessionIndex: result.idpSession || '',
inResponseToId: result.id || '',
});
serviceProvider.logoutResponseToUrl(response, envelope.relayState, (err, url) => {
if (err) {
SystemLogger.error({ err });
return redirect();
}
redirect(url);
});
} catch (err: any) {
SystemLogger.error({ err });
redirect();
}
});
}
private static async processLogoutResponse(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise<void> {
const envelope = await getSAMLEnvelope(req, 'SAMLResponse', 'HTTP-Redirect');
if (!envelope) {
SAMLUtils.error({ msg: 'Invalid LogoutResponse received: missing SAMLResponse parameter.', query: req.query });
throw new Error('Invalid LogoutResponse received.');
}
const serviceProvider = new SAMLServiceProvider(service);
await serviceProvider.validateLogoutResponse(envelope, async (err, inResponseTo) => {
if (err) {
throw new Error('Logout Response Validation failed');
}
if (!inResponseTo) {
throw new Meteor.Error('Invalid logout request: no inResponseTo value.');
}
const logOutUser = async (inResponseTo: string): Promise<void> => {
SAMLUtils.log({ msg: 'Processing logout for inResponseTo', inResponseTo });
// limit 2: only need to know whether the match is missing, unique, or ambiguous
const loggedOutUsers = await Users.findBySAMLInResponseTo(inResponseTo, { projection: { _id: 1 }, limit: 2 }).toArray();
if (loggedOutUsers.length > 1) {
throw new Meteor.Error('Found multiple users matching SAML inResponseTo fields');
}
if (loggedOutUsers.length === 0) {
throw new Meteor.Error('Invalid logout request: no user associated with inResponseTo.');
}
await this._logoutRemoveTokens(loggedOutUsers[0]._id);
};
try {
await logOutUser(inResponseTo);
} finally {
res.writeHead(302, {
Location: Meteor.absoluteUrl(),
});
res.end();
}
});
}
private static processSLORedirectAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void {
const { idpSLORedirectURL } = service;
const userRedirect = req.query.redirect as string;
if (!idpSLORedirectURL) {
res.writeHead(500);
res.end('SLO redirect not configured');
return;
}
if (!userRedirect || typeof userRedirect !== 'string') {
res.writeHead(400);
res.end('Missing redirect parameter');
return;
}
let configuredURL: URL;
let requestURL: URL;
try {
configuredURL = new URL(idpSLORedirectURL);
requestURL = new URL(userRedirect);
} catch {
res.writeHead(400);
res.end('Invalid URL format');
return;
}
if (configuredURL.origin !== requestURL.origin) {
res.writeHead(403);
res.end('Unauthorized redirect origin');
return;
}
const normalizePath = (p: string): string => p.replace(/\/+$/, '') || '/';
if (normalizePath(configuredURL.pathname) !== normalizePath(requestURL.pathname)) {
res.writeHead(403);
res.end('Unauthorized redirect path');
return;
}
res.writeHead(302, {
Location: requestURL.toString(),
});
res.end();
}
private static async processAuthorizeAction(
req: IIncomingMessage,
res: ServerResponse,
service: IServiceProviderOptions,
samlObject: ISAMLAction,
): Promise<void> {
const serviceProvider = new SAMLServiceProvider(service);
let url: string | undefined;
const requestedLoginClient = settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow') ? req.query.loginClient : undefined;
const loginClient = SAMLUtils.isSupportedLoginClient(requestedLoginClient) ? requestedLoginClient : undefined;
try {
url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken, loginClient);
} catch (err: any) {
SAMLUtils.error({ err, msg: 'Unable to generate authorize url' });
url = Meteor.absoluteUrl();
}
res.writeHead(302, {
Location: url,
});
res.end();
}
private static async processValidateAction(
req: IIncomingMessage,
res: ServerResponse,
service: IServiceProviderOptions,
_samlObject: ISAMLAction,
): Promise<void> {
const redirect = (url?: string) => {
res.writeHead(302, {
Location: url ?? Meteor.absoluteUrl(),
});
res.end();
};
const envelope = await getSAMLEnvelope(req, 'SAMLResponse', 'HTTP-POST').catch((err) => SAMLUtils.error(err));
if (!envelope) {
SAMLUtils.error({ msg: 'No envelope found for SAML Response' });
return redirect();
}
const serviceProvider = new SAMLServiceProvider(service);
const { provider, loginClient } = SAMLUtils.decodeAuthorizeRelayState(envelope.relayState);
// Keep exposing the provider as the relay state for downstream profile mapping.
SAMLUtils.relayState = provider ?? null;
serviceProvider.validateResponse(envelope, async (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.');
}
const baseExpireAt = profile.expireAt instanceof Date ? profile.expireAt : new Date(Date.now() + 300000);
const safeExpireAt = new Date(baseExpireAt.getTime() + (service.allowedClockDrift || 0));
if (!profile.assertionId || !profile.issuer) {
SAMLUtils.error({ msg: 'Invalid SAML response: missing Assertion ID or Issuer', profile });
throw new Error('Invalid SAML response: missing Assertion ID or Issuer.');
}
if (!(await SamlUsedAssertions.markUsed(profile.assertionId, profile.issuer, safeExpireAt))) {
SAMLUtils.warn({ msg: 'SAML assertion replay detected', issuer: profile.issuer, assertionId: profile.assertionId });
throw new Error('An assertion with the same ID cannot be used more than once.');
}
// create a random token to store the login result
// 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
const credentialToken = Random.id();
const loginResult = {
profile,
};
await this.storeCredential(credentialToken, loginResult);
let redirectPath = SAMLUtils.getValidationActionRedirectPath(credentialToken);
if (loginClient) {
redirectPath += `&loginClient=${loginClient}`;
}
const url = Meteor.absoluteUrl(redirectPath);
redirect(url);
} catch (err) {
SAMLUtils.error({ err });
redirect();
}
});
}
private static async findUser(username: string | undefined, emailRegex: RegExp): Promise<IUser | undefined | null> {
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 async subscribeToSAMLChannels(channels: Array<string>, user: IUser): Promise<void> {
const { includePrivateChannelsInUpdate } = SAMLUtils.globalSettings;
try {
for (let roomName of channels) {
roomName = roomName.trim();
if (!roomName) {
continue;
}
const privRoom = await Rooms.findOneByNameAndType(roomName, 'p', {});
if (privRoom && includePrivateChannelsInUpdate === true) {
await addUserToRoom(privRoom._id, user);
continue;
}
const room = await Rooms.findOneByNameAndType(roomName, 'c', {});
if (room) {
await addUserToRoom(room._id, user);
continue;
}
if (!room && !privRoom) {
// If the user doesn't have an username yet, we can't create new rooms for them
if (user.username) {
await createRoom('c', roomName, user);
}
}
}
} catch (err: any) {
SystemLogger.error({ err });
}
}
}

View File

@@ -0,0 +1,199 @@
import crypto from 'node:crypto';
import querystring from 'node:querystring';
import util from 'node:util';
import zlib from 'node:zlib';
import { Meteor } from 'meteor/meteor';
import { SAMLUtils } from './Utils';
import type { ILogoutResponse } from '../definition/ILogoutResponse';
import type { ISAMLRequest } from '../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
import type { ILogoutRequestValidateCallback, ILogoutResponseValidateCallback, IResponseValidateCallback } from '../definition/callbacks';
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 type { SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../definition/SAMLEnvelope';
import { getSigAlgKeyIfSupported, type SigAlgKey, signatureAlgorithms } from './signature/signatureAlgorithms';
export class SAMLServiceProvider {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
if (!serviceProviderOptions) {
throw new Error('SAMLServiceProvider instantiated without an options object');
}
this.serviceProviderOptions = serviceProviderOptions;
}
private getSignatureAlgorithm(): SigAlgKey {
const algorithm = `RSA-${this.serviceProviderOptions.signatureAlgorithm}`;
return getSigAlgKeyIfSupported(algorithm) || 'RSA-SHA256';
}
private maybeSignRequest(samlObject: Record<string, any>): Record<string, any> {
if (!this.serviceProviderOptions.privateKey) {
return samlObject;
}
const algorithm = this.getSignatureAlgorithm();
const alg = signatureAlgorithms[algorithm];
const xml = querystring.stringify({ ...samlObject, SigAlg: alg });
const signer = crypto.createSign(algorithm);
signer.update(xml);
const signature = signer.sign(this.serviceProviderOptions.privateKey, 'base64');
return {
...samlObject,
SigAlg: alg,
Signature: signature,
};
}
public generateAuthorizeRequest(credentialToken: string): string {
const identifiedRequest = AuthorizeRequest.generate(this.serviceProviderOptions, credentialToken);
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,
relayState: string | undefined,
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 += '?';
}
// The SAML spec requires the response RelayState to exactly match the value received on the request.
const samlResponse = this.maybeSignRequest({
SAMLResponse: base64,
...(relayState !== undefined && { RelayState: relayState }),
});
target += querystring.stringify(samlResponse);
return callback(null, target);
} catch (error) {
return callback(error instanceof Error ? error : String(error));
}
});
}
/*
This method will generate the request URL with all the query string params and pass it to the callback
*/
public async requestToUrl(request: string, operation: string, loginClient?: string): Promise<string | undefined> {
const buffer = await util.promisify(zlib.deflateRaw)(request);
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 = SAMLUtils.encodeAuthorizeRelayState(this.serviceProviderOptions.provider, loginClient);
}
const samlRequest = this.maybeSignRequest({
SAMLRequest: base64,
RelayState: relayState,
});
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 target;
}
return target;
} catch (error) {
throw error instanceof Error ? error : String(error);
}
}
public async getAuthorizeUrl(credentialToken: string, loginClient?: string): Promise<string | undefined> {
const request = this.generateAuthorizeRequest(credentialToken);
SAMLUtils.log({ request, msg: 'getAuthorizeUrl' });
return this.requestToUrl(request, 'authorize', loginClient);
}
public async validateLogoutRequest(
envelope: SAMLRedirectEnvelope<'SAMLRequest'>,
callback: ILogoutRequestValidateCallback,
): Promise<void> {
const parser = new LogoutRequestParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public async validateLogoutResponse(
envelope: SAMLRedirectEnvelope<'SAMLResponse'>,
callback: ILogoutResponseValidateCallback,
): Promise<void> {
const parser = new LogoutResponseParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public validateResponse(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void {
const parser = new ResponseParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public generateServiceProviderMetadata(): string {
return ServiceProviderMetadata.generate(this.serviceProviderOptions);
}
}

View File

@@ -0,0 +1,519 @@
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import zlib from 'node:zlib';
import type { Logger } from '@rocket.chat/logger';
import { StatusCode } from './constants';
import { ensureArray } from '../../../../lib/utils/arrayUtils';
import type { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping';
import type { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings';
import type { ISAMLUser } from '../definition/ISAMLUser';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
let providerList: Array<IServiceProviderOptions> = [];
let debug = false;
let relayState: string | null = null;
let logger: Logger | undefined;
const globalSettings: ISAMLGlobalSettings = {
generateUsername: false,
nameOverwrite: false,
mailOverwrite: false,
immutableProperty: 'EMail',
defaultUserRole: 'user',
userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}',
usernameNormalize: 'None',
channelsAttributeUpdate: false,
includePrivateChannelsInUpdate: false,
};
export class SAMLUtils {
public static events: EventEmitter;
public static get isDebugging(): boolean {
return debug;
}
public static get globalSettings(): ISAMLGlobalSettings {
return globalSettings;
}
public static get serviceProviders(): Array<IServiceProviderOptions> {
return providerList;
}
public static get relayState(): string | null {
return relayState;
}
public static set relayState(value: string | null) {
relayState = value;
}
public static get logger(): Logger | undefined {
return logger;
}
public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined {
this.log({ providerName, providerList });
return providerList.find((providerOptions) => providerOptions.provider === providerName);
}
public static setServiceProvidersList(list: Array<IServiceProviderOptions>): void {
providerList = list;
}
public static setLoggerInstance(instance: Logger): void {
logger = instance;
}
// TODO: Some of those should probably not be global
public static updateGlobalSettings(samlConfigs: Record<string, any>): void {
debug = Boolean(samlConfigs.debug);
globalSettings.generateUsername = Boolean(samlConfigs.generateUsername);
globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite);
globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite);
globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate);
globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate);
globalSettings.defaultUserRole = samlConfigs.defaultUserRole;
if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') {
globalSettings.immutableProperty = samlConfigs.immutableProperty;
}
if (samlConfigs.usernameNormalize && typeof samlConfigs.usernameNormalize === 'string') {
globalSettings.usernameNormalize = samlConfigs.usernameNormalize;
}
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 isParsableCertificate(cert: string): boolean {
try {
void new crypto.X509Certificate(this.certToPEM(cert));
return true;
} catch {
return false;
}
}
public static fillTemplateData(template: string, data: Record<string, string>): 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 getValidationActionRedirectPath(credentialToken: string): string {
// the saml_idp_credentialToken param is needed by the mobile app
return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`;
}
public static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' {
return value === 'desktop' || value === 'mobile';
}
public static encodeAuthorizeRelayState(provider: string, loginClient?: string): string {
if (!this.isSupportedLoginClient(loginClient)) {
return provider;
}
return new URLSearchParams({ provider, loginClient }).toString();
}
public static decodeAuthorizeRelayState(relayState?: string | null): { provider?: string; loginClient?: 'desktop' | 'mobile' } {
if (!relayState) {
return {};
}
if (relayState.startsWith('provider=') && relayState.includes('&loginClient=')) {
const params = new URLSearchParams(relayState);
const provider = params.get('provider') ?? undefined;
const loginClient = params.get('loginClient');
return {
provider,
loginClient: this.isSupportedLoginClient(loginClient) ? loginClient : undefined,
};
}
return { provider: relayState };
}
public static log(obj: object | string): void {
if (debug && logger) {
logger.debug(obj);
}
}
public static error(obj: object | string): void {
if (logger) {
logger.error(obj);
}
}
public static warn(obj: object | string): void {
if (logger) {
logger.warn(obj);
}
}
public static async inflateXml(deflatedXml: Buffer<ArrayBuffer>): Promise<Buffer<ArrayBuffer>> {
return new Promise((resolve, reject) => {
zlib.inflateRaw(deflatedXml, (err, inflatedXml) => {
if (err) {
this.log({ msg: 'Error while inflating.', err });
return reject(err);
}
if (!inflatedXml) {
return reject(new Error('Failed to extract request data'));
}
resolve(Buffer.from(inflatedXml));
});
});
}
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?.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<string, any>;
try {
map = JSON.parse(userDataFieldMap);
} catch (err) {
SAMLUtils.log({ userDataFieldMap, err });
throw new Error('Failed to parse custom user field map');
}
const parsedMap: IUserDataMap = {
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;
}
}
}
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<string, any>, mapping: IAttributeMapping, forceString = false): any {
const values: Record<string, string> = {
regex: '',
};
const fieldNames = ensureArray<string>(mapping.fieldName);
let mainValue;
for (const fieldName of fieldNames) {
let profileValue = profile[fieldName];
if (Array.isArray(profileValue)) {
for (let i = 0; i < profile[fieldName].length; i++) {
// Add every index to the list of possible values to be used, both first to last and from last to first
values[`${fieldName}[${i}]`] = profileValue[i];
values[`${fieldName}[-${Math.abs(0 - profileValue.length + i)}]`] = profileValue[i];
}
values[`${fieldName}[]`] = profileValue.join(' ');
if (forceString) {
profileValue = profileValue.join(' ');
}
} else {
values[fieldName] = profileValue;
}
values[fieldName] = profileValue;
if (!mainValue) {
mainValue = profileValue;
}
}
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?.match) {
let regexValue;
const match = mainValue.match(new RegExp(mapping.regex));
if (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 normalizeUsername(name: string): string {
const { globalSettings } = this;
switch (globalSettings.usernameNormalize) {
case 'Lowercase':
name = name.toLowerCase();
break;
}
return name;
}
public static mapProfileToUserObject(profile: Record<string, any>): ISAMLUser {
const userDataMap = this.getUserDataMapping();
SAMLUtils.log({ msg: 'Mapping SAML Profile to User Object', userDataMap });
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({ msg: 'SAML user profile is missing the attribute.', attribute: attributeName });
continue;
}
attributeList.set(attributeName, profile[attributeName]);
}
const email = this.getProfileValue(profile, userDataMap.email);
const profileUsername = this.getProfileValue(profile, userDataMap.username, true);
const name = this.getProfileValue(profile, userDataMap.name, true);
// 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 = {
samlLogin: {
provider: this.relayState,
idp: profile.issuer,
idpSession: profile.sessionIndex,
nameID: profile.nameID,
},
emailList: ensureArray<string>(email),
fullName: name || profile.displayName || profile.username,
eppn: profile.eppn,
attributeList,
identifier: userDataMap.identifier,
};
if (profileUsername) {
userObject.username = this.normalizeUsername(profileUsername);
}
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(',');
}
}
this.events.emit('mapUser', { profile, userObject });
return userObject;
}
}
SAMLUtils.events = new EventEmitter();

View File

@@ -0,0 +1,53 @@
export const defaultAuthnContextTemplate = `<samlp:RequestedAuthnContext xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Comparison="__authnContextComparison__">
<saml:AuthnContextClassRef xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
__authnContext__
</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>`;
export const defaultAuthRequestTemplate = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__newId__" Version="2.0" IssueInstant="__instant__" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="__callbackUrl__" Destination="__entryPoint__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
__identifierFormatTag__
__authnContextTag__
</samlp:AuthnRequest>`;
export const defaultLogoutResponseTemplate = `<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__inResponseToId__" Version="2.0" IssueInstant="__instant__" Destination="__idpSLORedirectURL__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
</samlp:LogoutResponse>`;
export const defaultLogoutRequestTemplate = `<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__newId__" Version="2.0" IssueInstant="__instant__" Destination="__idpSLORedirectURL__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" NameQualifier="http://id.init8.net:8080/openam" SPNameQualifier="__issuer__" Format="__identifierFormat__">__nameID__</saml:NameID>
<samlp:SessionIndex xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">__sessionIndex__</samlp:SessionIndex>
</samlp:LogoutRequest>`;
export const defaultMetadataCertificateTemplate = `
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>__certificate__</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
</KeyDescriptor>`;
export const defaultMetadataTemplate = `<?xml version="1.0"?>
<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="__issuer__">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">__certificateTag__
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="__sloLocation__" ResponseLocation="__sloLocation__"/>
<NameIDFormat>__identifierFormat__</NameIDFormat>
<AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="__callbackUrl__"/>
</SPSSODescriptor>
</EntityDescriptor>`;
export const defaultNameIDTemplate =
'<samlp:NameIDPolicy xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Format="__identifierFormat__" AllowCreate="true"></samlp:NameIDPolicy>';
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',
};

View File

@@ -0,0 +1,81 @@
import type { IAuthorizeRequestVariables } from '../../definition/IAuthorizeRequestVariables';
import type { ISAMLRequest } from '../../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import {
defaultIdentifierFormat,
defaultAuthnContext,
defaultAuthRequestTemplate,
defaultNameIDTemplate,
defaultAuthnContextTemplate,
} from '../constants';
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions): string | undefined;
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue: string): string;
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue?: string): string | undefined {
const value = serviceProviderOptions.customAuthnContext.trim();
return value.length > 0 ? value : defaultValue;
}
/*
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, credentialToken: string): ISAMLRequest {
const data = this.getDataForNewRequest(serviceProviderOptions, credentialToken);
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 (!resolveCustomAuthnContext(serviceProviderOptions)) {
return '';
}
return serviceProviderOptions.authnContextTemplate || defaultAuthnContextTemplate;
}
private static getDataForNewRequest(
serviceProviderOptions: IServiceProviderOptions,
credentialToken?: string,
): IAuthorizeRequestVariables {
const id = credentialToken || `_${SAMLUtils.generateUniqueID()}`;
const instant = SAMLUtils.generateInstant();
return {
newId: id,
instant,
callbackUrl: serviceProviderOptions.callbackUrl,
entryPoint: serviceProviderOptions.entryPoint,
issuer: serviceProviderOptions.issuer,
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
authnContextComparison: serviceProviderOptions.authnContextComparison || 'exact',
authnContext: resolveCustomAuthnContext(serviceProviderOptions, defaultAuthnContext),
};
}
}

View File

@@ -0,0 +1,45 @@
import type { ILogoutRequestVariables } from '../../definition/ILogoutRequestVariables';
import type { ISAMLRequest } from '../../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import { defaultIdentifierFormat, defaultLogoutRequestTemplate } from '../constants';
/*
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({ request, msg: '------- SAML Logout request -----------' });
return {
request,
id: data.newId,
};
}
static getDataForNewRequest(
serviceProviderOptions: IServiceProviderOptions,
nameID: string,
sessionIndex: string,
): ILogoutRequestVariables {
// nameId: <nameId as submitted during SAML SSO>
// sessionIndex: sessionIndex
// --- NO SAMLsettings: <Meteor.setting.saml entry for the provider you want to SLO from
const id = `_${SAMLUtils.generateUniqueID()}`;
const instant = SAMLUtils.generateInstant();
return {
newId: id,
instant,
idpSLORedirectURL: serviceProviderOptions.idpSLORedirectURL,
issuer: serviceProviderOptions.issuer,
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
nameID,
sessionIndex,
};
}
}

View File

@@ -0,0 +1,49 @@
import type { ILogoutResponse } from '../../definition/ILogoutResponse';
import type { ILogoutResponseVariables } from '../../definition/ILogoutResponseVariables';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import { defaultLogoutResponseTemplate, defaultIdentifierFormat } from '../constants';
/*
A Logout Response is used when the Identity Provider (IdP) sends us a Logout Request.
*/
export class LogoutResponse {
public static generate(
serviceProviderOptions: IServiceProviderOptions,
nameID: string,
sessionIndex: string,
inResponseToId: string,
): ILogoutResponse {
const data = this.getDataForNewResponse(serviceProviderOptions, nameID, sessionIndex, inResponseToId);
const response = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutResponseTemplate || defaultLogoutResponseTemplate, data);
SAMLUtils.log({ response, msg: '------- SAML Logout response -----------' });
return {
response,
id: data.newId,
inResponseToId: data.inResponseToId,
};
}
private static getDataForNewResponse(
serviceProviderOptions: IServiceProviderOptions,
nameID: string,
sessionIndex: string,
inResponseToId: string,
): ILogoutResponseVariables {
const id = `_${SAMLUtils.generateUniqueID()}`;
const instant = SAMLUtils.generateInstant();
return {
newId: id,
instant,
idpSLORedirectURL: serviceProviderOptions.idpSLORedirectURL,
issuer: serviceProviderOptions.issuer,
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
nameID,
sessionIndex,
inResponseToId,
};
}
}

View File

@@ -0,0 +1,53 @@
import { Meteor } from 'meteor/meteor';
import type { IMetadataVariables } from '../../definition/IMetadataVariables';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import { defaultIdentifierFormat, defaultMetadataCertificateTemplate, defaultMetadataTemplate } from '../constants';
/*
The metadata will be available at the following url:
[rocketchat-url]/_saml/metadata/[provider-name]
*/
export class ServiceProviderMetadata {
public static generate(serviceProviderOptions: IServiceProviderOptions): string {
const data = this.getData(serviceProviderOptions);
return SAMLUtils.fillTemplateData(this.metadataTemplate(serviceProviderOptions), data);
}
private static certificateTagTemplate(serviceProviderOptions: IServiceProviderOptions): string {
if (!serviceProviderOptions.privateKey) {
return '';
}
if (!serviceProviderOptions.privateCert) {
throw new Error('Missing certificate while generating metadata for decrypting service provider');
}
return serviceProviderOptions.metadataCertificateTemplate || defaultMetadataCertificateTemplate;
}
private static metadataTemplate(serviceProviderOptions: IServiceProviderOptions): string {
const data = {
certificateTag: this.certificateTagTemplate(serviceProviderOptions),
};
const template = serviceProviderOptions.metadataTemplate || defaultMetadataTemplate;
return SAMLUtils.fillTemplateData(template, data);
}
private static getData(serviceProviderOptions: IServiceProviderOptions): IMetadataVariables {
if (!serviceProviderOptions.callbackUrl) {
throw new Error('Unable to generate service provider metadata when callbackUrl option is not set');
}
return {
issuer: serviceProviderOptions.issuer,
certificate: SAMLUtils.normalizeCert(serviceProviderOptions.privateCert),
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
callbackUrl: serviceProviderOptions.callbackUrl,
sloLocation: `${Meteor.absoluteUrl()}_saml/logout/${serviceProviderOptions.provider}/`,
};
}
}

View File

@@ -0,0 +1,120 @@
import type { IIncomingMessage } from '@rocket.chat/core-typings';
import { SAMLUtils } from './Utils';
import type {
SAMLEnvelope,
SAMLBinding,
SAMLDocumentType,
SAMLRedirectEnvelope,
SAMLPOSTEnvelope,
SAMLBaseEnvelope,
} from '../definition/SAMLEnvelope';
function getStringAttribute(params: Record<string, any>, attributeName: string): string | null {
if (!(attributeName in params)) {
return null;
}
if (typeof params[attributeName] !== 'string') {
return null;
}
return params[attributeName];
}
async function performBindingSpecificDecoding(binding: SAMLBinding, buffer: Buffer<ArrayBuffer>): Promise<Buffer<ArrayBuffer>> {
switch (binding) {
case 'HTTP-Redirect':
return SAMLUtils.inflateXml(buffer);
default:
return buffer;
}
}
async function decodeDocument(base64Data: string, binding: SAMLBinding): Promise<string> {
const buffer = await performBindingSpecificDecoding(binding, Buffer.from(base64Data, 'base64'));
return buffer.toString('utf8');
}
function getSignedContent(req: IIncomingMessage, documentType: SAMLDocumentType): string | null {
// We load the signed content directly from the query string, because:
// 1. We need the raw value, without running decodeURIComponent or any other processing/normalization
// 2. We need to respect the order the params came in (although the order should be fixed)
const rawUrl = req.url;
if (!rawUrl) {
return null;
}
const queryString = rawUrl.split('?')[1];
if (!queryString) {
return null;
}
const params = queryString.split('&');
const signedContent = params
.filter((p) => p.startsWith(`${documentType}=`) || p.startsWith(`RelayState=`) || p.startsWith('SigAlg='))
.join('&');
return signedContent || null;
}
export async function getSAMLEnvelope<T extends SAMLDocumentType = SAMLDocumentType>(
req: IIncomingMessage,
type: T,
binding: 'HTTP-Redirect',
): Promise<SAMLRedirectEnvelope<T> | null>;
export async function getSAMLEnvelope<T extends SAMLDocumentType = SAMLDocumentType>(
req: IIncomingMessage,
type: T,
binding: 'HTTP-POST',
): Promise<SAMLPOSTEnvelope<T> | null>;
export async function getSAMLEnvelope<B extends SAMLBinding, T extends SAMLDocumentType = SAMLDocumentType>(
req: IIncomingMessage,
type: T,
binding: B,
): Promise<SAMLEnvelope<T> | null> {
const params = binding === 'HTTP-Redirect' ? req.query : req.body;
if (!params) {
return null;
}
const encodedDocument = getStringAttribute(params, type);
if (!encodedDocument) {
return null;
}
const decodedDocument = await decodeDocument(encodedDocument, binding);
const relayState = getStringAttribute(params, 'RelayState');
const envelope: SAMLBaseEnvelope<T> = {
binding,
type,
encodedDocument,
decodedDocument,
...(typeof relayState === 'string' && { relayState }),
};
if (envelope.binding !== 'HTTP-Redirect') {
return envelope;
}
const signedContent = getSignedContent(req, type);
const sigAlg = getStringAttribute(params, 'SigAlg');
const signature = getStringAttribute(params, 'Signature');
if (sigAlg && signature && signedContent) {
return {
...envelope,
sigAlg,
signature,
signedContent,
};
}
return envelope;
}

View File

@@ -0,0 +1,141 @@
import xmldom from '@xmldom/xmldom';
import { SAMLUtils } from '../Utils';
const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata';
const DS_NS = 'http://www.w3.org/2000/09/xmldsig#';
const REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
const SAML2_PROTOCOL = 'urn:oasis:names:tc:SAML:2.0:protocol';
type IdpMetadataResult = {
entryPoint?: string;
idpSLORedirectURL?: string;
cert?: string;
identifierFormat?: string;
warnings: string[];
};
type ExtractedValue = {
value?: string;
warning?: string;
};
export class InvalidIdpMetadataError extends Error {}
const isHttpUrl = (value: string): boolean => {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
};
const childrenOf = (el: Element | Document, tag: string): Element[] =>
Array.from(el.getElementsByTagNameNS(MD_NS, tag)).filter((child) => child.parentNode === el);
const parseIdpDescriptor = (xml: string): Element => {
const doc = new xmldom.DOMParser({
errorHandler: () => {
throw new InvalidIdpMetadataError('invalid-xml');
},
}).parseFromString(xml, 'text/xml');
if (!doc) {
throw new InvalidIdpMetadataError('invalid-xml');
}
const entityDescriptor = childrenOf(doc, 'EntityDescriptor')[0];
if (!entityDescriptor) {
throw new InvalidIdpMetadataError('root-is-not-entity-descriptor');
}
const idpDescriptors = childrenOf(entityDescriptor, 'IDPSSODescriptor');
if (!idpDescriptors.length) {
throw new InvalidIdpMetadataError('no-idp-sso-descriptor');
}
const idpDescriptor = idpDescriptors.find((descriptor) =>
(descriptor.getAttribute('protocolSupportEnumeration') ?? '').split(/\s+/).includes(SAML2_PROTOCOL),
);
if (!idpDescriptor) {
throw new InvalidIdpMetadataError('no-saml2-idp-sso-descriptor');
}
return idpDescriptor;
};
const extractSigningCert = (idp: Element): ExtractedValue => {
const certs = childrenOf(idp, 'KeyDescriptor')
.filter((kd) => {
const use = kd.getAttribute('use');
return !use || use === 'signing';
})
.map((kd) => kd.getElementsByTagNameNS(DS_NS, 'X509Certificate')[0]?.textContent?.trim())
.map((raw) => (raw ? SAMLUtils.normalizeCert(raw) : undefined))
.filter((cert): cert is string => !!cert && SAMLUtils.isParsableCertificate(cert));
if (!certs.length) {
return { warning: 'SAML_Metadata_warning_no_valid_cert' };
}
const warning = certs.length > 1 ? 'SAML_Metadata_warning_multiple_certs' : undefined;
return { value: certs[0], warning };
};
const findRedirectLocation = (services: Element[]): string | undefined =>
services
.filter((s) => s.getAttribute('Binding') === REDIRECT_BINDING)
.map((s) => s.getAttribute('Location'))
.find((location): location is string => !!location && isHttpUrl(location));
const extractEntryPoint = (idp: Element): ExtractedValue => {
const value = findRedirectLocation(childrenOf(idp, 'SingleSignOnService'));
const warning = value ? undefined : 'SAML_Metadata_warning_no_redirect_binding';
return { value, warning };
};
const extractSloUrl = (idp: Element): ExtractedValue => {
const services = childrenOf(idp, 'SingleLogoutService');
if (!services.length) {
return {};
}
const value = findRedirectLocation(services);
const warning = value ? undefined : 'SAML_Metadata_warning_no_slo_redirect_binding';
return { value, warning };
};
const extractIdentifierFormat = (idp: Element): ExtractedValue => {
const formats = childrenOf(idp, 'NameIDFormat')
.map((n) => n.textContent?.trim())
.filter((v): v is string => !!v);
const warning = formats.length > 1 ? 'SAML_Metadata_warning_multiple_nameid_formats' : undefined;
return {
value: formats[0],
warning,
};
};
export function parseIdpMetadata(xml: string): IdpMetadataResult {
const idp = parseIdpDescriptor(xml);
const cert = extractSigningCert(idp);
const entryPoint = extractEntryPoint(idp);
const sloUrl = extractSloUrl(idp);
const identifierFormat = extractIdentifierFormat(idp);
const warnings = [cert, entryPoint, sloUrl, identifierFormat].flatMap(({ warning }) => warning ?? []);
return {
cert: cert.value,
entryPoint: entryPoint.value,
idpSLORedirectURL: sloUrl.value,
identifierFormat: identifierFormat.value,
warnings,
};
}

View File

@@ -0,0 +1,66 @@
import xmldom from '@xmldom/xmldom';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope';
import type { ILogoutRequestValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { validateRedirectSignature } from '../signature/validateRedirectSignature';
export class LogoutRequestParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public async validate(envelope: SAMLRedirectEnvelope<'SAMLRequest'>, callback: ILogoutRequestValidateCallback): Promise<void> {
const { decodedDocument: xmlString } = envelope;
SAMLUtils.log({ msg: 'Validating SAML Logout Request', xmlString });
if (!this.verifySignature(envelope)) {
SAMLUtils.log({ msg: 'Failed to verify signature from Logout Request' });
return callback('Signature validation failed');
}
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;
const id = request.getAttribute('ID');
return callback(null, { idpSession, nameID, id });
} catch (err) {
SAMLUtils.error({ err });
const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage');
SAMLUtils.log({ msg: `Unexpected msg from IDP. Does your session still exist at IDP?`, statusMessage });
return callback(err instanceof Error ? err : String(err), null);
}
}
private verifySignature(envelope: SAMLRedirectEnvelope<'SAMLRequest'>): boolean {
if (!this.serviceProviderOptions.validateLogoutRequestSignature) {
return true;
}
return validateRedirectSignature(envelope, this.serviceProviderOptions.cert);
}
}

View File

@@ -0,0 +1,66 @@
import xmldom from '@xmldom/xmldom';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope';
import type { ILogoutResponseValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { validateRedirectSignature } from '../signature/validateRedirectSignature';
export class LogoutResponseParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public async validate(envelope: SAMLRedirectEnvelope<'SAMLResponse'>, callback: ILogoutResponseValidateCallback): Promise<void> {
const { decodedDocument: xmlString } = envelope;
SAMLUtils.log({ msg: 'Validating SAML Logout Response', xmlString });
if (!this.verifySignature(envelope)) {
SAMLUtils.log({ msg: 'Failed to verify signature from Logout Response' });
return callback('Signature validation failed');
}
const doc = new xmldom.DOMParser().parseFromString(xmlString, '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');
SAMLUtils.log({ msg: `Found InResponseTo`, inResponseTo });
} catch (err) {
SAMLUtils.log({ err });
const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage');
SAMLUtils.log(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${msg}`);
}
if (!inResponseTo) {
return callback('Unexpected Response from IDP', null);
}
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
return callback('Error. Logout not confirmed by IDP', null);
}
return callback(null, inResponseTo);
}
private verifySignature(envelope: SAMLRedirectEnvelope<'SAMLResponse'>): boolean {
if (!this.serviceProviderOptions.validateLogoutResponseSignature) {
return true;
}
return validateRedirectSignature(envelope, this.serviceProviderOptions.cert);
}
}

View File

@@ -0,0 +1,531 @@
import xmldom from '@xmldom/xmldom';
import { SignedXml } from 'xml-crypto';
import type { Reference } from 'xml-crypto';
import xmlenc from 'xml-encryption';
import type { ISAMLAssertion } from '../../definition/ISAMLAssertion';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLPOSTEnvelope } from '../../definition/SAMLEnvelope';
import type { IResponseValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { StatusCode } from '../constants';
type XmlParent = Element | Document;
export class ResponseParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public validate(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void {
const { decodedDocument: xml } = envelope;
// We currently use RelayState to save SAML provider
SAMLUtils.log({ msg: 'Validating SAML Response', xml });
let error: Error | null = null;
const doc = new xmldom.DOMParser({
errorHandler: {
fatalError: (e: any) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
error: (e: Error) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
},
}).parseFromString(xml, 'text/xml');
if (!doc) {
return callback('No Doc Found');
}
if (error) {
return callback(error, null, false);
}
const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response');
if (allResponses.length === 0) {
return this._checkLogoutResponse(doc, callback);
}
if (allResponses.length !== 1) {
return callback(new Error('Too many SAML responses'), null, false);
}
const response = allResponses[0];
if (response !== doc.documentElement) {
return callback(new Error('SAML Response must be the document root'), null, false);
}
SAMLUtils.log('Got response');
SAMLUtils.log('Verify status');
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
if (!statusValidateObj.statusCode) {
return callback(new Error('Missing StatusCode'), null, false);
}
if (statusValidateObj.statusCode === StatusCode.responder && statusValidateObj.message) {
return callback(new Error(statusValidateObj.message), null, false);
}
return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
}
SAMLUtils.log('Status ok');
let assertion: Element;
let assertionData: ISAMLAssertion;
let issuer;
try {
assertionData = this.getAssertion(response, xml);
assertion = assertionData.assertion;
this.verifySignatures(response, assertionData, xml);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
const profile: Record<string, any> = {};
if (response.hasAttribute('InResponseTo')) {
profile.inResponseToId = response.getAttribute('InResponseTo');
}
try {
issuer = this.getIssuer(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
if (issuer) {
profile.issuer = issuer.textContent;
}
if (assertion.hasAttribute('ID')) {
profile.assertionId = assertion.getAttribute('ID');
}
const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
if (conditions?.hasAttribute('NotOnOrAfter')) {
const notOnOrAfter = conditions.getAttribute('NotOnOrAfter');
if (notOnOrAfter) {
profile.expireAt = new Date(notOnOrAfter);
}
}
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 instanceof Error ? e : String(e), null, false);
}
}
try {
this.validateAssertionConditions(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(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({ msg: 'Session Index Found', sessionIndex: 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({ msg: 'NameID', 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: Element = allAssertions[0];
const encAssertion = allEncrypedAssertions[0];
let newXml = null;
if (typeof encAssertion !== 'undefined') {
// disallowDecryptionWithInsecureAlgorithm defaults to true in xml-encryption v4, but AES-CBC/3DES
// are still widely used by SAML IdPs in practice, so we keep the pre-v4 behaviour here.
const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false };
const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0];
xmlenc.decrypt(encData, options, (err, result) => {
if (err) {
SAMLUtils.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 {
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 (!this.serviceProviderOptions.cert) {
if (checkResponse || checkAssertion) {
SAMLUtils.log('Missing Signature validation params');
throw new Error('Unable to validate signature');
}
return;
}
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: Element): boolean {
return this.validateSignatureChildren(xml, cert, assertion);
}
private validateSignatureChildren(xml: string, cert: string, parent: Element): boolean {
const signatures = parent.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature');
let signature = null;
for (let i = 0; i < signatures.length; i++) {
const sign = signatures[i];
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, this.getElementId(parent));
}
private getElementId(element: Element): string | null {
return element.getAttribute('ID') || element.getAttribute('Id') || element.getAttribute('id');
}
private signatureCoversElement(sig: SignedXml, expectedId: string | null): boolean {
if (!expectedId) {
return false;
}
const references: Reference[] = sig.getReferences() || [];
if (references.length === 0) {
return false;
}
return references.every((reference: Reference) => {
// `signedReference` is only populated for references that were part of the successfully
// validated signature, so its presence proves this reference was actually covered.
if (reference.signedReference == null) {
return false;
}
const uri = reference.uri || '';
const referencedId = uri.charAt(0) === '#' ? uri.substring(1) : uri;
return referencedId === expectedId;
});
}
private validateSignature(xml: string, cert: string, signature: Element, expectedId: string | null): boolean {
const sig = new SignedXml({ publicCert: Buffer.from(SAMLUtils.certToPEM(cert)) });
sig.loadSignature(signature);
try {
if (!sig.checkSignature(xml)) {
SAMLUtils.log(sig.getReferences().map((reference) => reference.validationError?.message));
return false;
}
} catch (e) {
SAMLUtils.log(e instanceof Error ? e.message : String(e));
return false;
}
if (!this.signatureCoversElement(sig, expectedId)) {
SAMLUtils.log('Failed to validate SAML signature: signed reference does not match the processed element');
return false;
}
return true;
}
private getIssuer(assertion: Element): 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: Element): 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, disallowDecryptionWithInsecureAlgorithm: false };
xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => {
if (err) {
SAMLUtils.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 || 0;
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: Element): 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<string, any>): void {
SAMLUtils.log({ msg: 'Attribute Statement found, mapping attributes to profile.', attributeStatement });
const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute');
SAMLUtils.log({ msg: 'Attributes will be processed', count: 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({ msg: 'Mapping attribute to profile', attribute: 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'];
}
}
}

View File

@@ -0,0 +1,362 @@
import type { SAMLConfiguration } from '@rocket.chat/core-typings';
import { LoginServiceConfiguration } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { SAMLUtils } from './Utils';
import {
defaultAuthnContextTemplate,
defaultAuthRequestTemplate,
defaultLogoutResponseTemplate,
defaultLogoutRequestTemplate,
defaultNameIDTemplate,
defaultIdentifierFormat,
defaultAuthnContext,
defaultMetadataTemplate,
defaultMetadataCertificateTemplate,
} from './constants';
import { settings, settingsRegistry } from '../../../settings';
import { SystemLogger } from '../../logger/system';
import { notifyOnLoginServiceConfigurationChanged, notifyOnLoginServiceConfigurationChangedByService } from '../../notifyListener';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
const getSamlConfigs = function (service: string): SAMLConfiguration {
const configs: SAMLConfiguration = {
buttonLabelText: settings.get(`${service}_button_label_text`),
buttonLabelColor: settings.get(`${service}_button_label_color`),
buttonColor: settings.get(`${service}_button_color`),
clientConfig: {
provider: settings.get<string>(`${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`),
defaultUserRole: settings.get(`${service}_default_user_role`),
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) || ''),
algorithm: settings.get(`${service}_signature_algorithm`) || 'SHA1',
},
signatureValidationType: settings.get(`${service}_signature_validation_type`),
validateLogoutRequestSignature: settings.get(`${service}_validate_logout_request_signature`),
validateLogoutResponseSignature: settings.get(`${service}_validate_logout_response_signature`),
userDataFieldMap: settings.get(`${service}_user_data_fieldmap`),
allowedClockDrift: settings.get(`${service}_allowed_clock_drift`),
customAuthnContext: defaultAuthnContext,
authnContextComparison: 'exact',
identifierFormat: defaultIdentifierFormat,
nameIDPolicyTemplate: defaultNameIDTemplate,
authnContextTemplate: defaultAuthnContextTemplate,
authRequestTemplate: defaultAuthRequestTemplate,
logoutResponseTemplate: defaultLogoutResponseTemplate,
logoutRequestTemplate: defaultLogoutRequestTemplate,
metadataCertificateTemplate: defaultMetadataCertificateTemplate,
metadataTemplate: defaultMetadataTemplate,
channelsAttributeUpdate: settings.get(`${service}_channels_update`),
includePrivateChannelsInUpdate: settings.get(`${service}_include_private_channels_update`),
};
SAMLUtils.events.emit('loadConfigs', service, configs);
return configs;
};
const isValidConfiguration = function (key: string, samlConfigs: SAMLConfiguration): boolean {
const needsCert = samlConfigs.signatureValidationType !== 'None';
if (!samlConfigs.secret.cert && needsCert) {
SAMLUtils.log({ msg: 'SAML Configuration missing Custom Certificate setting', key });
return false;
}
return true;
};
const configureSamlService = function (samlConfigs: Record<string, any>): IServiceProviderOptions {
let privateCert = null;
let privateKey = null;
const rawCustomAuthnContext = samlConfigs.customAuthnContext;
const normalizedCustomAuthnContext = typeof rawCustomAuthnContext === 'string' ? rawCustomAuthnContext.trim() : rawCustomAuthnContext;
const customAuthnContext = typeof normalizedCustomAuthnContext === 'string' ? normalizedCustomAuthnContext : defaultAuthnContext;
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,
signatureAlgorithm: samlConfigs.secret.algorithm,
customAuthnContext,
authnContextComparison: samlConfigs.authnContextComparison,
defaultUserRole: samlConfigs.defaultUserRole,
allowedClockDrift: parseInt(samlConfigs.allowedClockDrift) || 0,
signatureValidationType: samlConfigs.signatureValidationType,
validateLogoutRequestSignature: samlConfigs.validateLogoutRequestSignature,
validateLogoutResponseSignature: samlConfigs.validateLogoutResponseSignature,
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 = async function (): Promise<void> {
const serviceName = 'saml';
const services = settings.getByRegexp(/^(SAML_Custom_)[a-z]+$/i);
if (!services) {
return SAMLUtils.setServiceProvidersList([]);
}
const providers = (
await Promise.all(
services.map(async ([key, value]) => {
if (value === true) {
const samlConfigs = getSamlConfigs(key);
if (isValidConfiguration(key, samlConfigs)) {
SAMLUtils.log({ msg: 'Loading SAML Provider', key });
await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs);
void notifyOnLoginServiceConfigurationChangedByService(serviceName);
return configureSamlService(samlConfigs);
}
SAMLUtils.logger?.warn({ msg: 'SAML Provider not loaded due to invalid configuration', key });
}
const service = await LoginServiceConfiguration.removeByService(serviceName);
if (!service) {
return false;
}
void notifyOnLoginServiceConfigurationChanged({ _id: service._id }, 'removed');
return false;
}),
)
).filter((e) => e) as IServiceProviderOptions[];
SAMLUtils.setServiceProvidersList(providers);
};
export const addSamlService = function (name: string): void {
SystemLogger.warn({
msg: 'Adding SAML service is deprecated',
serviceName: name,
});
};
export const addSettings = async function (name: string): Promise<void> {
await settingsRegistry.addGroup('SAML', async function () {
await this.with(
{
tab: 'SAML_Connection',
},
async function () {
await this.add(`SAML_Custom_${name}`, false, {
type: 'boolean',
i18nLabel: 'Accounts_OAuth_Custom_Enable',
public: true,
alert: 'Premium_required_from_9_0_0_alert',
});
await this.add(`SAML_Custom_${name}_provider`, 'provider-name', {
type: 'string',
i18nLabel: 'SAML_Custom_Provider',
public: true,
});
await this.add(`SAML_Custom_${name}_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', {
type: 'string',
i18nLabel: 'SAML_Custom_Entry_point',
});
await this.add(`SAML_Custom_${name}_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', {
type: 'string',
i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL',
public: true,
});
await this.add(`SAML_Custom_${name}_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', {
type: 'string',
i18nLabel: 'SAML_Custom_Issuer',
});
await this.add(`SAML_Custom_${name}_debug`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_Debug',
});
await this.section('SAML_Section_2_Certificate', async function () {
await this.add(`SAML_Custom_${name}_cert`, '', {
type: 'string',
i18nLabel: 'SAML_Custom_Cert',
multiline: true,
secret: true,
});
await this.add(`SAML_Custom_${name}_public_cert`, '', {
type: 'string',
multiline: true,
i18nLabel: 'SAML_Custom_Public_Cert',
});
await this.add(`SAML_Custom_${name}_signature_validation_type`, 'All', {
type: 'select',
values: [
{ key: 'None', i18nLabel: 'SAML_Custom_signature_validation_none' },
{ 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' },
],
i18nLabel: 'SAML_Custom_signature_validation_type',
i18nDescription: 'SAML_Custom_signature_validation_type_description',
});
await this.add(`SAML_Custom_${name}_validate_logout_request_signature`, true, {
type: 'boolean',
i18nLabel: 'SAML_Custom_validate_logout_request_signature',
});
await this.add(`SAML_Custom_${name}_validate_logout_response_signature`, true, {
type: 'boolean',
i18nLabel: 'SAML_Custom_validate_logout_response_signature',
});
await this.add(`SAML_Custom_${name}_private_key`, '', {
type: 'string',
multiline: true,
i18nLabel: 'SAML_Custom_Private_Key',
secret: true,
});
await this.add(`SAML_Custom_${name}_signature_algorithm`, 'SHA1', {
type: 'select',
values: [
{ key: 'SHA1', i18nLabel: 'SHA1' },
{ key: 'SHA256', i18nLabel: 'SHA256' },
{ key: 'SHA384', i18nLabel: 'SHA384' },
{ key: 'SHA512', i18nLabel: 'SHA512' },
],
i18nLabel: 'SAML_Custom_Signature_Algorithm',
i18nDescription: 'SAML_Custom_Signature_Algorithm_description',
secret: true,
});
});
},
);
await this.with(
{
tab: 'SAML_General',
},
async function () {
await this.section('SAML_Section_1_User_Interface', async function () {
await this.add(`SAML_Custom_${name}_button_label_text`, 'SAML', {
type: 'string',
i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text',
});
await this.add(`SAML_Custom_${name}_button_label_color`, '#FFFFFF', {
type: 'string',
i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color',
alert: 'OAuth_button_colors_alert',
});
await this.add(`SAML_Custom_${name}_button_color`, '#1d74f5', {
type: 'string',
i18nLabel: 'Accounts_OAuth_Custom_Button_Color',
alert: 'OAuth_button_colors_alert',
});
});
await this.section('SAML_Section_3_Behavior', async function () {
// Settings to customize behavior
await this.add(`SAML_Custom_${name}_generate_username`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_Generate_Username',
});
await this.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' },
],
i18nLabel: 'SAML_Custom_Username_Normalize',
});
await this.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' },
],
i18nLabel: 'SAML_Custom_Immutable_Property',
});
await this.add(`SAML_Custom_${name}_name_overwrite`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_name_overwrite',
});
await this.add(`SAML_Custom_${name}_mail_overwrite`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_mail_overwrite',
});
await this.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' },
],
i18nLabel: 'SAML_Custom_Logout_Behaviour',
public: true,
});
await this.add(`SAML_Custom_${name}_channels_update`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_channels_update',
i18nDescription: 'SAML_Custom_channels_update_description',
});
await this.add(`SAML_Custom_${name}_include_private_channels_update`, false, {
type: 'boolean',
i18nLabel: 'SAML_Custom_include_private_channels_update',
i18nDescription: 'SAML_Custom_include_private_channels_update_description',
});
await this.add(`SAML_Custom_${name}_default_user_role`, 'user', {
type: 'string',
i18nLabel: 'SAML_Default_User_Role',
i18nDescription: 'SAML_Default_User_Role_Description',
});
await this.add(`SAML_Custom_${name}_allowed_clock_drift`, 0, {
type: 'int',
invalidValue: 0,
i18nLabel: 'SAML_Allowed_Clock_Drift',
i18nDescription: 'SAML_Allowed_Clock_Drift_Description',
});
});
await this.section('SAML_Section_5_Mapping', async function () {
// Data Mapping Settings
await this.add(`SAML_Custom_${name}_user_data_fieldmap`, '{"username":"username", "email":"email", "name": "cn"}', {
type: 'string',
i18nLabel: 'SAML_Custom_user_data_fieldmap',
i18nDescription: 'SAML_Custom_user_data_fieldmap_description',
multiline: true,
});
});
},
);
SAMLUtils.events.emit('addSettings', name);
});
};

View File

@@ -0,0 +1,38 @@
export const signatureAlgorithms = {
'RSA-SHA1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
'RSA-SHA256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
'RSA-SHA384': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384',
'RSA-SHA512': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512',
} as const;
export type SigAlgKey = keyof typeof signatureAlgorithms;
export type SigAlgURI = (typeof signatureAlgorithms)[SigAlgKey];
export function getSigAlgKeyIfSupported(key: SigAlgKey): SigAlgKey;
export function getSigAlgKeyIfSupported(key: string): SigAlgKey | null;
export function getSigAlgKeyIfSupported(key: string): SigAlgKey | null {
if (key in signatureAlgorithms) {
return key as keyof typeof signatureAlgorithms;
}
return null;
}
export function getSigAlgKeyByURI(algURI: SigAlgURI): SigAlgKey;
export function getSigAlgKeyByURI(algURI: string): SigAlgKey | null;
export function getSigAlgKeyByURI(algURI: string): SigAlgKey | null {
for (const [key, uri] of Object.entries(signatureAlgorithms)) {
if (uri === algURI) {
return key as SigAlgKey;
}
}
return null;
}
export function getSigAlgURIByKey(key: SigAlgKey): SigAlgURI;
export function getSigAlgURIByKey(key: string): SigAlgURI | null;
export function getSigAlgURIByKey(key: string): SigAlgURI | null {
return signatureAlgorithms[key as SigAlgKey] ?? null;
}

View File

@@ -0,0 +1,44 @@
import crypto from 'node:crypto';
import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope';
import { SAMLUtils } from '../Utils';
import { getSigAlgKeyByURI } from './signatureAlgorithms';
export function validateRedirectSignature(envelope: SAMLRedirectEnvelope, certificate: string): boolean {
try {
if (!envelope.sigAlg || !envelope.signature) {
SAMLUtils.log({ msg: 'Could not validate SAML Document Signature: Missing Signature params', type: envelope.type });
return false;
}
if (!envelope.signedContent) {
SAMLUtils.log({ msg: 'Could not validate SAML Document signature: signedContent is empty', type: envelope.type });
return false;
}
const algorithm = getSigAlgKeyByURI(envelope.sigAlg);
if (!algorithm) {
SAMLUtils.log({ msg: 'Could not validate SAML Document signature: invalid algorithm', type: envelope.type });
return false;
}
const cert = SAMLUtils.certToPEM(certificate);
SAMLUtils.log({
msg: 'Verifying Signatures for SAML Document',
algorithm,
cert,
type: envelope.type,
});
const verifier = crypto.createVerify(algorithm);
verifier.update(envelope.signedContent, 'utf8');
verifier.end();
return verifier.verify(cert, envelope.signature, 'base64');
} catch (err) {
SAMLUtils.error({ msg: 'Failed to validate SAML Document Signature', err });
}
return false;
}