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

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

View File

@@ -0,0 +1,57 @@
# Changelog
This is a changelog of changes made to the saml package for RocketChat.
The package is originally based on https://github.com/steffow/meteor-accounts-saml .
## 9-Nov-2016 danb@catalyst-au.net
* Only do `logoutWithSaml` if we are doing single logout (ie
`idpSLORedirectURL` is set). We get an error if we do
`logoutWithSaml` and `idpSLORedirectURL` is not set. This was
discovered when testing with simplesamlphp. In this latter case,
`logoutWithSaml` will try to use the standard saml endpoint to
request the logout resulting in an error.
* **TODO:** doing the standard `Meteor.logout` doesn't seem to be
enough when logging out. If the user logs back into RocketChat from
the same window, they will be automatically logged back in to
RocketChat, no credentials required. If you enable single logout
(ie set `idpSLORedirectURL`), then `logoutWithSaml` will be called
and the client should be properly logged out.
* Switch to using `Cookies` for the client when recording the saml
provider and whether single logout is enabled. If the client has
multiple rocketchat tabs or windows, this will ensure they all
log out properly with the idp - but only when single logout
(`idpSLORedirectURL`) is enabled.
## 31-Oct-2016 danb@catalyst-au.net
* Attempt at SP-initiated logout (**requires more work**).
* Added configuration setting for SLO (single logout) redirect url
to the IDP.
* A `Session` key is set called `saml_provider`; it is unset at
logout. Is there a better way?
* `Meteor.logout` on the client is overridden so that
`Meteor.logoutWithSaml` is called if `saml_provider` is set. This
isn't great - is there a better way?
* Tested using simplesamlphp as the idp
* login in using saml
* then logout using the normal RocketChat logout
* after some time, you should see the RocketChat login screen
* if you try to login again, you will have to login to the idp again
repeating the previous login flow
* **TODO:** If a user has 2 independent browser login sessions using
saml, both will log out, but only the one the user logged out from
appears to be properly logged out of the idp.
* this might be because we shouldn't be logging out the second session
but RocketChat / meteor is doing this.
## 17-Oct-2016
* Fixed signing for redirect to idp.
* At login time, if the idp is configured to verify the redirect to its login screen, it will
expect `RelayState` as part of the signature.
Tested with **simplesamlphp** with `'redirect.validate' => TRUE` in the `sp-remote` metadata file
* Added `privateKey` - `SAML_Custom_Private_Key`
* Added `privateCert` - `SAML_Custom_Public_Cert`
* These input fields should point to locations on the the server eg `/path/to/private/certs/`.
* `privateKey` is used by RocketChat to sign saml requests.

View File

@@ -0,0 +1,127 @@
Meteor-accounts-saml
==================
SAML v2 login support for existing password based accounts
-----
## Demo
For OpenIDP, see the example app `example-openidp` and http://accounts-saml-example.meteor.com/ for a demo.
For OpenAM, see the example app `openam-example`.
## Important Notes
* **This package is working but may have issues with various saml providers** - it has only been tested and verified with [OpenIDP](https://openidp.feide.no/) and [OpenAM](https://www.forgerock.org/openam).
* Most SAML IDPs don't allow SPs with a _localhost (127.0.0.1)_ address. Unless you run your own IDP (eg via your own OpenAM instance) you might exprience issues.
* The accounts-ui loggin buttons will not include saml providers, this may be implemented as a future enhancement, see below for how to build a custom login button.
## Usage
Put SAML settings in eg `server/lib/settings.js` like so:
```
settings = {"saml":[{
"provider":"openam",
"entryPoint":"https://openam.idp.io/openam/SSORedirect/metaAlias/zimt/idp",
"issuer": "https://sp.zimt.io/", //replace with url of your app
"cert":"MIICizCCAfQCCQCY8tKaMc0 LOTS OF FUNNY CHARS ==",
"idpSLORedirectURL": "http://openam.idp.io/openam/IDPSloRedirect/metaAlias/zimt/idp",
"privateKeyFile": "certs/mykey.pem", // path is relative to $METEOR-PROJECT/private
"publicCertFile": "certs/mycert.pem", // eg $METEOR-PROJECT/private/certs/mycert.pem
"dynamicProfile": true // set to true if we want to create a user in Meteor.users dynamically if SAML assertion is valid
"identifierFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", // Defaults to urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
"localProfileMatchAttribute": "telephoneNumber" // CAUTION: this will be mapped to profile.<localProfileMatchAttribute> attribute in Mongo if identifierFormat (see above) differs from urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress,
"attributesSAML": [telephoneNumber, sn, givenName, mail], // attrs from SAML attr statement, which will be used for local Meteor profile creation. Currently no real attribute mapping. If required use mapping on IdP side.
}]}
Meteor.settings = settings;
```
in some template
```
<a href="#" class="saml-login" data-provider="openam">OpenAM</a>
```
in helper function
```
'click .saml-login' (event) {
event.preventDefault();
var provider = event.target.getAttribute('data-provider');
Meteor.loginWithSaml({
provider
}, function(error, result) {
//handle errors and result
});
}
```
and if SingleLogout is needed
```
'click .saml-login': function(event, template){
event.preventDefault();
var provider = $(event.target).data('provider');
Meteor.logoutWithSaml({
provider:provider
}, function(error, result){
//handle errors and result
});
}
```
## Setup SAML SP (Consumer)
1. Create a Meteor project by `meteor create sp` and cd into it.
2. Add `steffo:meteor-accounts-saml`
3. Create `server/lib/settings.js` as described above. Since Meteor loads things in `server/lib` first, this ensures that your settings are respected even on Galaxy where you cannot use `meteor --settings`.
4. Put your private key and your cert (not the IDP's one) into the "private" directory. Eg if your meteor project is at `/Users/steffo/sp` then place them in `/Users/steffo/sp/private`
5. Check if you can receive SP metadata eg via `curl http://localhost:3000/_saml/metadata/openam`. Output should look like:
```
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://localhost:3000/">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MKA.... lot of funny chars ... gkqhkiG9w0BAQUFADATMREwDwYDVQQDEwgxMC4w
</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>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/_saml/validate/openam"/>
</SPSSODescriptor>
</EntityDescriptor>
```
##OpenAM Setup
1. I prefer using OpenAM realms. Set up a realm using a name that matches the one in the entry point URL of the `settings.json` file: `https://openam.idp.io/openam/SSORedirect/metaAlias/<YOURREALM>/idp`; we used `zimt` above.
2. Save the SP metadata (obtained in Step 5 above) in a file `sp-metadata.xml`.
3. Logon OpenSSO console as `amadmin` and select _Common Tasks > Register Remote Service Provider_
4. Select the corresponding real and upload the metadata (alternatively, point OpenAM to the SP's metadata URL eg `http://sp.meteor.com/_saml/metadata/openam`). If all goes well the new SP shows up under _Federation > Entity Providers_
## OpenIDP setup
- EntryID = http://accounts-saml-example.meteor.com
- Name of Service = meteor-accounts-saml-example
- AssertionConsumerService endpoint = http://accounts-saml-example.meteor.com/_saml/validate/openidp/
## Roadmap
* Introduction of IDP types (eg openam, auth0 etc) to support implementaion specific workarounds.
* Support for different SAML Bindings
* Check for better/alternative SAML profile. I have the impression that the SAML Web Browser SSO profile somewhat conflicts with Meteor's DDP/websocket approach. Eg when the browser issues an HTTP request, Meteor apps don't necessarily know from which user/session this request comes from (ja, we could use cookies but that's not the the Meteor-way).
## Credits
Based Nat Strauser's Meteor/SAML package _natestrauser:meteor-accounts-saml_ which is
heavily derived from https://github.com/bergie/passport-saml.

View File

@@ -0,0 +1,16 @@
export interface IAttributeMapping {
fieldName: string | Array<string>;
regex?: string;
template?: string;
}
export interface IUserDataMap {
attributeList: Set<string>;
identifier: {
type: string;
attribute?: string;
};
email: IAttributeMapping;
username: IAttributeMapping;
name: IAttributeMapping;
}

View File

@@ -0,0 +1,10 @@
export interface IAuthorizeRequestVariables extends Record<string, string> {
newId: string;
instant: string;
callbackUrl: string;
entryPoint: string;
issuer: string;
identifierFormat: string;
authnContextComparison: string;
authnContext: string;
}

View File

@@ -0,0 +1,9 @@
export interface ILogoutRequestVariables extends Record<string, string> {
newId: string;
instant: string;
idpSLORedirectURL: string;
issuer: string;
identifierFormat: string;
nameID: string;
sessionIndex: string;
}

View File

@@ -0,0 +1,5 @@
export interface ILogoutResponse {
id: string;
response: string;
inResponseToId: string;
}

View File

@@ -0,0 +1,10 @@
export interface ILogoutResponseVariables extends Record<string, string> {
newId: string;
instant: string;
idpSLORedirectURL: string;
issuer: string;
identifierFormat: string;
nameID: string;
sessionIndex: string;
inResponseToId: string;
}

View File

@@ -0,0 +1,7 @@
export interface IMetadataVariables extends Record<string, string> {
issuer: string;
certificate: string;
identifierFormat: string;
callbackUrl: string;
sloLocation: string;
}

View File

@@ -0,0 +1,5 @@
export interface ISAMLAction {
actionName: string;
serviceName: string;
credentialToken: string;
}

View File

@@ -0,0 +1,4 @@
export interface ISAMLAssertion {
assertion: Element;
xml: string;
}

View File

@@ -0,0 +1,11 @@
export interface ISAMLGlobalSettings {
generateUsername: boolean;
nameOverwrite: boolean;
mailOverwrite: boolean;
immutableProperty: string;
defaultUserRole: string;
userDataFieldMap: string;
usernameNormalize: string;
channelsAttributeUpdate: boolean;
includePrivateChannelsInUpdate: boolean;
}

View File

@@ -0,0 +1,4 @@
export interface ISAMLRequest {
id: string;
request: string;
}

View File

@@ -0,0 +1,22 @@
export interface ISAMLUser {
emailList: Array<string>;
fullName: string | null;
roles?: Array<string>;
eppn: string | null;
username?: string;
language?: string;
channels?: Array<string>;
samlLogin: {
provider: string | null;
idp: string;
idpSession: string;
nameID: string;
};
attributeList: Map<string, any>;
identifier: {
type: string;
attribute?: string;
};
}

View File

@@ -0,0 +1,28 @@
import type { SAMLSignatureAlgorithm } from '@rocket.chat/core-typings';
export interface IServiceProviderOptions {
provider: string;
entryPoint: string;
idpSLORedirectURL: string;
issuer: string;
cert: string;
privateCert: string;
privateKey: string;
signatureAlgorithm: SAMLSignatureAlgorithm;
customAuthnContext: string;
authnContextComparison: string;
defaultUserRole: string;
allowedClockDrift: number;
signatureValidationType: string;
validateLogoutRequestSignature: boolean;
validateLogoutResponseSignature: boolean;
identifierFormat: string;
nameIDPolicyTemplate: string;
authnContextTemplate: string;
authRequestTemplate: string;
logoutResponseTemplate: string;
logoutRequestTemplate: string;
metadataCertificateTemplate: string;
metadataTemplate: string;
callbackUrl: string;
}

View File

@@ -0,0 +1,43 @@
// Other binding values are not supported: HTTP-Artifact, URI, SOAP, PAOS
export type SAMLBinding = 'HTTP-Redirect' | 'HTTP-POST';
export type SAMLDocumentType = 'SAMLRequest' | 'SAMLResponse';
export type SAMLBaseEnvelope<T extends SAMLDocumentType = SAMLDocumentType> = {
binding: SAMLBinding;
/**
* encodedDocument is the document in the exact format we received it
* */
encodedDocument: string;
/**
* decodedDocument is the raw XML file; processed according to the binding specification
* */
decodedDocument: string;
type: T;
relayState?: string;
};
/**
* HTTP-Redirect envelope
* For this binding, the document must be a Deflated XML file that is then base64 encoded
**/
export type SAMLRedirectEnvelope<T extends SAMLDocumentType = SAMLDocumentType> = SAMLBaseEnvelope<T> & {
binding: 'HTTP-Redirect';
sigAlg?: string;
signature?: string;
/**
* signedContent is the complete string value that must be matched by the signature
* */
signedContent?: string;
};
/**
* HTTP-POST envelope
* For this binding, the document must be a regular XML file that is base64 encoded
**/
export type SAMLPOSTEnvelope<T extends SAMLDocumentType = SAMLDocumentType> = SAMLBaseEnvelope<T> & {
binding: 'HTTP-POST';
};
export type SAMLEnvelope<T extends SAMLDocumentType = SAMLDocumentType> = SAMLRedirectEnvelope<T> | SAMLPOSTEnvelope<T>;

View File

@@ -0,0 +1,11 @@
export interface ILogoutRequestValidateCallback {
(err: string | object | null, data?: Record<string, string | null> | null): Promise<void>;
}
export interface ILogoutResponseValidateCallback {
(err: string | object | null, inResponseTo?: string | null): Promise<void>;
}
export interface IResponseValidateCallback {
(err: string | object | null, profile?: Record<string, any> | null, loggedOut?: boolean): void;
}

View File

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

View File

@@ -0,0 +1,199 @@
import crypto from 'node:crypto';
import querystring from 'node:querystring';
import util from 'node:util';
import zlib from 'node:zlib';
import { Meteor } from 'meteor/meteor';
import { SAMLUtils } from './Utils';
import type { ILogoutResponse } from '../definition/ILogoutResponse';
import type { ISAMLRequest } from '../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
import type { ILogoutRequestValidateCallback, ILogoutResponseValidateCallback, IResponseValidateCallback } from '../definition/callbacks';
import { AuthorizeRequest } from './generators/AuthorizeRequest';
import { LogoutRequest } from './generators/LogoutRequest';
import { LogoutResponse } from './generators/LogoutResponse';
import { ServiceProviderMetadata } from './generators/ServiceProviderMetadata';
import { LogoutRequestParser } from './parsers/LogoutRequest';
import { LogoutResponseParser } from './parsers/LogoutResponse';
import { ResponseParser } from './parsers/Response';
import type { SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../definition/SAMLEnvelope';
import { getSigAlgKeyIfSupported, type SigAlgKey, signatureAlgorithms } from './signature/signatureAlgorithms';
export class SAMLServiceProvider {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
if (!serviceProviderOptions) {
throw new Error('SAMLServiceProvider instantiated without an options object');
}
this.serviceProviderOptions = serviceProviderOptions;
}
private getSignatureAlgorithm(): SigAlgKey {
const algorithm = `RSA-${this.serviceProviderOptions.signatureAlgorithm}`;
return getSigAlgKeyIfSupported(algorithm) || 'RSA-SHA256';
}
private maybeSignRequest(samlObject: Record<string, any>): Record<string, any> {
if (!this.serviceProviderOptions.privateKey) {
return samlObject;
}
const algorithm = this.getSignatureAlgorithm();
const alg = signatureAlgorithms[algorithm];
const xml = querystring.stringify({ ...samlObject, SigAlg: alg });
const signer = crypto.createSign(algorithm);
signer.update(xml);
const signature = signer.sign(this.serviceProviderOptions.privateKey, 'base64');
return {
...samlObject,
SigAlg: alg,
Signature: signature,
};
}
public generateAuthorizeRequest(credentialToken: string): string {
const identifiedRequest = AuthorizeRequest.generate(this.serviceProviderOptions, credentialToken);
return identifiedRequest.request;
}
public generateLogoutResponse({
nameID,
sessionIndex,
inResponseToId,
}: {
nameID: string;
sessionIndex: string;
inResponseToId: string;
}): ILogoutResponse {
return LogoutResponse.generate(this.serviceProviderOptions, nameID, sessionIndex, inResponseToId);
}
public generateLogoutRequest({ nameID, sessionIndex }: { nameID: string; sessionIndex: string }): ISAMLRequest {
return LogoutRequest.generate(this.serviceProviderOptions, nameID, sessionIndex);
}
/*
This method will generate the response URL with all the query string params and pass it to the callback
*/
public logoutResponseToUrl(
response: string,
relayState: string | undefined,
callback: (err: string | object | null, url?: string) => void,
): void {
zlib.deflateRaw(response, (err, buffer) => {
if (err) {
return callback(err);
}
try {
const base64 = buffer.toString('base64');
let target = this.serviceProviderOptions.idpSLORedirectURL;
if (target.indexOf('?') > 0) {
target += '&';
} else {
target += '?';
}
// The SAML spec requires the response RelayState to exactly match the value received on the request.
const samlResponse = this.maybeSignRequest({
SAMLResponse: base64,
...(relayState !== undefined && { RelayState: relayState }),
});
target += querystring.stringify(samlResponse);
return callback(null, target);
} catch (error) {
return callback(error instanceof Error ? error : String(error));
}
});
}
/*
This method will generate the request URL with all the query string params and pass it to the callback
*/
public async requestToUrl(request: string, operation: string, loginClient?: string): Promise<string | undefined> {
const buffer = await util.promisify(zlib.deflateRaw)(request);
try {
const base64 = buffer.toString('base64');
let target = this.serviceProviderOptions.entryPoint;
if (operation === 'logout') {
if (this.serviceProviderOptions.idpSLORedirectURL) {
target = this.serviceProviderOptions.idpSLORedirectURL;
}
}
if (target.indexOf('?') > 0) {
target += '&';
} else {
target += '?';
}
// TBD. We should really include a proper RelayState here
let relayState;
if (operation === 'logout') {
// in case of logout we want to be redirected back to the Meteor app.
relayState = Meteor.absoluteUrl();
} else {
relayState = SAMLUtils.encodeAuthorizeRelayState(this.serviceProviderOptions.provider, loginClient);
}
const samlRequest = this.maybeSignRequest({
SAMLRequest: base64,
RelayState: relayState,
});
target += querystring.stringify(samlRequest);
SAMLUtils.log(`requestToUrl: ${target}`);
if (operation === 'logout') {
// in case of logout we want to be redirected back to the Meteor app.
return target;
}
return target;
} catch (error) {
throw error instanceof Error ? error : String(error);
}
}
public async getAuthorizeUrl(credentialToken: string, loginClient?: string): Promise<string | undefined> {
const request = this.generateAuthorizeRequest(credentialToken);
SAMLUtils.log({ request, msg: 'getAuthorizeUrl' });
return this.requestToUrl(request, 'authorize', loginClient);
}
public async validateLogoutRequest(
envelope: SAMLRedirectEnvelope<'SAMLRequest'>,
callback: ILogoutRequestValidateCallback,
): Promise<void> {
const parser = new LogoutRequestParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public async validateLogoutResponse(
envelope: SAMLRedirectEnvelope<'SAMLResponse'>,
callback: ILogoutResponseValidateCallback,
): Promise<void> {
const parser = new LogoutResponseParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public validateResponse(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void {
const parser = new ResponseParser(this.serviceProviderOptions);
return parser.validate(envelope, callback);
}
public generateServiceProviderMetadata(): string {
return ServiceProviderMetadata.generate(this.serviceProviderOptions);
}
}

View File

@@ -0,0 +1,519 @@
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import zlib from 'node:zlib';
import type { Logger } from '@rocket.chat/logger';
import { StatusCode } from './constants';
import { ensureArray } from '../../../../lib/utils/arrayUtils';
import type { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping';
import type { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings';
import type { ISAMLUser } from '../definition/ISAMLUser';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
let providerList: Array<IServiceProviderOptions> = [];
let debug = false;
let relayState: string | null = null;
let logger: Logger | undefined;
const globalSettings: ISAMLGlobalSettings = {
generateUsername: false,
nameOverwrite: false,
mailOverwrite: false,
immutableProperty: 'EMail',
defaultUserRole: 'user',
userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}',
usernameNormalize: 'None',
channelsAttributeUpdate: false,
includePrivateChannelsInUpdate: false,
};
export class SAMLUtils {
public static events: EventEmitter;
public static get isDebugging(): boolean {
return debug;
}
public static get globalSettings(): ISAMLGlobalSettings {
return globalSettings;
}
public static get serviceProviders(): Array<IServiceProviderOptions> {
return providerList;
}
public static get relayState(): string | null {
return relayState;
}
public static set relayState(value: string | null) {
relayState = value;
}
public static get logger(): Logger | undefined {
return logger;
}
public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined {
this.log({ providerName, providerList });
return providerList.find((providerOptions) => providerOptions.provider === providerName);
}
public static setServiceProvidersList(list: Array<IServiceProviderOptions>): void {
providerList = list;
}
public static setLoggerInstance(instance: Logger): void {
logger = instance;
}
// TODO: Some of those should probably not be global
public static updateGlobalSettings(samlConfigs: Record<string, any>): void {
debug = Boolean(samlConfigs.debug);
globalSettings.generateUsername = Boolean(samlConfigs.generateUsername);
globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite);
globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite);
globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate);
globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate);
globalSettings.defaultUserRole = samlConfigs.defaultUserRole;
if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') {
globalSettings.immutableProperty = samlConfigs.immutableProperty;
}
if (samlConfigs.usernameNormalize && typeof samlConfigs.usernameNormalize === 'string') {
globalSettings.usernameNormalize = samlConfigs.usernameNormalize;
}
if (samlConfigs.userDataFieldMap && typeof samlConfigs.userDataFieldMap === 'string') {
globalSettings.userDataFieldMap = samlConfigs.userDataFieldMap;
}
}
public static generateUniqueID(): string {
const chars = 'abcdef0123456789';
let uniqueID = 'id-';
for (let i = 0; i < 20; i++) {
uniqueID += chars.substr(Math.floor(Math.random() * 15), 1);
}
return uniqueID;
}
public static generateInstant(): string {
return new Date().toISOString();
}
public static certToPEM(cert: string): string {
const lines = cert.match(/.{1,64}/g);
if (!lines) {
throw new Error('Invalid Certificate');
}
lines.splice(0, 0, '-----BEGIN CERTIFICATE-----');
lines.push('-----END CERTIFICATE-----');
return lines.join('\n');
}
public static isParsableCertificate(cert: string): boolean {
try {
void new crypto.X509Certificate(this.certToPEM(cert));
return true;
} catch {
return false;
}
}
public static fillTemplateData(template: string, data: Record<string, string>): string {
let newTemplate = template;
for (const variable in data) {
if (variable in data) {
const key = `__${variable}__`;
while (newTemplate.includes(key)) {
newTemplate = newTemplate.replace(key, data[variable]);
}
}
}
return newTemplate;
}
public static getValidationActionRedirectPath(credentialToken: string): string {
// the saml_idp_credentialToken param is needed by the mobile app
return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`;
}
public static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' {
return value === 'desktop' || value === 'mobile';
}
public static encodeAuthorizeRelayState(provider: string, loginClient?: string): string {
if (!this.isSupportedLoginClient(loginClient)) {
return provider;
}
return new URLSearchParams({ provider, loginClient }).toString();
}
public static decodeAuthorizeRelayState(relayState?: string | null): { provider?: string; loginClient?: 'desktop' | 'mobile' } {
if (!relayState) {
return {};
}
if (relayState.startsWith('provider=') && relayState.includes('&loginClient=')) {
const params = new URLSearchParams(relayState);
const provider = params.get('provider') ?? undefined;
const loginClient = params.get('loginClient');
return {
provider,
loginClient: this.isSupportedLoginClient(loginClient) ? loginClient : undefined,
};
}
return { provider: relayState };
}
public static log(obj: object | string): void {
if (debug && logger) {
logger.debug(obj);
}
}
public static error(obj: object | string): void {
if (logger) {
logger.error(obj);
}
}
public static warn(obj: object | string): void {
if (logger) {
logger.warn(obj);
}
}
public static async inflateXml(deflatedXml: Buffer<ArrayBuffer>): Promise<Buffer<ArrayBuffer>> {
return new Promise((resolve, reject) => {
zlib.inflateRaw(deflatedXml, (err, inflatedXml) => {
if (err) {
this.log({ msg: 'Error while inflating.', err });
return reject(err);
}
if (!inflatedXml) {
return reject(new Error('Failed to extract request data'));
}
resolve(Buffer.from(inflatedXml));
});
});
}
public static validateStatus(doc: Document): {
success: boolean;
message: string;
statusCode: string;
} {
let successStatus = false;
let status = null;
let messageText = '';
const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode');
if (statusNodes.length) {
const statusNode = statusNodes[0];
const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0];
if (statusMessage?.firstChild?.textContent) {
messageText = statusMessage.firstChild.textContent;
}
status = statusNode.getAttribute('Value');
if (status === StatusCode.success) {
successStatus = true;
}
}
return {
success: successStatus,
message: messageText,
statusCode: status || '',
};
}
public static normalizeCert(cert: string): string {
if (!cert) {
return cert;
}
return cert
.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '')
.replace(/-+END CERTIFICATE-+\r?\n?/, '')
.replace(/\r\n/g, '\n')
.trim();
}
public static getUserDataMapping(): IUserDataMap {
const { userDataFieldMap, immutableProperty } = globalSettings;
let map: Record<string, any>;
try {
map = JSON.parse(userDataFieldMap);
} catch (err) {
SAMLUtils.log({ userDataFieldMap, err });
throw new Error('Failed to parse custom user field map');
}
const parsedMap: IUserDataMap = {
attributeList: new Set(),
email: {
fieldName: 'email',
},
username: {
fieldName: 'username',
},
name: {
fieldName: 'cn',
},
identifier: {
type: '',
},
};
let identifier = immutableProperty.toLowerCase();
for (const spFieldName in map) {
if (!map.hasOwnProperty(spFieldName)) {
continue;
}
const attribute = map[spFieldName];
if (typeof attribute !== 'string' && typeof attribute !== 'object') {
throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`);
}
if (spFieldName === '__identifier__') {
if (typeof attribute !== 'string') {
throw new Error('SAML User Map: Invalid identifier.');
}
identifier = attribute;
continue;
}
let attributeMap: IAttributeMapping | null = null;
// If it's a complex type, let's check what's in it
if (typeof attribute === 'object') {
// A fieldName is mandatory for complex fields. If it's missing, let's skip this one.
if (!attribute.hasOwnProperty('fieldName') && !attribute.hasOwnProperty('fieldNames')) {
continue;
}
const fieldName = attribute.fieldName || attribute.fieldNames;
const { regex, template } = attribute;
if (Array.isArray(fieldName)) {
if (!fieldName.length) {
throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`);
}
for (const idpFieldName of fieldName) {
parsedMap.attributeList.add(idpFieldName);
}
} else {
parsedMap.attributeList.add(fieldName);
}
if (regex && typeof regex !== 'string') {
throw new Error('SAML User Map: Invalid RegEx');
}
if (template && typeof template !== 'string') {
throw new Error('SAML User Map: Invalid Template');
}
attributeMap = {
fieldName,
...(regex && { regex }),
...(template && { template }),
};
} else if (typeof attribute === 'string') {
attributeMap = {
fieldName: attribute,
};
parsedMap.attributeList.add(attribute);
}
if (attributeMap) {
if (spFieldName === 'email' || spFieldName === 'username' || spFieldName === 'name') {
parsedMap[spFieldName] = attributeMap;
}
}
}
if (identifier) {
const defaultTypes = ['email', 'username'];
if (defaultTypes.includes(identifier)) {
parsedMap.identifier.type = identifier;
} else {
parsedMap.identifier.type = 'custom';
parsedMap.identifier.attribute = identifier;
parsedMap.attributeList.add(identifier);
}
}
return parsedMap;
}
public static getProfileValue(profile: Record<string, any>, mapping: IAttributeMapping, forceString = false): any {
const values: Record<string, string> = {
regex: '',
};
const fieldNames = ensureArray<string>(mapping.fieldName);
let mainValue;
for (const fieldName of fieldNames) {
let profileValue = profile[fieldName];
if (Array.isArray(profileValue)) {
for (let i = 0; i < profile[fieldName].length; i++) {
// Add every index to the list of possible values to be used, both first to last and from last to first
values[`${fieldName}[${i}]`] = profileValue[i];
values[`${fieldName}[-${Math.abs(0 - profileValue.length + i)}]`] = profileValue[i];
}
values[`${fieldName}[]`] = profileValue.join(' ');
if (forceString) {
profileValue = profileValue.join(' ');
}
} else {
values[fieldName] = profileValue;
}
values[fieldName] = profileValue;
if (!mainValue) {
mainValue = profileValue;
}
}
let shouldRunTemplate = false;
if (typeof mapping.template === 'string') {
// unless the regex result is used on the template, we process the template first
if (mapping.template.includes('__regex__')) {
shouldRunTemplate = true;
} else {
mainValue = this.fillTemplateData(mapping.template, values);
}
}
if (mapping.regex && mainValue?.match) {
let regexValue;
const match = mainValue.match(new RegExp(mapping.regex));
if (match?.length) {
if (match.length >= 2) {
regexValue = match[1];
} else {
regexValue = match[0];
}
}
if (regexValue) {
values.regex = regexValue;
if (!shouldRunTemplate) {
mainValue = regexValue;
}
}
}
if (shouldRunTemplate && typeof mapping.template === 'string') {
mainValue = this.fillTemplateData(mapping.template, values);
}
return mainValue;
}
public static normalizeUsername(name: string): string {
const { globalSettings } = this;
switch (globalSettings.usernameNormalize) {
case 'Lowercase':
name = name.toLowerCase();
break;
}
return name;
}
public static mapProfileToUserObject(profile: Record<string, any>): ISAMLUser {
const userDataMap = this.getUserDataMapping();
SAMLUtils.log({ msg: 'Mapping SAML Profile to User Object', userDataMap });
if (userDataMap.identifier.type === 'custom') {
if (!userDataMap.identifier.attribute) {
throw new Error('SAML User Data Map: invalid Identifier configuration received.');
}
if (!profile[userDataMap.identifier.attribute]) {
throw new Error(`SAML Profile did not have the expected identifier (${userDataMap.identifier.attribute}).`);
}
}
const attributeList = new Map();
for (const attributeName of userDataMap.attributeList) {
if (profile[attributeName] === undefined) {
this.log({ msg: 'SAML user profile is missing the attribute.', attribute: attributeName });
continue;
}
attributeList.set(attributeName, profile[attributeName]);
}
const email = this.getProfileValue(profile, userDataMap.email);
const profileUsername = this.getProfileValue(profile, userDataMap.username, true);
const name = this.getProfileValue(profile, userDataMap.name, true);
// Even if we're not using the email to identify the user, it is still mandatory because it's a mandatory information on Rocket.Chat
if (!email) {
throw new Error('SAML Profile did not contain an email address');
}
const userObject: ISAMLUser = {
samlLogin: {
provider: this.relayState,
idp: profile.issuer,
idpSession: profile.sessionIndex,
nameID: profile.nameID,
},
emailList: ensureArray<string>(email),
fullName: name || profile.displayName || profile.username,
eppn: profile.eppn,
attributeList,
identifier: userDataMap.identifier,
};
if (profileUsername) {
userObject.username = this.normalizeUsername(profileUsername);
}
if (profile.language) {
userObject.language = profile.language;
}
if (profile.channels) {
if (Array.isArray(profile.channels)) {
userObject.channels = profile.channels;
} else {
userObject.channels = profile.channels.split(',');
}
}
this.events.emit('mapUser', { profile, userObject });
return userObject;
}
}
SAMLUtils.events = new EventEmitter();

View File

@@ -0,0 +1,53 @@
export const defaultAuthnContextTemplate = `<samlp:RequestedAuthnContext xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Comparison="__authnContextComparison__">
<saml:AuthnContextClassRef xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
__authnContext__
</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>`;
export const defaultAuthRequestTemplate = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__newId__" Version="2.0" IssueInstant="__instant__" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="__callbackUrl__" Destination="__entryPoint__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
__identifierFormatTag__
__authnContextTag__
</samlp:AuthnRequest>`;
export const defaultLogoutResponseTemplate = `<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__inResponseToId__" Version="2.0" IssueInstant="__instant__" Destination="__idpSLORedirectURL__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
</samlp:LogoutResponse>`;
export const defaultLogoutRequestTemplate = `<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="__newId__" Version="2.0" IssueInstant="__instant__" Destination="__idpSLORedirectURL__">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">__issuer__</saml:Issuer>
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" NameQualifier="http://id.init8.net:8080/openam" SPNameQualifier="__issuer__" Format="__identifierFormat__">__nameID__</saml:NameID>
<samlp:SessionIndex xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">__sessionIndex__</samlp:SessionIndex>
</samlp:LogoutRequest>`;
export const defaultMetadataCertificateTemplate = `
<KeyDescriptor>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>__certificate__</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
</KeyDescriptor>`;
export const defaultMetadataTemplate = `<?xml version="1.0"?>
<EntityDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:oasis:names:tc:SAML:2.0:metadata https://docs.oasis-open.org/security/saml/v2.0/saml-schema-metadata-2.0.xsd" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="__issuer__">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">__certificateTag__
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="__sloLocation__" ResponseLocation="__sloLocation__"/>
<NameIDFormat>__identifierFormat__</NameIDFormat>
<AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="__callbackUrl__"/>
</SPSSODescriptor>
</EntityDescriptor>`;
export const defaultNameIDTemplate =
'<samlp:NameIDPolicy xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Format="__identifierFormat__" AllowCreate="true"></samlp:NameIDPolicy>';
export const defaultIdentifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress';
export const defaultAuthnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport';
export const StatusCode = {
success: 'urn:oasis:names:tc:SAML:2.0:status:Success',
responder: 'urn:oasis:names:tc:SAML:2.0:status:Responder',
};

View File

@@ -0,0 +1,81 @@
import type { IAuthorizeRequestVariables } from '../../definition/IAuthorizeRequestVariables';
import type { ISAMLRequest } from '../../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import {
defaultIdentifierFormat,
defaultAuthnContext,
defaultAuthRequestTemplate,
defaultNameIDTemplate,
defaultAuthnContextTemplate,
} from '../constants';
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions): string | undefined;
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue: string): string;
function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue?: string): string | undefined {
const value = serviceProviderOptions.customAuthnContext.trim();
return value.length > 0 ? value : defaultValue;
}
/*
An Authorize Request is used to show the Identity Provider login form when the user clicks on the Rocket.Chat SAML login button
*/
export class AuthorizeRequest {
public static generate(serviceProviderOptions: IServiceProviderOptions, credentialToken: string): ISAMLRequest {
const data = this.getDataForNewRequest(serviceProviderOptions, credentialToken);
const request = SAMLUtils.fillTemplateData(this.authorizeRequestTemplate(serviceProviderOptions), data);
return {
request,
id: data.newId,
};
}
// The AuthorizeRequest template is split into three parts
// This way, users don't need to change the template when all they want to do is remove the NameID Policy or the AuthnContext.
// This also ensures compatibility with providers that were configured before the templates existed.
private static authorizeRequestTemplate(serviceProviderOptions: IServiceProviderOptions): string {
const data = {
identifierFormatTag: this.identifierFormatTagTemplate(serviceProviderOptions),
authnContextTag: this.authnContextTagTemplate(serviceProviderOptions),
};
const template = serviceProviderOptions.authRequestTemplate || defaultAuthRequestTemplate;
return SAMLUtils.fillTemplateData(template, data);
}
private static identifierFormatTagTemplate(serviceProviderOptions: IServiceProviderOptions): string {
if (!serviceProviderOptions.identifierFormat) {
return '';
}
return serviceProviderOptions.nameIDPolicyTemplate || defaultNameIDTemplate;
}
private static authnContextTagTemplate(serviceProviderOptions: IServiceProviderOptions): string {
if (!resolveCustomAuthnContext(serviceProviderOptions)) {
return '';
}
return serviceProviderOptions.authnContextTemplate || defaultAuthnContextTemplate;
}
private static getDataForNewRequest(
serviceProviderOptions: IServiceProviderOptions,
credentialToken?: string,
): IAuthorizeRequestVariables {
const id = credentialToken || `_${SAMLUtils.generateUniqueID()}`;
const instant = SAMLUtils.generateInstant();
return {
newId: id,
instant,
callbackUrl: serviceProviderOptions.callbackUrl,
entryPoint: serviceProviderOptions.entryPoint,
issuer: serviceProviderOptions.issuer,
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
authnContextComparison: serviceProviderOptions.authnContextComparison || 'exact',
authnContext: resolveCustomAuthnContext(serviceProviderOptions, defaultAuthnContext),
};
}
}

View File

@@ -0,0 +1,45 @@
import type { ILogoutRequestVariables } from '../../definition/ILogoutRequestVariables';
import type { ISAMLRequest } from '../../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import { SAMLUtils } from '../Utils';
import { defaultIdentifierFormat, defaultLogoutRequestTemplate } from '../constants';
/*
A Logout Request is used when the user is logged out of Rocket.Chat and the Service Provider is configured to also logout from the Identity Provider.
*/
export class LogoutRequest {
static generate(serviceProviderOptions: IServiceProviderOptions, nameID: string, sessionIndex: string): ISAMLRequest {
const data = this.getDataForNewRequest(serviceProviderOptions, nameID, sessionIndex);
const request = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutRequestTemplate || defaultLogoutRequestTemplate, data);
SAMLUtils.log({ request, msg: '------- SAML Logout request -----------' });
return {
request,
id: data.newId,
};
}
static getDataForNewRequest(
serviceProviderOptions: IServiceProviderOptions,
nameID: string,
sessionIndex: string,
): ILogoutRequestVariables {
// nameId: <nameId as submitted during SAML SSO>
// sessionIndex: sessionIndex
// --- NO SAMLsettings: <Meteor.setting.saml entry for the provider you want to SLO from
const id = `_${SAMLUtils.generateUniqueID()}`;
const instant = SAMLUtils.generateInstant();
return {
newId: id,
instant,
idpSLORedirectURL: serviceProviderOptions.idpSLORedirectURL,
issuer: serviceProviderOptions.issuer,
identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat,
nameID,
sessionIndex,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
import xmldom from '@xmldom/xmldom';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope';
import type { ILogoutRequestValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { validateRedirectSignature } from '../signature/validateRedirectSignature';
export class LogoutRequestParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public async validate(envelope: SAMLRedirectEnvelope<'SAMLRequest'>, callback: ILogoutRequestValidateCallback): Promise<void> {
const { decodedDocument: xmlString } = envelope;
SAMLUtils.log({ msg: 'Validating SAML Logout Request', xmlString });
if (!this.verifySignature(envelope)) {
SAMLUtils.log({ msg: 'Failed to verify signature from Logout Request' });
return callback('Signature validation failed');
}
const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml');
if (!doc) {
return callback('No Doc Found');
}
const request = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutRequest')[0];
if (!request) {
return callback('No Request Found');
}
try {
const sessionNode = request.getElementsByTagNameNS('*', 'SessionIndex')[0];
const nameIdNode = request.getElementsByTagNameNS('*', 'NameID')[0];
if (!nameIdNode) {
throw new Error('SAML Logout Request: No NameID node found');
}
const idpSession = sessionNode.childNodes[0].nodeValue;
const nameID = nameIdNode.childNodes[0].nodeValue;
const id = request.getAttribute('ID');
return callback(null, { idpSession, nameID, id });
} catch (err) {
SAMLUtils.error({ err });
const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage');
SAMLUtils.log({ msg: `Unexpected msg from IDP. Does your session still exist at IDP?`, statusMessage });
return callback(err instanceof Error ? err : String(err), null);
}
}
private verifySignature(envelope: SAMLRedirectEnvelope<'SAMLRequest'>): boolean {
if (!this.serviceProviderOptions.validateLogoutRequestSignature) {
return true;
}
return validateRedirectSignature(envelope, this.serviceProviderOptions.cert);
}
}

View File

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

View File

@@ -0,0 +1,531 @@
import xmldom from '@xmldom/xmldom';
import { SignedXml } from 'xml-crypto';
import type { Reference } from 'xml-crypto';
import xmlenc from 'xml-encryption';
import type { ISAMLAssertion } from '../../definition/ISAMLAssertion';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLPOSTEnvelope } from '../../definition/SAMLEnvelope';
import type { IResponseValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { StatusCode } from '../constants';
type XmlParent = Element | Document;
export class ResponseParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public validate(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void {
const { decodedDocument: xml } = envelope;
// We currently use RelayState to save SAML provider
SAMLUtils.log({ msg: 'Validating SAML Response', xml });
let error: Error | null = null;
const doc = new xmldom.DOMParser({
errorHandler: {
fatalError: (e: any) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
error: (e: Error) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
},
}).parseFromString(xml, 'text/xml');
if (!doc) {
return callback('No Doc Found');
}
if (error) {
return callback(error, null, false);
}
const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response');
if (allResponses.length === 0) {
return this._checkLogoutResponse(doc, callback);
}
if (allResponses.length !== 1) {
return callback(new Error('Too many SAML responses'), null, false);
}
const response = allResponses[0];
if (response !== doc.documentElement) {
return callback(new Error('SAML Response must be the document root'), null, false);
}
SAMLUtils.log('Got response');
SAMLUtils.log('Verify status');
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
if (!statusValidateObj.statusCode) {
return callback(new Error('Missing StatusCode'), null, false);
}
if (statusValidateObj.statusCode === StatusCode.responder && statusValidateObj.message) {
return callback(new Error(statusValidateObj.message), null, false);
}
return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
}
SAMLUtils.log('Status ok');
let assertion: Element;
let assertionData: ISAMLAssertion;
let issuer;
try {
assertionData = this.getAssertion(response, xml);
assertion = assertionData.assertion;
this.verifySignatures(response, assertionData, xml);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
const profile: Record<string, any> = {};
if (response.hasAttribute('InResponseTo')) {
profile.inResponseToId = response.getAttribute('InResponseTo');
}
try {
issuer = this.getIssuer(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
if (issuer) {
profile.issuer = issuer.textContent;
}
if (assertion.hasAttribute('ID')) {
profile.assertionId = assertion.getAttribute('ID');
}
const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
if (conditions?.hasAttribute('NotOnOrAfter')) {
const notOnOrAfter = conditions.getAttribute('NotOnOrAfter');
if (notOnOrAfter) {
profile.expireAt = new Date(notOnOrAfter);
}
}
const subject = this.getSubject(assertion);
if (subject) {
const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0];
if (nameID) {
profile.nameID = nameID.textContent;
if (nameID.hasAttribute('Format')) {
profile.nameIDFormat = nameID.getAttribute('Format');
}
}
try {
this.validateSubjectConditions(subject);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
}
try {
this.validateAssertionConditions(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0];
if (authnStatement) {
if (authnStatement.hasAttribute('SessionIndex')) {
profile.sessionIndex = authnStatement.getAttribute('SessionIndex');
SAMLUtils.log({ msg: 'Session Index Found', sessionIndex: profile.sessionIndex });
} else {
SAMLUtils.log('No Session Index Found');
}
} else {
SAMLUtils.log('No AuthN Statement found');
}
const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0];
if (attributeStatement) {
this.mapAttributes(attributeStatement, profile);
} else {
SAMLUtils.log('No Attribute Statement found in SAML response.');
}
if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) {
profile.email = profile.nameID;
}
const profileKeys = Object.keys(profile);
for (let i = 0; i < profileKeys.length; i++) {
const key = profileKeys[i];
if (key.match(/\./)) {
profile[key.replace(/\./g, '-')] = profile[key];
delete profile[key];
}
}
SAMLUtils.log({ msg: 'NameID', profile });
return callback(null, profile, false);
}
private _checkLogoutResponse(doc: Document, callback: IResponseValidateCallback): void {
const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse');
if (!logoutResponse.length) {
return callback(new Error('Unknown SAML response message'), null, false);
}
SAMLUtils.log('Verify status');
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
}
SAMLUtils.log('Status ok');
// @ToDo: Check if this situation is still used
return callback(null, null, true);
}
private getAssertion(response: Element, xml: string): ISAMLAssertion {
const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion');
if (allAssertions.length + allEncrypedAssertions.length > 1) {
throw new Error('Too many SAML assertions');
}
let assertion: Element = allAssertions[0];
const encAssertion = allEncrypedAssertions[0];
let newXml = null;
if (typeof encAssertion !== 'undefined') {
// disallowDecryptionWithInsecureAlgorithm defaults to true in xml-encryption v4, but AES-CBC/3DES
// are still widely used by SAML IdPs in practice, so we keep the pre-v4 behaviour here.
const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false };
const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0];
xmlenc.decrypt(encData, options, (err, result) => {
if (err) {
SAMLUtils.error(err);
}
const document = new xmldom.DOMParser().parseFromString(result, 'text/xml');
if (!document) {
throw new Error('Failed to decrypt SAML assertion');
}
const decryptedAssertions = document.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
if (decryptedAssertions.length) {
assertion = decryptedAssertions[0];
}
newXml = result;
});
}
if (!assertion) {
throw new Error('Missing SAML assertion');
}
return {
assertion,
xml: newXml || xml,
};
}
private verifySignatures(response: Element, assertionData: ISAMLAssertion, xml: string): void {
const signatureType = this.serviceProviderOptions.signatureValidationType;
const checkEither = signatureType === 'Either';
const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither;
const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither;
let anyValidSignature = false;
if (!this.serviceProviderOptions.cert) {
if (checkResponse || checkAssertion) {
SAMLUtils.log('Missing Signature validation params');
throw new Error('Unable to validate signature');
}
return;
}
if (checkResponse) {
SAMLUtils.log('Verify Document Signature');
if (!this.validateResponseSignature(xml, this.serviceProviderOptions.cert, response)) {
if (!checkEither) {
SAMLUtils.log('Document Signature WRONG');
throw new Error('Invalid Signature');
}
} else {
anyValidSignature = true;
}
SAMLUtils.log('Document Signature OK');
}
if (checkAssertion) {
SAMLUtils.log('Verify Assertion Signature');
if (!this.validateAssertionSignature(assertionData.xml, this.serviceProviderOptions.cert, assertionData.assertion)) {
if (!checkEither) {
SAMLUtils.log('Assertion Signature WRONG');
throw new Error('Invalid Assertion signature');
}
} else {
anyValidSignature = true;
}
SAMLUtils.log('Assertion Signature OK');
}
if (checkEither && !anyValidSignature) {
SAMLUtils.log('No Valid Signature');
throw new Error('No valid SAML Signature found');
}
}
private validateResponseSignature(xml: string, cert: string, response: Element): boolean {
return this.validateSignatureChildren(xml, cert, response);
}
private validateAssertionSignature(xml: string, cert: string, assertion: Element): boolean {
return this.validateSignatureChildren(xml, cert, assertion);
}
private validateSignatureChildren(xml: string, cert: string, parent: Element): boolean {
const signatures = parent.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature');
let signature = null;
for (let i = 0; i < signatures.length; i++) {
const sign = signatures[i];
if (sign.parentNode !== parent) {
continue;
}
// Too many signatures
if (signature) {
SAMLUtils.log('Failed to validate SAML signature: Too Many Signatures');
return false;
}
signature = sign;
}
if (!signature) {
SAMLUtils.log('Failed to validate SAML signature: Signature not found');
return false;
}
return this.validateSignature(xml, cert, signature, this.getElementId(parent));
}
private getElementId(element: Element): string | null {
return element.getAttribute('ID') || element.getAttribute('Id') || element.getAttribute('id');
}
private signatureCoversElement(sig: SignedXml, expectedId: string | null): boolean {
if (!expectedId) {
return false;
}
const references: Reference[] = sig.getReferences() || [];
if (references.length === 0) {
return false;
}
return references.every((reference: Reference) => {
// `signedReference` is only populated for references that were part of the successfully
// validated signature, so its presence proves this reference was actually covered.
if (reference.signedReference == null) {
return false;
}
const uri = reference.uri || '';
const referencedId = uri.charAt(0) === '#' ? uri.substring(1) : uri;
return referencedId === expectedId;
});
}
private validateSignature(xml: string, cert: string, signature: Element, expectedId: string | null): boolean {
const sig = new SignedXml({ publicCert: Buffer.from(SAMLUtils.certToPEM(cert)) });
sig.loadSignature(signature);
try {
if (!sig.checkSignature(xml)) {
SAMLUtils.log(sig.getReferences().map((reference) => reference.validationError?.message));
return false;
}
} catch (e) {
SAMLUtils.log(e instanceof Error ? e.message : String(e));
return false;
}
if (!this.signatureCoversElement(sig, expectedId)) {
SAMLUtils.log('Failed to validate SAML signature: signed reference does not match the processed element');
return false;
}
return true;
}
private getIssuer(assertion: Element): any {
const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer');
if (issuers.length > 1) {
throw new Error('Too many Issuers');
}
return issuers[0];
}
private getSubject(assertion: Element): XmlParent {
let subject: XmlParent = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0];
const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0];
if (typeof encSubject !== 'undefined') {
const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false };
xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => {
if (err) {
SAMLUtils.error({ err });
}
subject = new xmldom.DOMParser().parseFromString(result, 'text/xml');
});
}
return subject;
}
private validateSubjectConditions(subject: XmlParent): void {
const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0];
if (subjectConfirmation) {
const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion',
'SubjectConfirmationData',
)[0];
if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) {
throw new Error('NotBefore / NotOnOrAfter assertion failed');
}
}
}
private validateNotBeforeNotOnOrAfterAssertions(element: Element): boolean {
const sysnow = new Date();
const allowedclockdrift = this.serviceProviderOptions.allowedClockDrift || 0;
const now = new Date(sysnow.getTime() + allowedclockdrift);
if (element.hasAttribute('NotBefore')) {
const notBefore: string | null = element.getAttribute('NotBefore');
if (!notBefore) {
return false;
}
const date = new Date(notBefore);
if (now < date) {
return false;
}
}
if (element.hasAttribute('NotOnOrAfter')) {
const notOnOrAfter: string | null = element.getAttribute('NotOnOrAfter');
if (!notOnOrAfter) {
return false;
}
const date = new Date(notOnOrAfter);
if (now >= date) {
return false;
}
}
return true;
}
private validateAssertionConditions(assertion: Element): void {
const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) {
throw new Error('NotBefore / NotOnOrAfter assertion failed');
}
}
private mapAttributes(attributeStatement: Element, profile: Record<string, any>): void {
SAMLUtils.log({ msg: 'Attribute Statement found, mapping attributes to profile.', attributeStatement });
const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute');
SAMLUtils.log({ msg: 'Attributes will be processed', count: attributes.length });
if (attributes) {
for (let i = 0; i < attributes.length; i++) {
const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue');
let value;
if (values.length === 1) {
value = values[0].textContent;
} else {
value = [];
for (let j = 0; j < values.length; j++) {
value.push(values[j].textContent);
}
}
const key = attributes[i].getAttribute('Name');
if (key) {
SAMLUtils.log({ msg: 'Mapping attribute to profile', attribute: key, value });
profile[key] = value;
}
}
} else {
SAMLUtils.log('No Attributes found in SAML attribute statement.');
}
if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
// See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs
profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3'];
}
if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) {
profile.email = profile['urn:oid:1.2.840.113549.1.9.1'];
}
if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) {
profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241'];
}
if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) {
profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6'];
}
if (!profile.email && profile.mail) {
profile.email = profile.mail;
}
if (!profile.cn && profile['urn:oid:2.5.4.3']) {
profile.cn = profile['urn:oid:2.5.4.3'];
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import bodyParser from 'body-parser';
import express from 'express';
import { Meteor } from 'meteor/meteor';
import { RoutePolicy } from 'meteor/routepolicy';
import { WebApp } from 'meteor/webapp';
import type { ISAMLAction } from './definition/ISAMLAction';
import { SAML } from './lib/SAML';
import { SAMLUtils } from './lib/Utils';
import { SystemLogger } from '../logger/system';
RoutePolicy.declare('/_saml/', 'network');
const samlUrlToObject = function (url: string | undefined): ISAMLAction | null {
// req.url will be '/_saml/<action>/<service name>/<credentialToken>'
if (!url) {
return null;
}
const splitUrl = url.split('?');
const splitPath = splitUrl[0].split('/');
// Any non-saml request will continue down the default
// middlewares.
if (splitPath[1] !== '_saml') {
return null;
}
const result = {
actionName: splitPath[2],
serviceName: splitPath[3],
credentialToken: splitPath[4],
};
SAMLUtils.log(result);
return result;
};
const middleware = async function (req: express.Request, res: ServerResponse, next: (err?: any) => void): Promise<void> {
// Make sure to catch any exceptions because otherwise we'd crash
// the runner
try {
const samlObject = samlUrlToObject(req.originalUrl);
if (!samlObject?.serviceName) {
next();
return;
}
if (!samlObject.actionName) {
throw new Error('Missing SAML action');
}
const service = SAMLUtils.getServiceProviderOptions(samlObject.serviceName);
if (!service) {
SystemLogger.error({
msg: 'SAML service provider not found',
serviceName: samlObject.serviceName,
});
throw new Error('SAML Service Provider not found.');
}
await SAML.processRequest(req, res, service, samlObject);
} catch (err) {
// @ToDo: Ideally we should send some error message to the client, but there's no way to do it on a redirect right now.
SystemLogger.error({ err });
if (!res.headersSent) {
const url = Meteor.absoluteUrl('home');
res.writeHead(302, {
Location: url,
});
res.end();
}
}
};
// Listen to incoming SAML http requests
// In Meteor 3 connectHandlers is an express app (multi-middleware use), but
// @types/meteor still types it as connect.Server (1-2 args).
(WebApp.connectHandlers as unknown as express.Application).use(
/^\/_saml/,
bodyParser.json(),
express.urlencoded({
extended: true,
limit: '50mb',
}),
async (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) => middleware(req as express.Request, res, next),
);

View File

@@ -0,0 +1,68 @@
import { CredentialTokens } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { i18n } from '../i18n';
import { SAML } from './lib/SAML';
import { SAMLUtils } from './lib/Utils';
import { SystemLogger } from '../logger/system';
import { warnUnlicensedAuthService } from '../premiumAuthDeprecation';
const makeError = (message: string): Record<string, any> => ({
type: 'saml',
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, message),
});
Accounts.registerLoginHandler('saml', async (loginRequest) => {
if (
!loginRequest.saml ||
!loginRequest.credentialToken ||
typeof loginRequest.credentialToken !== 'string' ||
SAMLUtils.serviceProviders.length === 0
) {
return undefined;
}
warnUnlicensedAuthService('SAML', 'saml-enterprise');
const loginResult = await SAML.retrieveCredential(loginRequest.credentialToken);
await CredentialTokens.removeById(loginRequest.credentialToken);
SAMLUtils.log({ msg: 'RESULT', loginResult });
if (!loginResult) {
return makeError('No matching login attempt found');
}
if (!loginResult.profile) {
return makeError('No profile information found');
}
try {
const userObject = SAMLUtils.mapProfileToUserObject(loginResult.profile);
const updatedUser = await SAML.insertOrUpdateSAMLUser(userObject);
SAMLUtils.events.emit('updateCustomFields', loginResult, updatedUser);
return updatedUser;
} catch (err: any) {
SystemLogger.error({ err });
let message = err.toString();
let errorCode = '';
if (err instanceof Meteor.Error) {
errorCode = (err.error || err.message) as string;
} else if (err instanceof Error) {
errorCode = err.message;
}
if (errorCode) {
const localizedMessage = i18n.t(errorCode);
if (localizedMessage && localizedMessage !== errorCode) {
message = localizedMessage;
}
}
return makeError(message);
}
});

View File

@@ -0,0 +1,16 @@
import { Logger } from '@rocket.chat/logger';
import debounce from 'lodash.debounce';
import { Meteor } from 'meteor/meteor';
import { SAMLUtils } from './lib/Utils';
import { loadSamlServiceProviders, addSettings } from './lib/settings';
import { settings } from '../../settings';
const logger = new Logger('steffo:meteor-accounts-saml');
SAMLUtils.setLoggerInstance(logger);
Meteor.startup(async () => {
await addSettings('Default');
});
settings.watchByRegex(/^SAML_.+/, debounce(loadSamlServiceProviders, 2000));