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
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:
615
apps/meteor/server/lib/saml/lib/SAML.ts
Normal file
615
apps/meteor/server/lib/saml/lib/SAML.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
199
apps/meteor/server/lib/saml/lib/ServiceProvider.ts
Normal file
199
apps/meteor/server/lib/saml/lib/ServiceProvider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
519
apps/meteor/server/lib/saml/lib/Utils.ts
Normal file
519
apps/meteor/server/lib/saml/lib/Utils.ts
Normal 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();
|
||||
53
apps/meteor/server/lib/saml/lib/constants.ts
Normal file
53
apps/meteor/server/lib/saml/lib/constants.ts
Normal 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',
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
45
apps/meteor/server/lib/saml/lib/generators/LogoutRequest.ts
Normal file
45
apps/meteor/server/lib/saml/lib/generators/LogoutRequest.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
apps/meteor/server/lib/saml/lib/generators/LogoutResponse.ts
Normal file
49
apps/meteor/server/lib/saml/lib/generators/LogoutResponse.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}/`,
|
||||
};
|
||||
}
|
||||
}
|
||||
120
apps/meteor/server/lib/saml/lib/getSAMLEnvelope.ts
Normal file
120
apps/meteor/server/lib/saml/lib/getSAMLEnvelope.ts
Normal 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;
|
||||
}
|
||||
141
apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts
Normal file
141
apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
apps/meteor/server/lib/saml/lib/parsers/LogoutRequest.ts
Normal file
66
apps/meteor/server/lib/saml/lib/parsers/LogoutRequest.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
apps/meteor/server/lib/saml/lib/parsers/LogoutResponse.ts
Normal file
66
apps/meteor/server/lib/saml/lib/parsers/LogoutResponse.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
531
apps/meteor/server/lib/saml/lib/parsers/Response.ts
Normal file
531
apps/meteor/server/lib/saml/lib/parsers/Response.ts
Normal 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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
362
apps/meteor/server/lib/saml/lib/settings.ts
Normal file
362
apps/meteor/server/lib/saml/lib/settings.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user