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