Skip to content
8 changes: 4 additions & 4 deletions apps/meteor/app/custom-oauth/client/CustomOAuth.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { capitalize } from '@rocket.chat/string-helpers';
import { Accounts } from 'meteor/accounts-base';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
import { ServiceConfiguration } from 'meteor/service-configuration';

import type { IOAuthProvider } from '../../../client/definitions/IOAuthProvider';
import { overrideLoginMethod, type LoginCallback } from '../../../client/lib/2fa/overrideLoginMethod';
import { loginServices } from '../../../client/lib/loginServices';
import { createOAuthTotpLoginMethod } from '../../../client/meteorOverrides/login/oauth';
import { isURL } from '../../../lib/utils/isURL';

Expand Down Expand Up @@ -86,7 +86,7 @@ export class CustomOAuth implements IOAuthProvider {
options: Meteor.LoginWithExternalServiceOptions = {},
credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void,
) {
const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name });
const config = await loginServices.loadLoginService<OAuthConfiguration>(this.name);
if (!config) {
if (credentialRequestCompleteCallback) {
credentialRequestCompleteCallback(new Accounts.ConfigError());
Expand All @@ -95,7 +95,7 @@ export class CustomOAuth implements IOAuthProvider {
}

const credentialToken = Random.secret();
const loginStyle = OAuth._loginStyle(this.name, config, options);
const loginStyle = OAuth._loginStyle(this.name, config);

const separator = this.authorizePath.indexOf('?') !== -1 ? '&' : '?';

Expand Down
147 changes: 147 additions & 0 deletions apps/meteor/client/lib/loginServices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { LoginServiceConfiguration } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { capitalize } from '@rocket.chat/string-helpers';
import type { LoginService } from '@rocket.chat/ui-contexts';

import { sdk } from '../../app/utils/client/lib/SDKClient';

type LoginServicesEvents = {
changed: undefined;
loaded: LoginServiceConfiguration[];
};

type LoadState = 'loaded' | 'loading' | 'error' | 'none';

const maxRetries = 3;
const timeout = 10000;

class LoginServices extends Emitter<LoginServicesEvents> {
private retries = 0;

private services: LoginServiceConfiguration[] = [];

private serviceButtons: LoginService[] = [];

private state: LoadState = 'none';

private config: Record<string, Partial<LoginService>> = {
'apple': { title: 'Apple', icon: 'apple' },
'facebook': { title: 'Facebook', icon: 'facebook' },
'twitter': { title: 'Twitter', icon: 'twitter' },
'google': { title: 'Google', icon: 'google' },
'github': { title: 'Github', icon: 'github' },
'github_enterprise': { title: 'Github Enterprise', icon: 'github' },
'gitlab': { title: 'Gitlab', icon: 'gitlab' },
'dolphin': { title: 'Dolphin', icon: 'dophin' },
'drupal': { title: 'Drupal', icon: 'drupal' },
'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' },
'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' },
'meteor-developer': { title: 'Meteor', icon: 'meteor' },
'wordpress': { title: 'WordPress', icon: 'wordpress' },
'linkedin': { title: 'Linkedin', icon: 'linkedin' },
};

private setServices(state: LoadState, services: LoginServiceConfiguration[]) {
this.services = services;
this.state = state;

this.generateServiceButtons();

if (state === 'loaded') {
this.retries = 0;
this.emit('loaded', services);
}
}

private generateServiceButtons(): void {
const filtered = this.services.filter((config) => !('showButton' in config) || config.showButton !== false) || [];
const sorted = filtered.sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2));
this.serviceButtons = sorted.map((service) => {
// Remove the appId attribute if present
const { appId: _, ...serviceData } = {
...service,
appId: undefined,
};

// Get the hardcoded title and icon, or fallback to capitalizing the service name
const serviceConfig = this.config[service.service] || {
title: capitalize(service.service),
};

return {
...serviceData,
...serviceConfig,
};
});

this.emit('changed');
}

public getLoginService<T extends Partial<LoginServiceConfiguration> = LoginServiceConfiguration>(serviceName: string): T | undefined {
if (!this.ready) {
return;
}

return this.services.find(({ service }) => service === serviceName) as T | undefined;
}

public async loadLoginService<T extends Partial<LoginServiceConfiguration> = LoginServiceConfiguration>(
serviceName: string,
): Promise<T | undefined> {
if (this.ready) {
return this.getLoginService<T>(serviceName);
}

return new Promise((resolve, reject) => {
this.onLoad(() => resolve(this.getLoginService<T>(serviceName)));

setTimeout(() => reject(new Error('LoadLoginService timeout')), timeout);
});
}

public get ready() {
return this.state === 'loaded';
}

public getLoginServiceButtons(): LoginService[] {
if (!this.ready) {
if (this.state === 'none') {
void this.loadServices();
}
}

return this.serviceButtons;
}

public onLoad(callback: (services: LoginServiceConfiguration[]) => void) {
if (this.ready) {
return callback(this.services);
}

void this.loadServices();
this.once('loaded', callback);
}

public async loadServices(): Promise<void> {
if (this.state === 'error') {
if (this.retries >= maxRetries) {
return;
}
this.retries++;
} else if (this.state !== 'none') {
return;
}

try {
this.state = 'loading';
const { configurations } = await sdk.rest.get('/v1/service.configurations');

this.setServices('loaded', configurations);
} catch (e) {
this.setServices('error', []);
throw e;
}
}
}

export const loginServices = new LoginServices();
52 changes: 52 additions & 0 deletions apps/meteor/client/lib/wrapRequestCredentialFn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Accounts } from 'meteor/accounts-base';
import type { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';

import { loginServices } from './loginServices';

type RequestCredentialOptions = Meteor.LoginWithExternalServiceOptions;
type RequestCredentialCallback = (credentialTokenOrError?: string | Error) => void;

type RequestCredentialConfig<T extends Partial<OAuthConfiguration>> = {
config: T;
loginStyle: string;
options: RequestCredentialOptions;
credentialRequestCompleteCallback?: RequestCredentialCallback;
};

export function wrapRequestCredentialFn<T extends Partial<OAuthConfiguration>>(
serviceName: string,
fn: (params: RequestCredentialConfig<T>) => void,
) {
const wrapped = async (
options: RequestCredentialOptions,
credentialRequestCompleteCallback?: RequestCredentialCallback,
): Promise<void> => {
const config = await loginServices.loadLoginService<T>(serviceName);
if (!config) {
credentialRequestCompleteCallback?.(new Accounts.ConfigError());
return;
}

const loginStyle = OAuth._loginStyle(serviceName, config, options);
fn({
config,
loginStyle,
options,
credentialRequestCompleteCallback,
});
};

return (
options?: RequestCredentialOptions | RequestCredentialCallback,
credentialRequestCompleteCallback?: RequestCredentialCallback,
) => {
if (!credentialRequestCompleteCallback && typeof options === 'function') {
void wrapped({}, options);
return;
}

void wrapped(options as RequestCredentialOptions, credentialRequestCompleteCallback);
};
}
45 changes: 45 additions & 0 deletions apps/meteor/client/meteorOverrides/login/facebook.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,56 @@
import type { FacebookOAuthConfiguration } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { Facebook } from 'meteor/facebook-oauth';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';

import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod';
import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn';
import { createOAuthTotpLoginMethod } from './oauth';

const { loginWithFacebook } = Meteor;
const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook);
Meteor.loginWithFacebook = (options, callback) => {
overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP);
};

Facebook.requestCredential = wrapRequestCredentialFn<FacebookOAuthConfiguration>(
'facebook',
({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => {
const options = requestOptions as Meteor.LoginWithExternalServiceOptions & {
absoluteUrlOptions?: Record<string, any>;
params?: Record<string, any>;
auth_type?: string;
};

const credentialToken = Random.secret();
const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent);
const display = mobile ? 'touch' : 'popup';

const scope = options?.requestPermissions ? options.requestPermissions.join(',') : 'email';

const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '17.0';

const loginUrlParameters: Record<string, any> = {
client_id: config.appId,
redirect_uri: OAuth._redirectUri('facebook', config, options.params, options.absoluteUrlOptions),
display,
scope,
state: OAuth._stateParam(loginStyle, credentialToken, options?.redirectUrl),
// Handle authentication type (e.g. for force login you need auth_type: "reauthenticate")
...(options.auth_type && { auth_type: options.auth_type }),
};

const loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?${Object.keys(loginUrlParameters)
.map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`)
.join('&')}`;

OAuth.launchLogin({
loginService: 'facebook',
loginStyle,
loginUrl,
credentialRequestCompleteCallback,
credentialToken,
});
},
);
31 changes: 31 additions & 0 deletions apps/meteor/client/meteorOverrides/login/github.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import { Random } from '@rocket.chat/random';
import { Accounts } from 'meteor/accounts-base';
import { Github } from 'meteor/github-oauth';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';

import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod';
import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn';
import { createOAuthTotpLoginMethod } from './oauth';

const { loginWithGithub } = Meteor;
const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(Github);
Meteor.loginWithGithub = (options, callback) => {
overrideLoginMethod(loginWithGithub, [options], callback, loginWithGithubAndTOTP);
};

Github.requestCredential = wrapRequestCredentialFn('github', ({ config, loginStyle, options, credentialRequestCompleteCallback }) => {
const credentialToken = Random.secret();
const scope = options?.requestPermissions || ['user:email'];
const flatScope = scope.map(encodeURIComponent).join('+');

let allowSignup = '';
if (Accounts._options?.forbidClientAccountCreation) {
allowSignup = '&allow_signup=false';
}

const loginUrl =
`https://github.com/login/oauth/authorize` +
`?client_id=${config.clientId}` +
`&scope=${flatScope}` +
`&redirect_uri=${OAuth._redirectUri('github', config)}` +
`&state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}${allowSignup}`;

OAuth.launchLogin({
loginService: 'github',
loginStyle,
loginUrl,
credentialRequestCompleteCallback,
credentialToken,
popupOptions: { width: 900, height: 450 },
});
});
Loading