Files
upstream-mirror 30810eecd7
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 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

404 lines
12 KiB
TypeScript

import { LDAP } from '@rocket.chat/core-services';
import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { DoneCallback } from 'passport';
import type { VerifyFunction, StrategyOptions } from 'passport-oauth2';
import { Strategy } from 'passport-oauth2';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings/cached';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
const logger = new Logger('CustomOAuth');
const BeforeUpdateOrCreateUserFromExternalService = new Map<
string,
(serviceName: string, serviceData: Record<string, any>) => Promise<void>
>();
const CustomOAuthInstances = new Map<string, CustomOAuthStrategy>();
export class CustomOAuthStrategy extends Strategy {
options: StrategyOptions;
config: OAuthConfiguration & { clientSecret: string };
identityPath: string;
emailPath: string;
tokenSentVia: string;
identityTokenSentVia: string;
keyField: string;
usernameField: string;
emailField: string;
nameField: string;
avatarField: string;
mergeUsers: boolean;
mergeUsersDistinctServices: boolean;
rolesClaim: string;
accessTokenParam: string;
serverURL: string;
tokenPath: string;
constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) {
if (!config.serverURL || typeof config.serverURL !== 'string') {
throw new Meteor.Error('customOAuth: serverURL is required and must be String');
}
const options: StrategyOptions = {
authorizationURL: config.serverURL + config.authorizePath,
tokenURL: config.serverURL + config.tokenPath,
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/${name}`,
state: true,
pkce: config.pkce ?? true,
scope: config.scope,
};
super(options, verify);
this.serverURL = config.serverURL;
this.tokenPath = config.tokenPath || '/oauth/token';
this.identityPath = config.identityPath || '/me';
this.tokenSentVia = config.tokenSentVia;
this.identityTokenSentVia = config.identityTokenSentVia;
this.keyField = config.keyField;
this.usernameField = config.usernameField?.trim();
this.emailField = config.emailField?.trim();
this.nameField = config.nameField?.trim();
this.avatarField = config.avatarField?.trim();
this.mergeUsers = !!config.mergeUsers;
this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices;
this.rolesClaim = config.rolesClaim || 'roles';
this.accessTokenParam = config.accessTokenParam || 'access_token';
if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') {
this.identityTokenSentVia = this.tokenSentVia;
}
if (!isAbsoluteURL(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!isAbsoluteURL(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (this.emailPath && !isAbsoluteURL(this.emailPath)) {
this.emailPath = this.serverURL + this.emailPath;
}
if (this.identityTokenSentVia === 'header') {
this._oauth2.useAuthorizationHeaderforGET(true);
}
if (config.accessTokenParam && config.accessTokenParam !== 'access_token') {
this._oauth2.setAccessTokenName(config.accessTokenParam);
}
if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') {
Accounts.addAutopublishFields(config.addAutopublishFields);
}
this.name = name;
this.options = options;
this.config = config;
this.addHookToProcessUser();
}
getUsername(data: Record<string, any>) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data });
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', { cause: error });
}
}
getEmail(data: Record<string, any>) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data });
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', { cause: error });
}
}
getCustomName(data: Record<string, any>) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', { cause: error });
}
}
getAvatarUrl(data: Record<string, any>) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error });
}
}
getName(identity: Record<string, any>): string {
const name = (identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
identity.user?.name) as string;
return name;
}
normalizeIdentity(identity: Record<string, any>) {
if (identity) {
for (const normalizer of Object.values(normalizers)) {
const result = normalizer(identity);
if (result) {
identity = result;
}
}
}
if (this.usernameField) {
identity.username = this.getUsername(identity);
}
if (this.emailField) {
identity.email = this.getEmail(identity);
}
if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
if (this.nameField) {
identity.name = this.getCustomName(identity);
} else {
identity.name = this.getName(identity);
}
return renameInvalidProperties(identity);
}
override userProfile(accessToken: string, done: DoneCallback) {
if (!this.identityPath) {
return done(new Error(`identityPath is required for ${this.name} custom oauth`));
}
this._oauth2.get(this.identityPath, accessToken, (err, body, res) => {
if (err) {
return done(JSON.stringify(err));
}
if ((res && res.statusCode !== 200) || !body) {
return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`));
}
try {
const result = JSON.parse(typeof body === 'string' ? body : body.toString());
const normalizedIdentity = this.normalizeIdentity(result);
//Nextcloud URL needed on addWebdavServer
normalizedIdentity.serverURL = this.serverURL;
return done(null, normalizedIdentity);
} catch (e) {
return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`));
}
});
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.set(this.name, async (serviceName, serviceData) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user: IUser | null = null;
if (this.keyField === 'username') {
user = this.mergeUsersDistinctServices
? await Users.findOneByUsernameIgnoringCase(serviceData.username)
: await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName);
} else if (this.keyField === 'email') {
user = this.mergeUsersDistinctServices
? await Users.findOneByEmailAddress(serviceData.email)
: await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName);
}
if (!user) {
return;
}
await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user });
// User already created or merged and has identical name as before
if (
user.services?.[serviceName as keyof NonNullable<IUser['services']>] &&
user.services[serviceName as keyof NonNullable<IUser['services']>].id === serviceData.id &&
user.name === serviceData.name &&
(this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email))
) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const successCallbacks = [
async () => {
const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } });
if (updatedUser) {
const { _id, ...diff } = updatedUser;
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff });
}
},
];
const session = client.startSession();
try {
// Extend the session to match the ExtendedSession type expected by saveUserIdentity
Object.assign(session, {
onceSuccesfulCommit: (cb: () => Promise<void>) => {
successCallbacks.push(cb);
},
});
session.startTransaction();
const updater = Users.getUpdater();
if (this.keyField === 'username' && serviceData.email) {
updater.set('emails', [{ address: serviceData.email, verified: true }]);
}
updater.set(serviceIdKey as keyof IUser['services'], serviceData.id);
await saveUserIdentity({
_id: user._id,
name: serviceData.name,
updater,
session,
updateUsernameInBackground: true,
// Username needs to be included otherwise the name won't be updated in some collections
username: user.username,
});
await Users.updateFromUpdater({ _id: user._id }, updater, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
void Promise.allSettled(successCallbacks.map((cb) => cb()));
}
});
CustomOAuthInstances.set(this.name, this);
}
}
Accounts.validateNewUser((user: IUser & { email: string }) => {
for (const [name, instance] of CustomOAuthInstances) {
const service = user.services?.[name as keyof NonNullable<IUser['services']>];
if (!service?.id) {
continue;
}
if (instance.usernameField) {
user.username = service.username;
}
if (instance.emailField) {
user.email = service.email;
}
if (instance.nameField) {
user.name = service.name;
}
}
return true;
});
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService.values()) {
await hook.apply(this, args as unknown as [string, Record<string, any>]);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user?.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId as string);
if (!fullUser) {
return undefined;
}
if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};