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,7 @@
# Custom OAuth
An implementation of the OAuth2 flow. It works with your own private OAuth2 server instance.
This software is supplied courtesy of the [Rocket.Chat](https://rocket.chat/) open source communications platform.
See the [project page](https://www.meteor.com/accounts) on Meteor Accounts for more details.

View File

@@ -0,0 +1,403 @@
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;
};

View File

@@ -0,0 +1,7 @@
export class CustomOAuth {
constructor(name: string, options: Record<string, any>);
getIdentity(accessToken: string, query: Record<string, any>): any;
configure(options: Record<string, any>): any;
}

View File

@@ -0,0 +1,554 @@
import { LDAP } from '@rocket.chat/core-services';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
import { registerAccessTokenService } from '../oauth/oauth';
const logger = new Logger('CustomOAuth', { redact: ['options.clientSecret'] });
const Services = {};
const BeforeUpdateOrCreateUserFromExternalService = [];
/**
* @deprecated in favor of new Passport OAuth implementation.
*/
export class CustomOAuth {
constructor(name, options) {
logger.debug({ msg: 'Init CustomOAuth', name, options });
this.name = name;
if (!Match.test(this.name, String)) {
throw new Meteor.Error('CustomOAuth: Name is required and must be String');
}
if (Services[this.name]) {
Services[this.name].configure(options);
return;
}
Services[this.name] = this;
this.configure(options);
this.userAgent = 'Meteor';
if (Meteor.release) {
this.userAgent += `/${Meteor.release}`;
}
Accounts.oauth.registerService(this.name);
this.registerService();
this.addHookToProcessUser();
this.registerAccessTokenService(this.name, this.accessTokenParam);
}
configure(options) {
if (!Match.test(options, Object)) {
throw new Meteor.Error('CustomOAuth: Options is required and must be Object');
}
if (!Match.test(options.serverURL, String)) {
throw new Meteor.Error('CustomOAuth: Options.serverURL is required and must be String');
}
if (!Match.test(options.tokenPath, String)) {
options.tokenPath = '/oauth/token';
}
if (!Match.test(options.identityPath, String)) {
options.identityPath = '/me';
}
if (!Match.test(options.accessTokenParam, String)) {
options.accessTokenParam = 'access_token';
}
this.serverURL = options.serverURL;
this.tokenPath = options.tokenPath;
this.identityPath = options.identityPath;
this.emailPath = options.emailPath;
this.tokenSentVia = options.tokenSentVia;
this.identityTokenSentVia = options.identityTokenSentVia;
this.keyField = options.keyField;
this.usernameField = (options.usernameField || '').trim();
this.emailField = (options.emailField || '').trim();
this.nameField = (options.nameField || '').trim();
this.avatarField = (options.avatarField || '').trim();
this.mergeUsers = options.mergeUsers;
this.mergeUsersDistinctServices = options.mergeUsersDistinctServices;
this.rolesClaim = options.rolesClaim || 'roles';
this.accessTokenParam = options.accessTokenParam;
this.channelsAdmin = options.channelsAdmin || 'rocket.cat';
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 (Match.test(options.addAutopublishFields, Object)) {
Accounts.addAutopublishFields(options.addAutopublishFields);
}
}
async getAccessToken(query) {
const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name });
if (!config) {
throw new Accounts.ConfigError();
}
let response = undefined;
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
const params = new URLSearchParams({
code: query.code,
redirect_uri: OAuth._redirectUri(this.name, config),
grant_type: 'authorization_code',
state: query.state,
});
// Only send clientID / secret once on header or payload.
if (this.tokenSentVia === 'header') {
const b64 = Buffer.from(`${config.clientId}:${OAuth.openSecret(config.secret)}`).toString('base64');
headers.Authorization = `Basic ${b64}`;
} else {
params.append('client_secret', config.secret);
params.append('client_id', config.clientId);
}
try {
const request = await fetch(`${this.tokenPath}`, {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
method: 'POST',
headers,
body: params,
});
if (!request.ok) {
throw new Error(request.statusText);
}
response = await request.json();
} catch (err) {
const error = new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
if (response.error) {
// if the http response was a json object with an error attribute
throw new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${response.error}`);
} else {
return response;
}
}
async getIdentity(accessToken) {
const params = {};
const headers = {
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.identityPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
logger.debug({ msg: 'Identity response', response });
return this.normalizeIdentity(response, accessToken);
} catch (err) {
const error = new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
registerService() {
const self = this;
OAuth.registerService(this.name, 2, null, async (query) => {
const response = await self.getAccessToken(query);
const identity = await self.getIdentity(response.access_token, query);
const serviceData = {
_OAuthCustom: true,
serverURL: self.serverURL,
accessToken: response.access_token,
idToken: response.id_token,
expiresAt: +new Date() + 1000 * parseInt(response.expires_in, 10),
};
// only set the token in serviceData if it's there. this ensures
// that we don't lose old ones (since we only get this on the first
// log in attempt)
if (response.refresh_token) {
serviceData.refreshToken = response.refresh_token;
}
_.extend(serviceData, identity);
const data = {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
return data;
});
}
async normalizeIdentity(identity, accessToken) {
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 (!identity.email && this.emailPath) {
identity.email = await this.getEmailFromPath(accessToken);
}
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);
}
async getEmailFromPath(accessToken) {
if (!this.emailPath) {
throw new Meteor.Error('CustomOAuth: emailPath is required');
}
const params = {};
const headers = {
'User-Agent': this.userAgent,
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.emailPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
return response.find((email) => email.primary === true)?.email;
} catch (err) {
const error = new Error(`Failed to fetch emails from ${this.name} at ${this.emailPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
retrieveCredential(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
}
getUsername(data) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', error.message);
}
}
getEmail(data) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', error.message);
}
}
getCustomName(data) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', error.message);
}
}
getAvatarUrl(data) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', error.message);
}
}
getName(identity) {
const name =
identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
(identity.user && identity.user.name);
return name;
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData /* , options*/) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user = undefined;
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 &&
user.services[serviceName] &&
user.services[serviceName].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) => {
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, 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()));
}
});
Accounts.validateNewUser((user) => {
if (!user.services || !user.services[this.name] || !user.services[this.name].id) {
return true;
}
if (this.usernameField) {
user.username = user.services[this.name].username;
}
if (this.emailField) {
user.email = user.services[this.name].email;
}
if (this.nameField) {
user.name = user.services[this.name].name;
}
return true;
});
}
registerAccessTokenService(name) {
const self = this;
const whitelisted = ['id', 'email', 'username', 'name', this.rolesClaim];
registerAccessTokenService(name, async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
expiresIn: Match.Integer,
}),
);
const identity = await self.getIdentity(options.accessToken);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelisted);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});
}
}
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args /* serviceName, serviceData, options*/) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService) {
await hook.apply(this, args);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId);
if (settings.get('LDAP_Update_Data_On_OAuth_Login')) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};

View File

@@ -0,0 +1,160 @@
import { isObject } from '../../../../lib/utils/isObject';
export const normalizers = {
// Set 'id' to '_id' for any sources that provide it
_id(identity) {
if (identity._id && !identity.id) {
identity.id = identity._id;
}
},
// Fix for Reddit
redit(identity) {
if (identity.result) {
return identity.result;
}
},
// Fix WordPress-like identities having 'ID' instead of 'id'
wordpress(identity) {
if (identity.ID && !identity.id) {
identity.id = identity.ID;
}
},
// Fix Auth0-like identities having 'user_id' instead of 'id'
user_id(identity) {
if (identity.user_id && !identity.id) {
identity.id = identity.user_id;
}
},
characterid(identity) {
if (identity.CharacterID && !identity.id) {
identity.id = identity.CharacterID;
}
},
// Fix Dataporten having 'user.userid' instead of 'id'
dataporten(identity) {
if (identity.user && identity.user.userid && !identity.id) {
if (identity.user.userid_sec && identity.user.userid_sec[0]) {
identity.id = identity.user.userid_sec[0];
} else {
identity.id = identity.user.userid;
}
identity.email = identity.user.email;
}
},
// Fix for Xenforo [BD]API plugin for 'user.user_id; instead of 'id'
xenforo(identity) {
if (identity.user && identity.user.user_id && !identity.id) {
identity.id = identity.user.user_id;
identity.email = identity.user.user_email;
}
},
// Fix general 'phid' instead of 'id' from phabricator
phabricator(identity) {
if (identity.phid && !identity.id) {
identity.id = identity.phid;
}
},
// Fix Keycloak-like identities having 'sub' instead of 'id'
kaycloak(identity) {
if (identity.sub && !identity.id) {
identity.id = identity.sub;
}
},
// Fix OpenShift identities where id is in 'metadata' object
openshift(identity) {
if (!identity.id && identity.metadata && identity.metadata.uid) {
identity.id = identity.metadata.uid;
identity.name = identity.fullName;
}
},
// Fix general 'userid' instead of 'id' from provider
userid(identity) {
if (identity.userid && !identity.id) {
identity.id = identity.userid;
}
},
// Fix Nextcloud provider
nextcloud(identity) {
if (!identity.id && identity.ocs && identity.ocs.data && identity.ocs.data.id) {
identity.id = identity.ocs.data.id;
identity.name = identity.ocs.data.displayname;
identity.email = identity.ocs.data.email;
}
},
// Fix when authenticating from a meteor app with 'emails' field
meteor(identity) {
if (!identity.email && identity.emails && Array.isArray(identity.emails) && identity.emails.length >= 1) {
identity.email = identity.emails[0].address ? identity.emails[0].address : undefined;
}
},
};
const IDENTITY_PROPNAME_FILTER = /(\.)/g;
export const renameInvalidProperties = (input) => {
if (Array.isArray(input)) {
return input.map(renameInvalidProperties);
}
if (!isObject(input)) {
return input;
}
return Object.entries(input).reduce(
(result, [name, value]) => ({
...result,
[name.replace(IDENTITY_PROPNAME_FILTER, '_')]: renameInvalidProperties(value),
}),
{},
);
};
export const getNestedValue = (propertyPath, source) =>
propertyPath.split('.').reduce((prev, curr) => (prev ? prev[curr] : undefined), source);
// /^(.+)@/::email
const REGEXP_FROM_FORMULA = /^\/((?!\/::).*)\/::(.+)/;
export const getRegexpMatch = (formula, data) => {
const regexAndPath = REGEXP_FROM_FORMULA.exec(formula);
if (!regexAndPath) {
return getNestedValue(formula, data);
}
if (regexAndPath.length !== 3) {
throw new Error(`expected array of length 3, got ${regexAndPath.length}`);
}
const [, regexString, path] = regexAndPath;
const nestedValue = getNestedValue(path, data);
const regex = new RegExp(regexString);
const matches = regex.exec(nestedValue);
// regexp does not match nested value
if (!matches) {
return undefined;
}
// we only support regular expressions with a single capture group
const [, value] = matches;
// this could mean we return `undefined` (e.g. when capture group is empty)
return value;
};
const templateStringRegex = /{{((?:(?!}}).)+)}}/g;
export const fromTemplate = (template, data) => {
if (!templateStringRegex.test(template)) {
return getNestedValue(template, data);
}
return template.replace(templateStringRegex, (fullMatch, match) => getRegexpMatch(match, data));
};