Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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