diff --git a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts index 58c4142d134..1d57d1969d9 100644 --- a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts +++ b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts @@ -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'; @@ -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(this.name); if (!config) { if (credentialRequestCompleteCallback) { credentialRequestCompleteCallback(new Accounts.ConfigError()); @@ -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 ? '&' : '?'; diff --git a/apps/meteor/client/lib/loginServices.ts b/apps/meteor/client/lib/loginServices.ts new file mode 100644 index 00000000000..ad5ee926ccc --- /dev/null +++ b/apps/meteor/client/lib/loginServices.ts @@ -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 { + private retries = 0; + + private services: LoginServiceConfiguration[] = []; + + private serviceButtons: LoginService[] = []; + + private state: LoadState = 'none'; + + private config: Record> = { + '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 = LoginServiceConfiguration>(serviceName: string): T | undefined { + if (!this.ready) { + return; + } + + return this.services.find(({ service }) => service === serviceName) as T | undefined; + } + + public async loadLoginService = LoginServiceConfiguration>( + serviceName: string, + ): Promise { + if (this.ready) { + return this.getLoginService(serviceName); + } + + return new Promise((resolve, reject) => { + this.onLoad(() => resolve(this.getLoginService(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 { + 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(); diff --git a/apps/meteor/client/lib/wrapRequestCredentialFn.ts b/apps/meteor/client/lib/wrapRequestCredentialFn.ts new file mode 100644 index 00000000000..12102187de3 --- /dev/null +++ b/apps/meteor/client/lib/wrapRequestCredentialFn.ts @@ -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> = { + config: T; + loginStyle: string; + options: RequestCredentialOptions; + credentialRequestCompleteCallback?: RequestCredentialCallback; +}; + +export function wrapRequestCredentialFn>( + serviceName: string, + fn: (params: RequestCredentialConfig) => void, +) { + const wrapped = async ( + options: RequestCredentialOptions, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ): Promise => { + const config = await loginServices.loadLoginService(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); + }; +} diff --git a/apps/meteor/client/meteorOverrides/login/facebook.ts b/apps/meteor/client/meteorOverrides/login/facebook.ts index 09875021238..72a91775818 100644 --- a/apps/meteor/client/meteorOverrides/login/facebook.ts +++ b/apps/meteor/client/meteorOverrides/login/facebook.ts @@ -1,7 +1,11 @@ +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; @@ -9,3 +13,44 @@ const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook); Meteor.loginWithFacebook = (options, callback) => { overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP); }; + +Facebook.requestCredential = wrapRequestCredentialFn( + 'facebook', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + absoluteUrlOptions?: Record; + params?: Record; + 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 = { + 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, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/github.ts b/apps/meteor/client/meteorOverrides/login/github.ts index 15e514ab6d5..2a1aa390331 100644 --- a/apps/meteor/client/meteorOverrides/login/github.ts +++ b/apps/meteor/client/meteorOverrides/login/github.ts @@ -1,7 +1,11 @@ +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; @@ -9,3 +13,30 @@ 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 }, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts index 149f55b00ac..2742cade15d 100644 --- a/apps/meteor/client/meteorOverrides/login/google.ts +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -1,8 +1,11 @@ +import { Random } from '@rocket.chat/random'; import { Accounts } from 'meteor/accounts-base'; import { Google } from 'meteor/google-oauth'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; declare module 'meteor/accounts-base' { @@ -10,6 +13,7 @@ declare module 'meteor/accounts-base' { namespace Accounts { export const _options: { restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; }; } } @@ -70,3 +74,53 @@ const loginWithGoogleAndTOTP = ( Meteor.loginWithGoogle = (options, callback) => { overrideLoginMethod(loginWithGoogle, [options], callback, loginWithGoogleAndTOTP); }; + +Google.requestCredential = wrapRequestCredentialFn( + 'google', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + prompt?: string; + }; + + const scope = ['email', ...(options.requestPermissions || ['profile'])].join(' '); + + const loginUrlParameters: Record = { + ...options.loginUrlParameters, + ...(options.requestOfflineToken !== undefined && { + access_type: options.requestOfflineToken ? 'offline' : 'online', + }), + ...((options.prompt || options.forceApprovalPrompt) && { prompt: options.prompt || 'consent' }), + ...(options.loginHint && { login_hint: options.loginHint }), + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }; + + Object.assign(loginUrlParameters, { + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }); + const loginUrl = `https://accounts.google.com/o/oauth2/auth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'google', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { height: 600 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/linkedin.ts b/apps/meteor/client/meteorOverrides/login/linkedin.ts index 0f309ee360f..a10b8182fee 100644 --- a/apps/meteor/client/meteorOverrides/login/linkedin.ts +++ b/apps/meteor/client/meteorOverrides/login/linkedin.ts @@ -1,8 +1,12 @@ +import type { LinkedinOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { Linkedin } from 'meteor/pauli:linkedin-oauth'; import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; declare module 'meteor/meteor' { @@ -16,3 +20,29 @@ const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(Linkedin); Meteor.loginWithLinkedin = (options, callback) => { overrideLoginMethod(loginWithLinkedin, [options], callback, loginWithLinkedinAndTOTP); }; + +Linkedin.requestCredential = wrapRequestCredentialFn( + 'linkedin', + ({ options, credentialRequestCompleteCallback, config, loginStyle }) => { + const credentialToken = Random.secret(); + + const { requestPermissions } = options; + const scope = (requestPermissions || ['openid', 'email', 'profile']).join('+'); + + const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ + config.clientId + }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; + + OAuth.launchLogin({ + credentialRequestCompleteCallback, + credentialToken, + loginService: 'linkedin', + loginStyle, + loginUrl, + popupOptions: { + width: 390, + height: 628, + }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts index 9577194f404..56823fee6b6 100644 --- a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts +++ b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; +import { OAuth } from 'meteor/oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithMeteorDeveloperAccount } = Meteor; @@ -9,3 +11,33 @@ const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(Meteor Meteor.loginWithMeteorDeveloperAccount = (options, callback) => { overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], callback, loginWithMeteorDeveloperAccountAndTOTP); }; + +MeteorDeveloperAccounts.requestCredential = wrapRequestCredentialFn( + 'meteor-developer', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + + const credentialToken = Random.secret(); + + let loginUrl = + `${MeteorDeveloperAccounts._server}/oauth2/authorize?` + + `state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}` + + `&response_type=code&` + + `client_id=${config.clientId}${options.details ? `&details=${options.details}` : ''}`; + + if (options.loginHint) { + loginUrl += `&user_email=${encodeURIComponent(options.loginHint)}`; + } + + loginUrl += `&redirect_uri=${OAuth._redirectUri('meteor-developer', config)}`; + + OAuth.launchLogin({ + loginService: 'meteor-developer', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 497, height: 749 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/saml.ts b/apps/meteor/client/meteorOverrides/login/saml.ts index 8972cfe4812..dd8b04c4006 100644 --- a/apps/meteor/client/meteorOverrides/login/saml.ts +++ b/apps/meteor/client/meteorOverrides/login/saml.ts @@ -1,9 +1,10 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { type LoginCallback, callLoginMethod, handleLogin } from '../../lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../lib/loginServices'; declare module 'meteor/meteor' { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -40,7 +41,8 @@ const { logout } = Meteor; Meteor.logout = async function (...args) { const { sdk } = await import('../../../app/utils/client/lib/SDKClient'); - const samlService = await ServiceConfiguration.configurations.findOneAsync({ service: 'saml' }); + // #TODO: Use SAML settings directly instead of relying on the login service + const samlService = await loginServices.loadLoginService('saml'); if (samlService) { const provider = (samlService.clientConfig as { provider?: string } | undefined)?.provider; if (provider) { diff --git a/apps/meteor/client/meteorOverrides/login/twitter.ts b/apps/meteor/client/meteorOverrides/login/twitter.ts index 955277b1ce5..e19ce234e5e 100644 --- a/apps/meteor/client/meteorOverrides/login/twitter.ts +++ b/apps/meteor/client/meteorOverrides/login/twitter.ts @@ -1,7 +1,11 @@ +import type { TwitterOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; import { Twitter } from 'meteor/twitter-oauth'; import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; const { loginWithTwitter } = Meteor; @@ -9,3 +13,44 @@ const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(Twitter); Meteor.loginWithTwitter = (options, callback) => { overrideLoginMethod(loginWithTwitter, [options], callback, loginWithTwitterAndTOTP); }; + +Twitter.requestCredential = wrapRequestCredentialFn( + 'twitter', + ({ loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + const credentialToken = Random.secret(); + + let loginPath = `_oauth/twitter/?requestTokenAndRedirect=true&state=${OAuth._stateParam( + loginStyle, + credentialToken, + options?.redirectUrl, + )}`; + + if (Meteor.isCordova) { + loginPath += '&cordova=true'; + if (/Android/i.test(navigator.userAgent)) { + loginPath += '&android=true'; + } + } + + // Support additional, permitted parameters + if (options) { + const hasOwn = Object.prototype.hasOwnProperty; + Twitter.validParamsAuthenticate.forEach((param: string) => { + if (hasOwn.call(options, param)) { + loginPath += `&${param}=${encodeURIComponent(options[param])}`; + } + }); + } + + const loginUrl = Meteor.absoluteUrl(loginPath); + + OAuth.launchLogin({ + loginService: 'twitter', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx index 10fe5c9d074..c76f06bcd3b 100644 --- a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -1,31 +1,13 @@ -import type { LoginService } from '@rocket.chat/ui-contexts'; +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { capitalize } from '@rocket.chat/string-helpers'; import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; -import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; +import { loginServices } from '../../lib/loginServices'; import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; -const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); - -const config: Record> = { - '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' }, -}; - export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; type AuthenticationProviderProps = { @@ -62,12 +44,15 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac resolve(); }); }), - loginWithService: ({ service, clientConfig = {} }: T): (() => Promise) => { - const loginMethods = { + loginWithService: (serviceConfig: T): (() => Promise) => { + const loginMethods: Record = { 'meteor-developer': 'MeteorDeveloperAccount', - } as const; + }; + + const { service: serviceName } = serviceConfig; + const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {}; - const loginWithService = `loginWith${loginMethods[service] || capitalize(String(service || ''))}`; + const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; @@ -86,28 +71,11 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac }); }); }, - queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), - ), + + queryLoginServices: { + getCurrentValue: () => loginServices.getLoginServiceButtons(), + subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), + }, }), [loginMethod], ); diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index f23c2a0358c..1b9060f84e3 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,27 +1,20 @@ -import type { ILoginServiceConfiguration, OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { CustomOAuth } from '../../app/custom-oauth/client/CustomOAuth'; +import { loginServices } from '../lib/loginServices'; Meteor.startup(() => { - ServiceConfiguration.configurations - .find({ - custom: true, - }) - .observe({ - async added(record) { - const service = record as unknown as (ILoginServiceConfiguration & OAuthConfiguration) | undefined; + loginServices.onLoad((services) => { + for (const service of services) { + if (!('custom' in service && service.custom)) { + return; + } - if (!service) { - return; - } - - new CustomOAuth(service.service, { - serverURL: service.serverURL, - authorizePath: service.authorizePath, - scope: service.scope, - }); - }, - }); + new CustomOAuth(service.service, { + serverURL: service.serverURL, + authorizePath: service.authorizePath, + scope: service.scope, + }); + } + }); }); diff --git a/apps/meteor/client/startup/iframeCommands.ts b/apps/meteor/client/startup/iframeCommands.ts index cb946ba4417..f0db83ccdcb 100644 --- a/apps/meteor/client/startup/iframeCommands.ts +++ b/apps/meteor/client/startup/iframeCommands.ts @@ -2,7 +2,6 @@ import type { UserStatus, IUser } from '@rocket.chat/core-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { LocationPathname } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { settings } from '../../app/settings/client'; import { AccountBox } from '../../app/ui-utils/client/lib/AccountBox'; @@ -10,6 +9,7 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; import { capitalize, ltrim, rtrim } from '../../lib/utils/stringUtils'; import { baseURI } from '../lib/baseURI'; +import { loginServices } from '../lib/loginServices'; import { router } from '../providers/RouterProvider'; const commands = { @@ -55,7 +55,7 @@ const commands = { } if (typeof data.service === 'string' && window.ServiceConfiguration) { - const customOauth = ServiceConfiguration.configurations.findOne({ service: data.service }); + const customOauth = loginServices.getLoginService(data.service); if (customOauth) { const customLoginWith = (Meteor as any)[`loginWith${capitalize(customOauth.service, true)}`]; diff --git a/apps/meteor/definition/externals/meteor/oauth.d.ts b/apps/meteor/definition/externals/meteor/oauth.d.ts index 9573b2888f4..f07ede0b0b5 100644 --- a/apps/meteor/definition/externals/meteor/oauth.d.ts +++ b/apps/meteor/definition/externals/meteor/oauth.d.ts @@ -1,7 +1,6 @@ declare module 'meteor/oauth' { import type { IRocketChatRecord } from '@rocket.chat/core-typings'; import type { Mongo } from 'meteor/mongo'; - import type { Configuration } from 'meteor/service-configuration'; interface IOauthCredentials extends IRocketChatRecord { key: string; @@ -27,16 +26,21 @@ declare module 'meteor/oauth' { loginUrl: string; credentialRequestCompleteCallback?: (credentialTokenOrError?: string | Error) => void; credentialToken: string; - popupOptions: { - width: number; - height: number; + popupOptions?: { + width?: number; + height?: number; }; }): void; function _stateParam(loginStyle: string, credentialToken: string, redirectUrl?: string): string; - function _redirectUri(serviceName: string, config: Configuration, params?: any, absoluteUrlOptions?: any): string; + function _redirectUri( + serviceName: string, + config: { loginStyle?: string }, + params?: Record, + absoluteUrlOptions?: Record, + ): string; - function _loginStyle(serviceName: string, config: Configuration, options?: Meteor.LoginWithExternalServiceOptions): string; + function _loginStyle(serviceName: string, config: { loginStyle?: string }, options?: Meteor.LoginWithExternalServiceOptions): string; } } diff --git a/apps/meteor/packages/linkedin-oauth/linkedin-client.js b/apps/meteor/packages/linkedin-oauth/linkedin-client.js index 4803d69d34b..c93826bc742 100644 --- a/apps/meteor/packages/linkedin-oauth/linkedin-client.js +++ b/apps/meteor/packages/linkedin-oauth/linkedin-client.js @@ -1,7 +1,3 @@ -import { ServiceConfiguration } from 'meteor/service-configuration'; -import { Random } from '@rocket.chat/random'; -import { OAuth } from 'meteor/oauth'; - export const Linkedin = {}; // Request LinkedIn credentials for the user @@ -10,47 +6,6 @@ export const Linkedin = {}; // completion. Takes one argument, credentialToken on success, or Error on // error. Linkedin.requestCredential = async function (options, credentialRequestCompleteCallback) { - // support both (options, callback) and (callback). - if (!credentialRequestCompleteCallback && typeof options === 'function') { - credentialRequestCompleteCallback = options; - options = {}; - } - - const config = await ServiceConfiguration.configurations.findOneAsync({ service: 'linkedin' }); - if (!config) { - throw new Accounts.ConfigError('Service not configured'); - } - - const credentialToken = Random.secret(); - - let scope; - const { requestPermissions, ...otherOptionsToPassThrough } = options; - if (requestPermissions) { - scope = requestPermissions.join('+'); - } else { - // If extra permissions not passed, we need to request basic, available to all - scope = 'openid+email+profile'; - } - const loginStyle = OAuth._loginStyle('linkedin', config, options); - if (!otherOptionsToPassThrough.popupOptions) { - // the default dimensions (https://github.com/meteor/meteor/blob/release-1.6.1/packages/oauth/oauth_browser.js#L15) don't play well with the content shown by linkedin - // so override popup dimensions to something appropriate (might have to change if LinkedIn login page changes its layout) - otherOptionsToPassThrough.popupOptions = { - width: 390, - height: 628, - }; - } - - const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ - config.clientId - }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; - - OAuth.launchLogin({ - credentialRequestCompleteCallback, - credentialToken, - loginService: 'linkedin', - loginStyle, - loginUrl, - ...otherOptionsToPassThrough, - }); + // This function will be replaced by meteorOverrides/login/linkedin.ts + throw new Error('Linkedin integration error - invalid reference to original requestCredential implementation.'); }; diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index e8ad6a6c7e5..8d53fa9503b 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -7,20 +7,24 @@ test.describe('OAuth', () => { test.beforeEach(async ({ page }) => { poRegistration = new Registration(page); - - await page.goto('/home'); }); - test('Login Page', async ({ api }) => { + test('Login Page', async ({ page, api }) => { await test.step('expect OAuth button to be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); - await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(5000); + + await page.goto('/home'); + + await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); }); await test.step('expect OAuth button to not be visible', async () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await page.waitForTimeout(5000); - await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); + await page.goto('/home'); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); }); }); -}); \ No newline at end of file +}); diff --git a/packages/ui-contexts/src/AuthenticationContext.tsx b/packages/ui-contexts/src/AuthenticationContext.tsx index 6c5db9836e6..d4a448eecd1 100644 --- a/packages/ui-contexts/src/AuthenticationContext.tsx +++ b/packages/ui-contexts/src/AuthenticationContext.tsx @@ -1,12 +1,8 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { createContext } from 'react'; -export type LoginService = { - clientConfig: unknown; - - title: string; - service: 'meteor-developer'; - - buttonLabelText?: string; +export type LoginService = LoginServiceConfiguration & { + title?: string; icon?: string; }; @@ -14,13 +10,21 @@ export type AuthenticationContextValue = { loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; loginWithToken: (user: string) => Promise; - queryAllServices(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => LoginService[]]; loginWithService(service: T): () => Promise; + + queryLoginServices: { + getCurrentValue: () => LoginService[]; + subscribe: (onStoreChange: () => void) => () => void; + }; }; export const AuthenticationContext = createContext({ - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], loginWithService: () => () => Promise.reject('loginWithService not implemented'), loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), loginWithToken: async () => Promise.reject('loginWithToken not implemented'), + + queryLoginServices: { + getCurrentValue: () => [], + subscribe: (_: () => void) => () => Promise.reject('queryLoginServices not implemented'), + }, }); diff --git a/packages/ui-contexts/src/hooks/useLoginServices.ts b/packages/ui-contexts/src/hooks/useLoginServices.ts index 6cfcb2cf174..dd016abd9d9 100644 --- a/packages/ui-contexts/src/hooks/useLoginServices.ts +++ b/packages/ui-contexts/src/hooks/useLoginServices.ts @@ -1,11 +1,13 @@ import { useContext, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import type { LoginService } from '../AuthenticationContext'; -import { AuthenticationContext } from '../AuthenticationContext'; +import { AuthenticationContext, type LoginService } from '../AuthenticationContext'; export const useLoginServices = (): LoginService[] => { - const { queryAllServices } = useContext(AuthenticationContext); - const [subscribe, getSnapshot] = useMemo(() => queryAllServices(), [queryAllServices]); + const { queryLoginServices } = useContext(AuthenticationContext); + const [subscribe, getSnapshot] = useMemo(() => { + return [queryLoginServices.subscribe, () => queryLoginServices.getCurrentValue()]; + }, [queryLoginServices]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/packages/ui-contexts/src/hooks/useLoginWithService.ts b/packages/ui-contexts/src/hooks/useLoginWithService.ts index e2ca7c3f90f..c3df01f8eed 100644 --- a/packages/ui-contexts/src/hooks/useLoginWithService.ts +++ b/packages/ui-contexts/src/hooks/useLoginWithService.ts @@ -1,9 +1,9 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { useContext, useMemo } from 'react'; -import type { LoginService } from '../AuthenticationContext'; import { AuthenticationContext } from '../AuthenticationContext'; -export const useLoginWithService = (service: T): (() => Promise) => { +export const useLoginWithService = (service: T): (() => Promise) => { const { loginWithService } = useContext(AuthenticationContext); return useMemo(() => { diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index 92b78bbb64f..cdba5e26474 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -11,7 +11,6 @@ const LoginServicesButton = ({ buttonLabelText, icon, title, - clientConfig, service, className, disabled, @@ -23,7 +22,7 @@ const LoginServicesButton = ({ setError?: Dispatch>; }): ReactElement => { const t = useTranslation(); - const handler = useLoginWithService({ service, buttonLabelText, title, clientConfig, ...props }); + const handler = useLoginWithService({ service, buttonLabelText, ...props }); const handleOnClick = useCallback(() => { handler().catch((e: { error?: LoginErrors }) => {