diff --git a/.changeset/breezy-parts-kiss.md b/.changeset/breezy-parts-kiss.md new file mode 100644 index 0000000000000..082079b663c60 --- /dev/null +++ b/.changeset/breezy-parts-kiss.md @@ -0,0 +1,31 @@ +--- +'@rocket.chat/web-ui-registration': major +'@rocket.chat/model-typings': major +'@rocket.chat/core-typings': major +'@rocket.chat/rest-typings': major +'@rocket.chat/passport-x': major +'@rocket.chat/models': major +'@rocket.chat/i18n': major +'@rocket.chat/meteor': major +--- + +## Phishing-Resistant Multi-Factor Authentication + +Introduces a more secure and reliable server-side OAuth authentication flow. + +### What’s New + +- **Improved OAuth login security** + OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception. + +- **Built-in CSRF, state validation, and PKCE protection** + OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support. + +- **Improved two-step verification with OAuth logins** + Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others. + +- **Improved mobile & desktop app login** + Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow. + +- **A new setting to enable/disable new OAuth Flow** + Enable this new setting `Accounts_OAuth_Use_Modern_Flow` to use all of the above mentioned features. diff --git a/.changeset/flat-poets-cheat.md b/.changeset/flat-poets-cheat.md new file mode 100644 index 0000000000000..2560e2d3ee172 --- /dev/null +++ b/.changeset/flat-poets-cheat.md @@ -0,0 +1,28 @@ +--- +'@rocket.chat/web-ui-registration': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/desktop-api': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +## Phishing-Resistant Multi-Factor Authentication + +Introduces a more secure and reliable server-side OAuth authentication flow. + +### What’s New + +- **Improved OAuth login security** + OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception. + +- **Built-in CSRF, state validation, and PKCE protection** + OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support. + +- **Improved two-step verification with OAuth logins** + Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others. + +- **Improved mobile & desktop app login** + Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow. diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index 5967aa518348b..c52531eaac781 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -10,7 +10,7 @@ import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; export class EmailCheck implements ICodeCheck { - public readonly name = 'email'; + public readonly name: string = 'email'; private getUserVerifiedEmails(user: IUser): string[] { if (!Array.isArray(user.emails)) { @@ -145,6 +145,6 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} public async maxFaildedAttemtpsReached(user: IUser) { const maxAttempts = settings.get('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts'); - return (await Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts)) as boolean; + return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts); } } diff --git a/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts new file mode 100644 index 0000000000000..1b0c190b29792 --- /dev/null +++ b/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts @@ -0,0 +1,38 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { EmailCheck } from './EmailCheck'; + +export class EmailCheckForOAuth extends EmailCheck { + public override readonly name = 'email-oauth'; + + public readonly method = 'email'; + + public async sendTwoFactorChallenge(user: IUser): Promise { + const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email'); + await this.sendEmailCode(user); + return challengeId; + } + + public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/app/2fa/server/code/TOTPCheck.ts b/apps/meteor/app/2fa/server/code/TOTPCheck.ts index 236016883f89e..ae9502569457f 100644 --- a/apps/meteor/app/2fa/server/code/TOTPCheck.ts +++ b/apps/meteor/app/2fa/server/code/TOTPCheck.ts @@ -5,7 +5,7 @@ import { settings } from '../../../settings/server'; import { TOTP } from '../lib/totp'; export class TOTPCheck implements ICodeCheck { - public readonly name = 'totp'; + public readonly name: string = 'totp'; public isEnabled(user: IUser): boolean { if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) { diff --git a/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts new file mode 100644 index 0000000000000..8197903a57a85 --- /dev/null +++ b/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts @@ -0,0 +1,36 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { TOTPCheck } from './TOTPCheck'; + +export class TOTPCheckForOAuth extends TOTPCheck { + public override readonly name = 'totp-oauth'; + + public readonly method = 'totp'; + + public async sendTwoFactorChallenge(user: IUser): Promise { + return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp'); + } + + public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 2ef1a2e6811f5..956688e617566 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -63,8 +63,8 @@ export function getFingerprintFromConnection(connection: IMethodConnection): str return crypto.createHash('md5').update(data).digest('hex'); } -function getRememberDate(from: Date = new Date()): Date | undefined { - const rememberFor = parseInt(settings.get('Accounts_TwoFactorAuthentication_RememberFor') as string, 10); +export function getRememberDate(from: Date = new Date()): Date | undefined { + const rememberFor = settings.get('Accounts_TwoFactorAuthentication_RememberFor'); if (rememberFor <= 0) { return; @@ -126,6 +126,20 @@ function isAuthorizedForToken(connection: IMethodConnection, user: IUser, option return true; } +export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise { + const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } }); + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const expires = getRememberDate(); + if (!expires) { + return; + } + + await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires); +} + async function rememberAuthorization(connection: IMethodConnection, user: IUser): Promise { // Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData` // via `_getLoginToken`, REST falls back to the token carried on `connection.token`. @@ -156,7 +170,7 @@ interface ICheckCodeForUser { connection?: IMethodConnection; } -const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { +export const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { // try first getting one of the available methods or the one that was already provided const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); if (selectedMethod) { @@ -184,7 +198,6 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne } let existingUser: IUser | null; - if (typeof user === 'string') { existingUser = await getUserForCheck(user); } else { diff --git a/apps/meteor/app/apple/server/applePassportOAuth.ts b/apps/meteor/app/apple/server/applePassportOAuth.ts new file mode 100644 index 0000000000000..483062ba379d8 --- /dev/null +++ b/apps/meteor/app/apple/server/applePassportOAuth.ts @@ -0,0 +1,176 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import express from 'express'; +import { Accounts } from 'meteor/accounts-base'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import { Strategy as AppleStrategy } from 'passport-apple'; +import type { Profile } from 'passport-apple'; + +import { AppleCustomOAuth } from './AppleCustomOAuth'; +import { oAuthRouter } from '../../../server/configuration/configurePassport'; +import { allowPassportOAuthMiddleware } from '../../../server/lib/oauth/allowPassportOAuthMiddleware'; +import { passportOAuthCallback } from '../../../server/lib/oauth/passportOAuthCallback'; +import { settings } from '../../settings/server'; +import { config } from '../lib/config'; +import { handleIdentityToken } from '../lib/handleIdentityToken'; + +new AppleCustomOAuth('apple', config); + +type RequestWithAppleProfile = { + appleProfile?: { + name?: { + firstName?: string; + middleName?: string; + lastName?: string; + }; + }; +}; + +function isRequestWithAppleProfile(req: object): req is RequestWithAppleProfile { + if ( + 'appleProfile' in req && + req.appleProfile && + typeof req.appleProfile === 'object' && + 'name' in req.appleProfile && + req.appleProfile.name && + typeof req.appleProfile.name === 'object' + ) { + return true; + } + return false; +} + +settings.watchMultiple( + [ + 'Accounts_OAuth_Apple', + 'Accounts_OAuth_Apple_id', + 'Accounts_OAuth_Apple_secretKey', + 'Accounts_OAuth_Apple_iss', + 'Accounts_OAuth_Apple_kid', + 'Accounts_OAuth_Use_Modern_Flow', + ], + async ([enabled, clientId, serverSecret, iss, kid, useModernFlow]) => { + passport.unuse('apple'); + + if (!useModernFlow) { + return; + } + + if (!enabled) { + return ServiceConfiguration.configurations.removeAsync({ + service: 'apple', + }); + } + + // if everything is empty but Apple login is enabled, don't show the login button + if (!clientId && !serverSecret && !iss && !kid) { + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + return; + } + + if (typeof clientId !== 'string' || !clientId) { + return ServiceConfiguration.configurations.removeAsync({ + service: 'apple', + }); + } + + passport.use( + 'apple', + + new AppleStrategy( + { + clientID: clientId, + teamID: settings.get('Accounts_OAuth_Apple_iss'), + keyID: settings.get('Accounts_OAuth_Apple_kid'), + privateKeyString: settings.get('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'), + callbackURL: `${settings.get('Site_Url').replace(/\/$/, '')}/_oauth/apple`, + scope: ['name', 'email'], + passReqToCallback: true, + state: false, + }, + async (req, accessToken: string, refreshToken: string, idToken: string, _profile: Profile, done) => { + try { + const serviceData = await handleIdentityToken(idToken, clientId); + + if (isRequestWithAppleProfile(req)) { + const appleName = req.appleProfile?.name; + serviceData.name = [appleName?.firstName, appleName?.middleName, appleName?.lastName].filter(Boolean).join(' '); + } + + const user = await Accounts.updateOrCreateUserFromExternalService( + 'apple', + { + accessToken, + refreshToken, + ...serviceData, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + } catch (error: any) { + done(error); + return { + type: 'apple', + error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message), + }; + } + }, + ), + ); + + const callbackHandler = [ + allowPassportOAuthMiddleware('apple'), + express.urlencoded({ extended: true }), + passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }), + passportOAuthCallback(settings.get('Site_Url').replace(/\/$/, '')), + ]; + + oAuthRouter.get( + '/oauth/apple', + allowPassportOAuthMiddleware('apple'), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate('apple', { + scope: ['name', 'email'], + }), + ); + + oAuthRouter + .route('/_oauth/apple') + .post(...callbackHandler) + .get(...callbackHandler); + }, +); diff --git a/apps/meteor/app/apple/server/index.ts b/apps/meteor/app/apple/server/index.ts index 5643ad67a54e2..7dd8c1845d894 100644 --- a/apps/meteor/app/apple/server/index.ts +++ b/apps/meteor/app/apple/server/index.ts @@ -1,2 +1,3 @@ import './appleOauthRegisterService'; import './loginHandler'; +import './applePassportOAuth'; diff --git a/apps/meteor/app/custom-oauth/server/customOAuth.ts b/apps/meteor/app/custom-oauth/server/customOAuth.ts new file mode 100644 index 0000000000000..988018518cfdf --- /dev/null +++ b/apps/meteor/app/custom-oauth/server/customOAuth.ts @@ -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 '../../../server/database/utils'; +import { callbacks } from '../../../server/lib/callbacks'; +import { saveUserIdentity } from '../../../server/lib/users/saveUserIdentity'; +import { notifyOnUserChange } from '../../lib/server/lib/notifyListener'; +import { settings } from '../../settings/server/cached'; + +const logger = new Logger('CustomOAuth'); +const BeforeUpdateOrCreateUserFromExternalService = new Map< + string, + (serviceName: string, serviceData: Record) => Promise +>(); +const CustomOAuthInstances = new Map(); + +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('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) { + 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) { + 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) { + 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) { + 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 { + 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) { + 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] && + user.services[serviceName as keyof NonNullable].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) => { + 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]; + 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]); + } + + 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; +}; diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index 45f6b2d1183d7..bade47d8cc1b3 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -23,6 +23,9 @@ const logger = new Logger('CustomOAuth'); 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 }); diff --git a/apps/meteor/app/dolphin/server/lib.ts b/apps/meteor/app/dolphin/server/lib.ts index 826a277b81e4e..04f2f76782e1e 100644 --- a/apps/meteor/app/dolphin/server/lib.ts +++ b/apps/meteor/app/dolphin/server/lib.ts @@ -1,13 +1,16 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import _ from 'underscore'; import { callbacks } from '../../../server/lib/callbacks'; import { beforeCreateUserCallback } from '../../../server/lib/callbacks/beforeCreateUserCallback'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config = { +const config: Partial = { serverURL: '', authorizePath: '/m/oauth2/auth/', tokenPath: '/m/oauth2/token/', @@ -30,11 +33,45 @@ function DolphinOnCreateUser(options: any, user?: IUser) { return options; } +const configureDolphinOAuth = () => { + passport.unuse('dolphin'); + + const enabled = settings.get('Accounts_OAuth_Dolphin'); + if (!enabled) { + return; + } + + const serverURL = settings.get('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Dolphin_id'); + const clientSecret = settings.get('Accounts_OAuth_Dolphin_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + const completeConfig = { ...config, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('dolphin', completeConfig); + return; + } + + Dolphin.configure(completeConfig); +}; + Meteor.startup(async () => { - settings.watch('Accounts_OAuth_Dolphin_URL', (value) => { - config.serverURL = value; - return Dolphin.configure(config); - }); + const updateConfig = _.debounce(configureDolphinOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Dolphin', + 'Accounts_OAuth_Dolphin_URL', + 'Accounts_OAuth_Dolphin_id', + 'Accounts_OAuth_Dolphin_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); if (settings.get('Accounts_OAuth_Dolphin_URL')) { const data = { diff --git a/apps/meteor/app/drupal/server/lib.ts b/apps/meteor/app/drupal/server/lib.ts index d137551fb8377..dffa2ac41638b 100644 --- a/apps/meteor/app/drupal/server/lib.ts +++ b/apps/meteor/app/drupal/server/lib.ts @@ -1,13 +1,13 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; +import _ from 'underscore'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -// Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal -// In RocketChat -> Administration the URL needs to be http(s)://{drupal.server}/ - -const config: OauthConfig = { +const config: Partial = { serverURL: '', identityPath: '/oauth2/UserInfo', authorizePath: '/oauth2/authorize', @@ -25,9 +25,42 @@ const config: OauthConfig = { const Drupal = new CustomOAuth('drupal', config); +const configureDrupalOAuth = () => { + passport.unuse('drupal'); + const enabled = settings.get('Accounts_OAuth_Drupal'); + if (!enabled) { + return; + } + + const serverURL = settings.get('API_Drupal_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Drupal_id'); + const clientSecret = settings.get('Accounts_OAuth_Drupal_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + const completeConfig = { ...config, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('drupal', completeConfig); + return; + } + + Drupal.configure(completeConfig); +}; + Meteor.startup(() => { - settings.watch('API_Drupal_URL', (value) => { - config.serverURL = value; - Drupal.configure(config); - }); + const updateConfig = _.debounce(configureDrupalOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Drupal', + 'API_Drupal_URL', + 'Accounts_OAuth_Drupal_id', + 'Accounts_OAuth_Drupal_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); }); diff --git a/apps/meteor/app/gitlab/server/lib.ts b/apps/meteor/app/gitlab/server/lib.ts index 0f4d330cf5970..27b54517e55fe 100644 --- a/apps/meteor/app/gitlab/server/lib.ts +++ b/apps/meteor/app/gitlab/server/lib.ts @@ -1,13 +1,17 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; import _ from 'underscore'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: OauthConfig = { +const config: Partial = { serverURL: 'https://gitlab.com', identityPath: '/api/v4/user', + authorizePath: '/oauth/authorize', + tokenPath: '/oauth/token', scope: 'read_user', mergeUsers: false, addAutopublishFields: { @@ -19,13 +23,47 @@ const config: OauthConfig = { const Gitlab = new CustomOAuth('gitlab', config); +const configureGitlabOAuth = () => { + passport.unuse('gitlab'); + + const enabled = settings.get('Accounts_OAuth_Gitlab'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Gitlab_id'); + const clientSecret = settings.get('Accounts_OAuth_Gitlab_secret'); + const serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; + const identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; + const mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); + + if (!clientId || !clientSecret) { + return; + } + + const completeConfig = { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('gitlab', completeConfig); + return; + } + + Gitlab.configure(completeConfig); +}; + Meteor.startup(() => { - const updateConfig = _.debounce(() => { - config.serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; - config.identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; - config.mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); - Gitlab.configure(config); - }, 300); - - settings.watchMultiple(['API_Gitlab_URL', 'Accounts_OAuth_Gitlab_identity_path', 'Accounts_OAuth_Gitlab_merge_users'], updateConfig); + const updateConfig = _.debounce(configureGitlabOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Gitlab', + 'API_Gitlab_URL', + 'Accounts_OAuth_Gitlab_id', + 'Accounts_OAuth_Gitlab_secret', + 'Accounts_OAuth_Gitlab_identity_path', + 'Accounts_OAuth_Gitlab_merge_users', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); }); diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 0b8939190d7b6..ec93a65196544 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -21,7 +21,7 @@ export async function generateAccessToken(userId: string, secret: string, caller } const token = Accounts._generateStampedLoginToken(); - Accounts._insertLoginToken(userId, token); + await Accounts._insertLoginToken(userId, token); await User.ensureLoginTokensLimit(userId); diff --git a/apps/meteor/app/linkedin/server/index.ts b/apps/meteor/app/linkedin/server/index.ts new file mode 100644 index 0000000000000..cf327e4971bb2 --- /dev/null +++ b/apps/meteor/app/linkedin/server/index.ts @@ -0,0 +1 @@ +import './lib'; diff --git a/apps/meteor/app/linkedin/server/lib.ts b/apps/meteor/app/linkedin/server/lib.ts new file mode 100644 index 0000000000000..6fb0d450d2fda --- /dev/null +++ b/apps/meteor/app/linkedin/server/lib.ts @@ -0,0 +1,49 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings/server'; + +const config: Partial = { + serverURL: 'https://www.linkedin.com', + authorizePath: '/oauth/v2/authorization', + tokenPath: '/oauth/v2/accessToken', + identityPath: 'https://api.linkedin.com/v2/userinfo', + scope: 'openid email profile', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.linkedin'], + forOtherUsers: ['services.linkedin.name'], + }, + pkce: false, + emailField: 'email', + avatarField: 'picture', +}; + +const serviceKey = 'linkedin'; + +const configureLinkedInOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Linkedin'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Linkedin_id'); + const clientSecret = settings.get('Accounts_OAuth_Linkedin_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'], + configureLinkedInOAuth, + ); +}); diff --git a/apps/meteor/app/meteor-developer/server/index.ts b/apps/meteor/app/meteor-developer/server/index.ts new file mode 100644 index 0000000000000..cf327e4971bb2 --- /dev/null +++ b/apps/meteor/app/meteor-developer/server/index.ts @@ -0,0 +1 @@ +import './lib'; diff --git a/apps/meteor/app/meteor-developer/server/lib.ts b/apps/meteor/app/meteor-developer/server/lib.ts new file mode 100644 index 0000000000000..8a77b47235a25 --- /dev/null +++ b/apps/meteor/app/meteor-developer/server/lib.ts @@ -0,0 +1,46 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings/server'; + +const config: Partial = { + serverURL: 'https://www.meteor.com', + authorizePath: '/oauth2/authorize', + tokenPath: '/oauth2/token', + identityPath: '/api/v1/identity', + scope: 'email', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.meteor-developer'], + forOtherUsers: ['services.meteor-developer.username'], + }, +}; + +const serviceKey = 'meteor'; + +const configureMeteorDeveloperOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Meteor'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Meteor_id'); + const clientSecret = settings.get('Accounts_OAuth_Meteor_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'], + configureMeteorDeveloperOAuth, + ); +}); diff --git a/apps/meteor/app/wordpress/server/lib.ts b/apps/meteor/app/wordpress/server/lib.ts index 7777ea1382215..bf2fcc3e02a32 100644 --- a/apps/meteor/app/wordpress/server/lib.ts +++ b/apps/meteor/app/wordpress/server/lib.ts @@ -1,12 +1,14 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; import _ from 'underscore'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: OauthConfig = { +const config: Partial = { serverURL: '', identityPath: '/oauth/me', @@ -17,7 +19,9 @@ const config: OauthConfig = { accessTokenParam: 'access_token', }; -const WordPress = new CustomOAuth('wordpress', config); +const serviceKey = 'wordpress'; + +const WordPress = new CustomOAuth(serviceKey, config); const fillSettings = _.debounce(async (): Promise => { config.serverURL = settings.get('API_Wordpress_URL'); @@ -34,6 +38,8 @@ const fillSettings = _.debounce(async (): Promise => { delete config.tokenPath; delete config.scope; + passport.unuse(serviceKey); + const serverType = settings.get('Accounts_OAuth_Wordpress_server_type'); switch (serverType) { case 'custom': @@ -58,23 +64,39 @@ const fillSettings = _.debounce(async (): Promise => { } break; case 'wordpress-com': - config.identityPath = 'https://public-api.wordpress.com/rest/v1/me'; - config.identityTokenSentVia = 'header'; - config.authorizePath = 'https://public-api.wordpress.com/oauth2/authorize'; - config.tokenPath = 'https://public-api.wordpress.com/oauth2/token'; + config.identityPath = '/rest/v1/me'; + config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia']; + config.authorizePath = '/oauth2/authorize'; + config.tokenPath = '/oauth2/token'; config.scope = 'auth'; break; default: config.identityPath = '/oauth/me'; + config.authorizePath = '/oauth/authorize'; + config.tokenPath = '/oauth/token'; break; } - const result = WordPress.configure(config); + const clientId = settings.get('Accounts_OAuth_Wordpress_id'); + const clientSecret = settings.get('Accounts_OAuth_Wordpress_secret'); + + if (!clientId || !clientSecret) { + return; + } + + const completeConfig = { ...config, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth(serviceKey, completeConfig); + } else { + WordPress.configure(completeConfig); + } + const enabled = settings.get('Accounts_OAuth_Wordpress'); if (enabled) { await ServiceConfiguration.configurations.upsertAsync( { - service: 'wordpress', + service: serviceKey, }, { $set: config, @@ -82,13 +104,12 @@ const fillSettings = _.debounce(async (): Promise => { ); } else { await ServiceConfiguration.configurations.removeAsync({ - service: 'wordpress', + service: serviceKey, }); } - - return result; }, 1000); Meteor.startup(() => { - return settings.watchByRegex(/(API\_Wordpress\_URL)?(Accounts\_OAuth\_Wordpress\_)?/, () => fillSettings()); + settings.watchByRegex(/(API\_Wordpress\_URL)?(Accounts\_OAuth\_Wordpress\_)?/, () => fillSettings()); + settings.watch('Accounts_OAuth_Use_Modern_Flow', () => fillSettings()); }); diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index 7c7ac9b619d8c..a81e9d1a948b1 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -13,14 +13,22 @@ export type TwoFactorEmailModalProps = { onConfirm: OnConfirm; onClose: () => void; invalidAttempt?: boolean; - emailOrUsername: string; -}; +} & ( + | { + emailOrUsername: string; + challengeId?: never; + } + | { + challengeId: string; + emailOrUsername?: never; + } +); type TwoFactorEmailFormData = { code: string; }; -const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttempt }: TwoFactorEmailModalProps) => { +const TwoFactorEmailModal = ({ onConfirm, onClose, invalidAttempt, emailOrUsername, challengeId }: TwoFactorEmailModalProps) => { const dispatchToastMessage = useToastMessageDispatch(); const { t } = useTranslation(); @@ -45,10 +53,15 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttem }, [invalidAttempt, setError, t]); const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode'); + const sendEmailCodeByChallengeId = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode'); const onClickResendCode = async (): Promise => { try { - await sendEmailCode({ emailOrUsername }); + if (emailOrUsername) { + await sendEmailCode({ emailOrUsername }); + } else if (challengeId) { + await sendEmailCodeByChallengeId({ challengeId }); + } dispatchToastMessage({ type: 'success', message: t('Email_sent') }); } catch (error) { dispatchToastMessage({ diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index 718edbfa845a1..c3eef4eb451e9 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -21,6 +21,12 @@ export type TwoFactorModalProps = { | { method: 'email'; emailOrUsername: string; + challengeId?: never; + } + | { + method: 'email'; + challengeId: string; + emailOrUsername?: never; } ); @@ -30,9 +36,9 @@ const TwoFactorModal = ({ onConfirm, onClose, invalidAttempt, ...props }: TwoFac } if (props.method === Method.EMAIL) { - const { emailOrUsername } = props; + // const { emailOrUsername, challengeId } = props; - return ; + return ; } if (props.method === Method.PASSWORD) { diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index c8b4e5cce34a1..c2c895f37ac1e 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -31,7 +31,9 @@ const hasRequiredTwoFactorMethod = ( function assertModalProps(props: { method: TwoFactorMethod; emailOrUsername?: string; -}): asserts props is { method: 'totp' } | { method: 'password' } | { method: 'email'; emailOrUsername: string } { +}): asserts props is + | { method: 'totp' | 'password'; invalidAttempt?: boolean } + | { method: 'email'; emailOrUsername: string; invalidAttempt?: boolean } { if (props.method === 'email' && typeof props.emailOrUsername !== 'string') { throw new Error('Invalid Two Factor method'); } diff --git a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts new file mode 100644 index 0000000000000..e4481fed271d3 --- /dev/null +++ b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts @@ -0,0 +1,5 @@ +export const buildDeepLinkURL = (resumeToken: string, userId: string) => { + const url = new URL(window.location.href); + const { origin } = url; + return `rocketchat://auth?host=${origin}&token=${resumeToken}&userId=${userId}`; +}; diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts index b8e5d3d17914e..19b7691134e19 100644 --- a/apps/meteor/client/lib/sdk/ddpSdk.ts +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -76,7 +76,7 @@ export const getDdpSdk = (): DDPSDK => { return instance; }; -const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); +export const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); let inflightLogin: Promise | undefined; diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 77f76158cb65d..2b8e1c43e35e2 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -3,6 +3,7 @@ import { createElement, lazy, useEffect } from 'react'; import { appLayout } from '../lib/appLayout'; import { router } from '../providers/RouterProvider'; +import OAuthTwoFactorAuthenticationRouter from '../views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter'; import MainLayout from '../views/root/MainLayout'; const IndexRoute = lazy(() => import('../views/root/IndexRoute')); @@ -39,6 +40,10 @@ declare module '@rocket.chat/ui-contexts' { pathname: `/meet/${string}`; pattern: '/meet/:rid'; }; + '2fa': { + pathname: `/2fa/${string}/${string}`; + pattern: '/2fa/:method/:challengeId'; + }; 'home': { pathname: '/home'; pattern: '/home'; @@ -131,6 +136,11 @@ router.defineRoutes([ return null; }), }, + { + path: '/2fa/:method/:challengeId', + id: '2fa', + element: appLayout.wrap(), + }, { path: '/home', id: 'home', diff --git a/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx b/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx new file mode 100644 index 0000000000000..be8434f2626ac --- /dev/null +++ b/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx @@ -0,0 +1,101 @@ +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { Page } from '@rocket.chat/ui-client'; +import { + useEndpoint, + useLoginWithToken, + useRouteParameter, + useRouter, + useSetModal, + useToastMessageDispatch, +} from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import TwoFactorModal from '../../components/TwoFactorModal/TwoFactorModal'; + +const throwErrorOnInvalidMethod = (method: never): never => { + throw new Error(`Invalid Two Factor method: ${method}`); +}; + +const OAuthTwoFactorAuthenticationRouter = () => { + const method = useRouteParameter('method') as 'totp' | 'email' | undefined; + const challengeId = useRouteParameter('challengeId'); + const router = useRouter(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const loginWithToken = useLoginWithToken(); + const { t } = useTranslation(); + const verifyChallenge = useEndpoint('POST', '/v1/twoFactorChallenges.verifyChallenge'); + const sendEmailCode = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode'); + + const navigateToHome = useStableCallback(() => { + setModal(null); + router.navigate('/home', { replace: true }); + }); + + const resendEmail = useStableCallback(async () => { + if (!challengeId) { + return null; + } + await sendEmailCode({ challengeId }); + return null; + }); + + const onConfirm = useStableCallback(async (code: string) => { + if (!challengeId || !code) { + return; + } + try { + const { loginToken, userId } = await verifyChallenge({ challengeId, code }); + + const { loginClient } = router.getSearchParameters(); + + if (loginClient === 'mobile' || loginClient === 'desktop') { + setModal(null); + router.navigate({ name: 'home', search: { resumeToken: loginToken, userId, loginClient } }, { replace: true }); + return; + } + + await loginWithToken(loginToken); + navigateToHome(); + } catch (error: any) { + console.error('Failed to verify challenge', error); + if (error.errorType === 'totp-max-attempts') { + setModal(null); + dispatchToastMessage({ type: 'error', message: t('Maximum_number_of_attempts_reached_please_try_again_later') }); + router.navigate('/login', { replace: true }); + return; + } + if (error.errorType === 'error-challenge-expired' || error.errorType === 'error-challenge-not-found') { + setModal(null); + dispatchToastMessage({ type: 'error', message: t('Challenge_expired_please_try_again_later') }); + router.navigate('/login', { replace: true }); + return; + } + throw error; + } + }); + + useEffect(() => { + if (!method || !challengeId) { + router.navigate('/home'); + return; + } + + if (method === 'email') { + setModal(); + return; + } + + if (method === 'totp') { + setModal(); + return; + } + + throwErrorOnInvalidMethod(method); + }, [method, challengeId, router, setModal, onConfirm, resendEmail, navigateToHome]); + + return ; +}; + +export default OAuthTwoFactorAuthenticationRouter; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index 108254199ae0d..ee90f03ba271d 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -28,11 +28,14 @@ import { useKeyboardShortcutsHotkey } from './hooks/useKeyboardShortcutsHotkey'; import { useLivechatEnterprise } from './hooks/useLivechatEnterprise'; import { useLoadMissedMessages } from './hooks/useLoadMissedMessages'; import { useLoadRoomForAllowedAnonymousRead } from './hooks/useLoadRoomForAllowedAnonymousRead'; +import { useLoginOtherClients } from './hooks/useLoginOtherClients'; import { useLoginViaQuery } from './hooks/useLoginViaQuery'; import { useMessageLinkClicks } from './hooks/useMessageLinkClicks'; import { useNotificationPermission } from './hooks/useNotificationPermission'; +import { useOAuthLogin } from './hooks/useOAuthLogin'; import { useRedirectToSetupWizard } from './hooks/useRedirectToSetupWizard'; import { useSettingsOnLoadSiteUrl } from './hooks/useSettingsOnLoadSiteUrl'; +import { useShareSessionWithOtherClients } from './hooks/useShareSessionWithOtherClients'; import { useStartupEvent } from './hooks/useStartupEvent'; import { appLayout } from '../../lib/appLayout'; @@ -71,6 +74,9 @@ const AppLayout = () => { useAutoupdate(); useCodeHighlight(); useLoginViaQuery(); + useLoginOtherClients(); + useOAuthLogin(); + useShareSessionWithOtherClients(); useLoadMissedMessages(); useDesktopFavicon(); useDesktopTitle(); diff --git a/apps/meteor/client/views/root/hooks/useIframeCommands.ts b/apps/meteor/client/views/root/hooks/useIframeCommands.ts index 684787a0f4341..ccc9a3681de48 100644 --- a/apps/meteor/client/views/root/hooks/useIframeCommands.ts +++ b/apps/meteor/client/views/root/hooks/useIframeCommands.ts @@ -17,6 +17,7 @@ export const useIframeCommands = () => { const loginWithToken = useLoginWithToken(); const loginWithCustomOauth = useLoginWithCustomOauth(); const { logout } = useContext(UserContext); + const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true); useEffect(() => { if (!iframeReceiveEnabled) { @@ -50,6 +51,21 @@ export const useIframeCommands = () => { }, 'call-custom-oauth-login'(data: { service: string; redirectUrl?: string | null }, event: MessageEvent) { + if (enableModernOAuthFlow) { + const url = new URL(window.location.href); + const queryParams = url.searchParams; + const loginClient = queryParams.get('loginClient'); + + const redirectUrl = new URL(`/oauth/${data.service}`, window.location.origin); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + window.location.href = redirectUrl.toString(); + return; + } + const customOAuthCallback = (response: unknown) => { event.source?.postMessage( { @@ -65,7 +81,7 @@ export const useIframeCommands = () => { data.redirectUrl = null; } - if (typeof data.service === 'string' && window.ServiceConfiguration) { + if (window.ServiceConfiguration) { const customOauth = loginServices.getLoginService(data.service); if (customOauth) { @@ -117,5 +133,5 @@ export const useIframeCommands = () => { return () => { window.removeEventListener('message', messageListener); }; - }, [iframeReceiveEnabled, iframeReceiveOrigin, loginWithToken, loginWithCustomOauth, logout]); + }, [iframeReceiveEnabled, iframeReceiveOrigin, loginWithToken, loginWithCustomOauth, logout, enableModernOAuthFlow]); }; diff --git a/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts new file mode 100644 index 0000000000000..a826091886772 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts @@ -0,0 +1,30 @@ +import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; + +export const useLoginOtherClients = () => { + const router = useRouter(); + const resumeToken = useSearchParameter('resumeToken'); + const loginClient = useSearchParameter('loginClient'); + const userId = useSearchParameter('userId'); + + useEffect(() => { + if (!resumeToken || !userId) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(resumeToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 0); + + return () => clearTimeout(timeout); + }, [resumeToken, userId, loginClient, router]); +}; diff --git a/apps/meteor/client/views/root/hooks/useOAuthLogin.ts b/apps/meteor/client/views/root/hooks/useOAuthLogin.ts new file mode 100644 index 0000000000000..59d3f05007b4f --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useOAuthLogin.ts @@ -0,0 +1,46 @@ +import { useEndpoint, useRouter, useSearchParameter, useLoginWithToken } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; + +export const useOAuthLogin = () => { + const router = useRouter(); + const loginCode = useSearchParameter('loginCode'); + const loginClient = useSearchParameter('loginClient'); + const redeemLoginCode = useEndpoint('POST', '/v1/loginCode.redeem'); + const loginWithToken = useLoginWithToken(); + + const { mutate: redeemLoginCodeMutation } = useMutation({ + mutationFn: async (loginCode: string) => { + const { loginToken, userId } = await redeemLoginCode({ code: loginCode }); + + if (!loginToken || !userId) { + throw new Error('Invalid response from login code redemption'); + } + + return { loginToken, userId }; + }, + onSuccess: async ({ loginToken, userId }) => { + if (loginClient === 'desktop' || loginClient === 'mobile') { + window.location.href = buildDeepLinkURL(loginToken, userId); + return; + } + + await loginWithToken(loginToken); + router.navigate('/home', { replace: true }); + }, + onError: (error) => { + console.error('Failed to redeem login code for client redirect', error); + router.navigate('/login', { replace: true }); + }, + }); + + useEffect(() => { + if (!loginCode) { + return; + } + + redeemLoginCodeMutation(loginCode); + }, [loginCode, redeemLoginCodeMutation]); +}; diff --git a/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts new file mode 100644 index 0000000000000..62fd2fee04818 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts @@ -0,0 +1,41 @@ +import { useRouter, useSearchParameter, useUserId } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; +import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk'; + +export const useShareSessionWithOtherClients = () => { + const router = useRouter(); + const userId = useUserId(); + const resumeToken = useSearchParameter('resumeToken'); + const loginClient = useSearchParameter('loginClient'); + + useEffect(() => { + if (!userId) { + return; + } + + const loginToken = readStoredLoginToken(); + + if (!loginToken) { + return; + } + + if (resumeToken) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(loginToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 100); + + return () => clearTimeout(timeout); + }, [resumeToken, loginClient, router, userId]); +}; diff --git a/apps/meteor/definition/externals/express-session.d.ts b/apps/meteor/definition/externals/express-session.d.ts new file mode 100644 index 0000000000000..6a59655d241a7 --- /dev/null +++ b/apps/meteor/definition/externals/express-session.d.ts @@ -0,0 +1,7 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + loginClient?: string; + } +} diff --git a/apps/meteor/definition/externals/express.d.ts b/apps/meteor/definition/externals/express.d.ts index 1661385d94f77..251d8310daf9f 100644 --- a/apps/meteor/definition/externals/express.d.ts +++ b/apps/meteor/definition/externals/express.d.ts @@ -9,3 +9,9 @@ declare module 'express' { unauthorized?: boolean; } } +declare global { + namespace Express { + // eslint-disable-next-line @typescript-eslint/no-empty-interface -- merges with Passport's Express.User + interface User extends IUser {} + } +} diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 75a7e4b832d39..aa630621e809d 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -25,7 +25,7 @@ declare module 'meteor/accounts-base' { function _generateStampedLoginToken(): { token: string; when: Date }; - function _insertLoginToken(userId: string, token: { token: string; when: Date }): void; + function _insertLoginToken(userId: string, token: { token: string; when: Date }): Promise; function _runLoginHandlers(methodInvocation: T, loginRequest: Record): Promise; @@ -43,7 +43,9 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record, options: Record, - ): Promise>; + ): Promise | undefined>; + + function addAutopublishFields(options: Record): void; function _clearAllLoginTokens(userId: string | null): void; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 58634287ed865..b65ab63edb4a1 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -139,6 +139,7 @@ "@rocket.chat/omni-core-ee": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/onboarding-ui": "~0.37.0", + "@rocket.chat/passport-x": "workspace:~", "@rocket.chat/password-policies": "workspace:^", "@rocket.chat/patch-injection": "workspace:^", "@rocket.chat/pdf-worker": "workspace:^", @@ -184,6 +185,7 @@ "colorette": "^2.0.20", "colors": "^1.4.0", "connect": "^3.7.0", + "connect-mongo": "5.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -206,6 +208,7 @@ "expiry-map": "^2.0.0", "express": "^4.21.2", "express-rate-limit": "^5.5.1", + "express-session": "^1.19.0", "fast-redact": "^3.5.0", "fastq": "^1.17.1", "fflate": "^0.8.2", @@ -262,6 +265,13 @@ "object-path": "^0.11.8", "overlayscrollbars": "~2.11.5", "overlayscrollbars-react": "^0.5.6", + "passport": "^0.7.0", + "passport-apple": "^2.0.2", + "passport-facebook": "^3.0.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", + "passport-oauth1": "~1.3.0", + "passport-oauth2": "^1.8.0", "path": "^0.12.7", "path-to-regexp": "^6.3.0", "pino": "10.3.1", @@ -354,6 +364,7 @@ "@types/ejson": "^2.2.2", "@types/express": "^4.17.25", "@types/express-rate-limit": "^5.1.3", + "@types/express-session": "^1", "@types/fast-redact": "^3", "@types/google-libphonenumber": "^7.4.30", "@types/gravatar": "^1.8.6", @@ -384,6 +395,12 @@ "@types/oauth2-server": "^3.0.18", "@types/object-path": "^0.11.4", "@types/parseurl": "^1.3.3", + "@types/passport": "^1.0.17", + "@types/passport-apple": "^2", + "@types/passport-facebook": "^3.0.4", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2", + "@types/passport-oauth2": "^1", "@types/prometheus-gc-stats": "^0.6.4", "@types/proxy-from-env": "^1.0.4", "@types/proxyquire": "^1.3.31", diff --git a/apps/meteor/server/api/ApiClass.ts b/apps/meteor/server/api/ApiClass.ts index df8cbe28659c8..6ab935e0a3c7b 100644 --- a/apps/meteor/server/api/ApiClass.ts +++ b/apps/meteor/server/api/ApiClass.ts @@ -144,7 +144,7 @@ const rateLimiterDictionary: Record< } > = {}; -const generateConnection = ( +export const generateConnection = ( ipAddress: string, httpHeaders: Record, ): { diff --git a/apps/meteor/server/api/index.ts b/apps/meteor/server/api/index.ts index 7f6720c661537..da9c563851873 100644 --- a/apps/meteor/server/api/index.ts +++ b/apps/meteor/server/api/index.ts @@ -45,7 +45,8 @@ import './v1/mailer'; import './v1/teams'; import './v1/moderation'; import './v1/uploads'; - +import './v1/twoFactorChallenges'; +import './v1/loginCode'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/server/api/v1/custom-sounds.ts b/apps/meteor/server/api/v1/custom-sounds.ts index 5e3888e6be274..12f073d0c4878 100644 --- a/apps/meteor/server/api/v1/custom-sounds.ts +++ b/apps/meteor/server/api/v1/custom-sounds.ts @@ -15,6 +15,7 @@ import { validateInternalErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { Meteor } from 'meteor/meteor'; import { deleteCustomSound } from '../../../app/custom-sounds/server/lib/deleteCustomSound'; import { insertOrUpdateSound } from '../../../app/custom-sounds/server/lib/insertOrUpdateSound'; @@ -126,7 +127,7 @@ const customSoundsEndpoints = API.v1 const filter = { ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), }; const { cursor, totalCount } = CustomSounds.findPaginated(filter, { diff --git a/apps/meteor/server/api/v1/loginCode.ts b/apps/meteor/server/api/v1/loginCode.ts new file mode 100644 index 0000000000000..e681ebbb909e4 --- /dev/null +++ b/apps/meteor/server/api/v1/loginCode.ts @@ -0,0 +1,71 @@ +import { LoginCodes } from '@rocket.chat/models'; +import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; +import { Accounts } from 'meteor/accounts-base'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +const loginCodeRedeemResponse = ajv.compile<{ loginToken: string; userId: string }>({ + type: 'object', + properties: { + loginToken: { type: 'string' }, + userId: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['loginToken', 'userId', 'success'], + additionalProperties: false, +}); + +type LoginCodeRedeemParams = { code: string }; + +const LoginCodeRedeemSchema: JSONSchemaType = { + type: 'object', + properties: { + code: { type: 'string', minLength: 64, maxLength: 64 }, + }, + required: ['code'], + additionalProperties: false, +}; + +const isLoginCodeRedeemParamsPOST = ajv.compile(LoginCodeRedeemSchema); + +const loginCodeEndpoints = API.v1.post( + 'loginCode.redeem', + { + authRequired: false, + body: isLoginCodeRedeemParamsPOST, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 10 }, + response: { + 200: loginCodeRedeemResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { code } = this.bodyParams; + + const loginCode = await LoginCodes.findOneNotExpiredByCodeAndDelete(code); + + if (!loginCode) { + return API.v1.failure('error-invalid-code'); + } + + const { userId } = loginCode; + + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, stampedToken); + + return API.v1.success({ + loginToken: stampedToken.token, + userId, + }); + }, +); + +type LoginCodeEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LoginCodeEndpoints {} +} diff --git a/apps/meteor/server/api/v1/settings.ts b/apps/meteor/server/api/v1/settings.ts index 0f66548018b36..2418eef97fa42 100644 --- a/apps/meteor/server/api/v1/settings.ts +++ b/apps/meteor/server/api/v1/settings.ts @@ -181,6 +181,7 @@ API.v1.get( }, async function action() { const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); + const isPassportFlowEnabled = settings.get('Accounts_OAuth_Use_Modern_Flow'); return API.v1.success({ services: oAuthServicesEnabled.map((service) => { @@ -188,8 +189,17 @@ API.v1.get( return service; } - if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { - return { ...service }; + // CAUTION: Never hide sign-in with apple button from mobile app. + if (service.service && ['apple'].includes(service.service)) { + return { ...service, hideButtonOnMobile: false }; + } + + if (service.service && ['saml', 'cas', 'ldap'].includes(service.service)) { + return { ...service, hideButtonOnMobile: false }; + } + + if ((service as OAuthConfiguration).custom || (service.service && service.service === 'wordpress')) { + return { ...service, hideButtonOnMobile: isPassportFlowEnabled }; } return { @@ -203,6 +213,7 @@ API.v1.get( buttonColor: service.buttonColor || '', buttonLabelColor: service.buttonLabelColor || '', custom: false, + hideButtonOnMobile: isPassportFlowEnabled, }; }), }); diff --git a/apps/meteor/server/api/v1/twoFactorChallenges.ts b/apps/meteor/server/api/v1/twoFactorChallenges.ts new file mode 100644 index 0000000000000..866f4eeccf763 --- /dev/null +++ b/apps/meteor/server/api/v1/twoFactorChallenges.ts @@ -0,0 +1,113 @@ +import type { IMethodConnection } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { isTwoFactorChallengesSendEmailCodeParamsPOST, isTwoFactorChallengesVerifyChallengeParamsPOST } from '@rocket.chat/rest-typings'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { getUserForCheck, rememberAuthorizationByToken } from '../../../app/2fa/server/code'; +import { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../lib/oauth/twoFactorAuth'; +import { generateConnection } from '../ApiClass'; +import { API } from '../api'; + +API.v1.addRoute( + 'twoFactorChallenges.sendEmailCode', + { validateParams: isTwoFactorChallengesSendEmailCodeParamsPOST, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 } }, + { + async post() { + const { challengeId } = this.bodyParams; + + if (!challengeId) { + throw new Meteor.Error('error-parameter-required', 'challengeId is required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + if (challenge.method !== 'email') { + throw new Meteor.Error('error-invalid-challenge-method', 'invalid challenge method'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + await emailCheckForOAuth.sendEmailCode(user); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'twoFactorChallenges.verifyChallenge', + { + validateParams: isTwoFactorChallengesVerifyChallengeParamsPOST, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 }, + }, + { + async post() { + const { challengeId, code } = this.bodyParams; + + if (!challengeId || !code) { + throw new Meteor.Error('error-parameter-required', 'challengeId and code are required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const twoFAMethod = getTwoFAMethodForOAuth(challenge.method); + + const isCodeValid = await twoFAMethod.verifyEmailTwoFactorChallenge(user, challengeId, code); + + if (!isCodeValid) { + const tooManyAttempts = await twoFAMethod.maxFaildedAttemtpsReached(user); + if (tooManyAttempts) { + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached'); + } + return API.v1.failure('error-invalid-code', 'Invalid code'); + } + + const stampedToken = Accounts._generateStampedLoginToken(); + + await Accounts._insertLoginToken(user._id, stampedToken); + + const hashedToken = Accounts._hashLoginToken(stampedToken.token); + + const connection = { + ...generateConnection(this.requestIp, this.request.headers), + token: hashedToken, + } as unknown as IMethodConnection; + + // remember the 2FA authorization for the next requests + await rememberAuthorizationByToken(hashedToken, user._id, connection); + + return API.v1.success({ + loginToken: stampedToken.token, + userId: user._id, + }); + }, + }, +); diff --git a/apps/meteor/server/bridges/nextcloud/lib.ts b/apps/meteor/server/bridges/nextcloud/lib.ts index c1f3c51082c57..03b56ee320bea 100644 --- a/apps/meteor/server/bridges/nextcloud/lib.ts +++ b/apps/meteor/server/bridges/nextcloud/lib.ts @@ -1,14 +1,15 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; +import passport from 'passport'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { settings } from '../../../app/settings/server'; +import { addPassportCustomOAuth } from '../../lib/oauth/addPassportCustomOAuth'; -const config: OauthConfig = { +const NEXTCLOUD_PATHS = { serverURL: '', tokenPath: '/index.php/apps/oauth2/api/v1/token', - tokenSentVia: 'header', + tokenSentVia: 'header' as OAuthConfiguration['tokenSentVia'], authorizePath: '/index.php/apps/oauth2/authorize', identityPath: '/ocs/v2.php/cloud/user?format=json', scope: 'openid', @@ -18,20 +19,42 @@ const config: OauthConfig = { }, }; -const Nextcloud = new CustomOAuth('nextcloud', config); +const Nextcloud = new CustomOAuth('nextcloud', NEXTCLOUD_PATHS); -const fillServerURL = _.debounce((): void => { - const nextcloudURL = settings.get('Accounts_OAuth_Nextcloud_URL'); - if (!nextcloudURL) { - if (nextcloudURL === undefined) { - return fillServerURL(); - } +function configureNextcloudOAuth(): void { + passport.unuse('nextcloud'); + const enabled = settings.get('Accounts_OAuth_Nextcloud'); + if (!enabled) { return; } - config.serverURL = nextcloudURL.trim().replace(/\/*$/, ''); - return Nextcloud.configure(config); -}, 1000); + + const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL')?.trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Nextcloud_id'); + const clientSecret = settings.get('Accounts_OAuth_Nextcloud_secret'); + + if (!serverURL || !clientId || !clientSecret) { + return; + } + + const config = { ...NEXTCLOUD_PATHS, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('nextcloud', config); + return; + } + + Nextcloud.configure(config); +} Meteor.startup(() => { - settings.watch('Accounts_OAuth_Nextcloud_URL', () => fillServerURL()); + settings.watchMultiple( + [ + 'Accounts_OAuth_Nextcloud', + 'Accounts_OAuth_Nextcloud_URL', + 'Accounts_OAuth_Nextcloud_id', + 'Accounts_OAuth_Nextcloud_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + configureNextcloudOAuth, + ); }); diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js index 037150def37ee..2c8fff11a820b 100644 --- a/apps/meteor/server/configuration/accounts_meld.js +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -18,10 +18,6 @@ export async function configureAccounts() { } } - if (serviceName === 'linkedin') { - serviceData.email = serviceData.emailAddress; - } - if (serviceData.email) { const user = await Users.findOneByEmailAddress(serviceData.email); if (user != null && user.services?.[serviceName]?.id !== serviceData.id) { diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts new file mode 100644 index 0000000000000..b5d5a99fe5b66 --- /dev/null +++ b/apps/meteor/server/configuration/configurePassport.ts @@ -0,0 +1,104 @@ +import { Users } from '@rocket.chat/models'; +import bodyParser from 'body-parser'; +import MongoStore from 'connect-mongo'; +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import session from 'express-session'; +import { MongoInternals } from 'meteor/mongo'; +import { WebApp } from 'meteor/webapp'; +import passport from 'passport'; + +import type { ICachedSettings } from '../../app/settings/server/CachedSettings'; +import { configureOAuthServices } from '../lib/oauth/configureOAuthServices'; +import { createOAuthServiceConfig } from '../lib/oauth/createOAuthServiceConfig'; +import { getOAuthServices } from '../lib/oauth/getOAuthServices'; + +const oAuthPaths = ['/oauth', '/_oauth']; + +const { Router: router } = express; + +export const oAuthRouter = router(); + +const oAuthApp = express(); +oAuthApp.set('trust proxy', 1); + +export const configurePassport = (settings: ICachedSettings) => { + const { client } = MongoInternals.defaultRemoteCollectionDriver().mongo; + + oAuthApp.use( + oAuthPaths, + session({ + name: 'oauth', + secret: settings.get('Accounts_OAuth_Session_Secret'), + resave: false, + saveUninitialized: false, + proxy: true, + store: MongoStore.create({ + client, + collectionName: 'rocketchat_oauth_sessions', + ttl: 5 * 60, + autoRemove: 'native', + }), + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 5 * 60 * 1000, // 5 minutes + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + }, + }), + ); + + oAuthApp.use(oAuthPaths, passport.initialize()); + oAuthApp.use(oAuthPaths, passport.session()); + oAuthApp.use(oAuthPaths, bodyParser.urlencoded({ extended: true })); + + const createOAuthRateLimiter = () => + rateLimit({ + windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + skip: () => + process.env.TEST_MODE === 'true' || + process.env.TEST_MODE === 'api' || + settings.get('API_Enable_Rate_Limiter') !== true || + (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), + handler: (_req, res) => { + res.status(429).json({ + success: false, + error: 'Too many requests. Please try again later.', + }); + }, + }); + + let oauthRateLimiter = createOAuthRateLimiter(); + + settings.watchMultiple(['API_Enable_Rate_Limiter_Limit_Time_Default', 'API_Enable_Rate_Limiter_Limit_Calls_Default'], () => { + oauthRateLimiter = createOAuthRateLimiter(); + }); + + oAuthRouter.use(oAuthPaths, (req, res, next) => oauthRateLimiter(req, res, next)); + + // Register OAuth Routes + oAuthApp.use(oAuthRouter); + + passport.serializeUser((user: any, done) => { + done(null, user._id); + }); + + passport.deserializeUser(async (id, done) => { + const user = await Users.findOneById(id as string, { projection: { __rooms: 0 } }); + // we don’t actually use this user later + done(null, user); + }); + + settings.watchByRegex(/^(Accounts_OAuth_[a-z0-9_]+|API_GitHub_Enterprise_URL)$/i, () => { + if (!settings.get('Accounts_OAuth_Use_Modern_Flow')) { + return; + } + + const services = getOAuthServices(settings); + const oauthServiceConfigs = createOAuthServiceConfig(settings, services); + configureOAuthServices(oauthServiceConfigs, settings); + }); + + WebApp.rawConnectHandlers.use(oAuthApp); +}; diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts index 491c410e26f4a..79f3160120ba1 100644 --- a/apps/meteor/server/configuration/index.ts +++ b/apps/meteor/server/configuration/index.ts @@ -7,6 +7,7 @@ import { configureCORS } from './configureCORS'; import { configureDirectReply } from './configureDirectReply'; import { configureIRC } from './configureIRC'; import { configureLogLevel } from './configureLogLevel'; +import { configurePassport } from './configurePassport'; import { configureSMTP } from './configureSMTP'; import { configureLDAP } from './ldap'; import { configureOAuth } from './oauth'; @@ -28,5 +29,6 @@ export async function configureServer(settings: ICachedSettings) { configureDirectReply(settings), configureSMTP(settings), configureIRC(settings), + configurePassport(settings), ]); } diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index 38dafc69ec98f..47bcd1b2e05d7 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -33,6 +33,8 @@ import '../app/importer-slack-users/server'; import '../app/integrations/server'; import './bridges/irc'; import '../app/lib/server'; +import '../app/meteor-developer/server'; +import '../app/linkedin/server'; import '../app/token-login/server'; import '../app/mailer/server/api'; import '../app/markdown/server'; diff --git a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts new file mode 100644 index 0000000000000..36cf866983df2 --- /dev/null +++ b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts @@ -0,0 +1,60 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import passport from 'passport'; +import type { DoneCallback, Profile } from 'passport'; + +import { allowPassportOAuthMiddleware } from './allowPassportOAuthMiddleware'; +import { passportOAuthCallback } from './passportOAuthCallback'; +import { verifyFunction } from './verifyFunction'; +import { CustomOAuthStrategy } from '../../../app/custom-oauth/server/customOAuth'; +import { settings } from '../../../app/settings/server'; +import { oAuthRouter } from '../../configuration/configurePassport'; + +export const addPassportCustomOAuth = ( + serviceName: string, + config: Partial, + isCustomOAuth: boolean = false, +) => { + passport.unuse(serviceName); + + if (!config.clientId || !config.clientSecret || !config.serverURL) { + return; + } + + passport.use( + serviceName, + new CustomOAuthStrategy( + serviceName, + config as OAuthConfiguration & { clientSecret: string }, + (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => + verifyFunction(accessToken, refreshToken, profile, done, serviceName), + ), + ); + + const siteUrl = settings.get('Site_Url').replace(/\/$/, ''); + + oAuthRouter.get( + `/oauth/${serviceName}`, + allowPassportOAuthMiddleware(serviceName, isCustomOAuth), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + + oAuthRouter.get( + `/_oauth/${serviceName}`, + allowPassportOAuthMiddleware(serviceName, isCustomOAuth), + passport.authenticate(serviceName, { failureRedirect: '/login', failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl, config.loginStyle ? config.loginStyle : undefined), + ); +}; diff --git a/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts b/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts new file mode 100644 index 0000000000000..daee09f87d199 --- /dev/null +++ b/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts @@ -0,0 +1,20 @@ +import { capitalize } from '@rocket.chat/string-helpers'; +import type { NextFunction, Request, Response } from 'express'; + +import { settings } from '../../../app/settings/server'; + +export const allowPassportOAuthMiddleware = + (service: string, isCustomOAuth: boolean = false) => + (_req: Request, _res: Response, next: NextFunction) => { + const isPassportFlowEnabled = settings.get('Accounts_OAuth_Use_Modern_Flow'); + const settingPrefix = `${isCustomOAuth ? 'Accounts_OAuth_Custom-' : 'Accounts_OAuth_'}`; + const isOAuthServiceEnabled = settings.get( + `${settingPrefix}${service === 'github_enterprise' ? 'GitHub_Enterprise' : capitalize(service)}`, + ); + + if (!isPassportFlowEnabled || !isOAuthServiceEnabled) { + next('router'); + } else { + next(); + } + }; diff --git a/apps/meteor/server/lib/oauth/configureOAuthServices.ts b/apps/meteor/server/lib/oauth/configureOAuthServices.ts new file mode 100644 index 0000000000000..06e45ecfa183e --- /dev/null +++ b/apps/meteor/server/lib/oauth/configureOAuthServices.ts @@ -0,0 +1,90 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import passport from 'passport'; +import type { Profile, DoneCallback } from 'passport'; + +import { allowPassportOAuthMiddleware } from './allowPassportOAuthMiddleware'; +import type { OAuthServiceConfig } from './createOAuthServiceConfig'; +import { passportOAuthCallback } from './passportOAuthCallback'; +import type { ICachedSettings } from '../../../app/settings/server/CachedSettings'; +import { oAuthRouter } from '../../configuration/configurePassport'; + +export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], settings: ICachedSettings) => { + oauthServiceConfig.forEach((config) => { + const Strategy = config.strategy; + const siteUrl = settings.get('Site_Url').replace(/\/$/, ''); + + passport.unuse(config.provider); + + passport.use( + config.provider, + new Strategy( + { + ...config, + clientID: config.clientId, + clientSecret: config.clientSecret, + consumerKey: config.clientId, + consumerSecret: config.clientSecret, + callbackURL: `${siteUrl}/_oauth/${config.provider}`, + state: true, + pkce: true, + profileFields: ['id', 'displayName', 'emails'], + }, + async (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + config.provider, + { + accessToken, + refreshToken, + name: profile.displayName, + ...restProfile, + ..._json, + email: profile?.emails?.[0]?.value, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + }, + ), + ); + + oAuthRouter.get( + `/oauth/${config.provider}`, + allowPassportOAuthMiddleware(config.provider), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + oAuthRouter.get( + `/_oauth/${config.provider}`, + allowPassportOAuthMiddleware(config.provider), + passport.authenticate(config.provider, { failureRedirect: '/login', failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl), + ); + }); +}; diff --git a/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts new file mode 100644 index 0000000000000..3ea8213140b95 --- /dev/null +++ b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts @@ -0,0 +1,59 @@ +import { capitalize } from '@rocket.chat/string-helpers'; +import { isTruthy } from '@rocket.chat/tools'; +import type { Strategy } from 'passport'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; + +export type OAuthServiceConfig = { + provider: string; + strategy: new (...args: any[]) => Strategy; + clientId: string; + clientSecret: string; + scope?: string[]; +}; + +export const createOAuthServiceConfig = (settings: ICachedSettings, services: string[]): OAuthServiceConfig[] => { + return services + .map((service) => { + if (!OAuthConfigs[service]) { + return; + } + + if (service === 'github_enterprise') { + const clientId = settings.get('Accounts_OAuth_GitHub_Enterprise_id'); + const clientSecret = settings.get('Accounts_OAuth_GitHub_Enterprise_secret'); + const serverUrl = settings.get('API_GitHub_Enterprise_URL'); + + if (!clientId || !clientSecret || !serverUrl) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + authorizationURL: `${serverUrl}/login/oauth/authorize`, + tokenURL: `${serverUrl}/login/oauth/access_token`, + userProfileURL: `${serverUrl}/api/v3/user`, + strategy: OAuthConfigs.github_enterprise.strategy, + scope: OAuthConfigs.github_enterprise.scope, + }; + } + + const clientId = settings.get(`Accounts_OAuth_${capitalize(service)}_id`); + const clientSecret = settings.get(`Accounts_OAuth_${capitalize(service)}_secret`); + + if (!clientId || !clientSecret) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + ...OAuthConfigs[service], + }; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/getOAuthServices.ts b/apps/meteor/server/lib/oauth/getOAuthServices.ts new file mode 100644 index 0000000000000..7a431982d9f71 --- /dev/null +++ b/apps/meteor/server/lib/oauth/getOAuthServices.ts @@ -0,0 +1,34 @@ +import { isTruthy } from '@rocket.chat/tools'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; + +export const getOAuthServices = (settings: ICachedSettings) => { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean' && value === true); + return filteredServices + .map(([key, value]) => { + if (!value) { + return; + } + + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + + if (/Accounts_OAuth_Custom-/.test(key)) { + return; + } + + const serviceKey = serviceName.toLowerCase(); + + const oauthConfig = OAuthConfigs[serviceKey]; + if (!oauthConfig) { + return; + } + + return serviceKey; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/oauthConfigs.ts b/apps/meteor/server/lib/oauth/oauthConfigs.ts new file mode 100644 index 0000000000000..44fc2d16358db --- /dev/null +++ b/apps/meteor/server/lib/oauth/oauthConfigs.ts @@ -0,0 +1,36 @@ +import { Strategy as XStrategy } from '@rocket.chat/passport-x'; +import type { Strategy } from 'passport'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { Strategy as GitHubStrategy } from 'passport-github2'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; + +export type OAuthConfig = { + strategy: new (...args: any[]) => Strategy; + scope?: string[]; + includeEmail?: boolean; +}; + +export const OAuthConfigs: Record = { + github: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, + facebook: { + strategy: FacebookStrategy, + scope: ['email'], + }, + google: { + strategy: GoogleStrategy, + scope: ['email', 'profile'], + }, + twitter: { + strategy: XStrategy, + includeEmail: true, + }, + github_enterprise: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, +} as const; + +export type Provider = keyof typeof OAuthConfigs; diff --git a/apps/meteor/server/lib/oauth/passportOAuthCallback.ts b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts new file mode 100644 index 0000000000000..d3f0fdf916e83 --- /dev/null +++ b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts @@ -0,0 +1,108 @@ +import crypto from 'crypto'; + +import type { IUser } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LoginCodes } from '@rocket.chat/models'; +import type { Request, Response } from 'express'; +import { Accounts } from 'meteor/accounts-base'; + +import { doesUserRequire2FA } from './twoFactorAuth'; + +const logger = new Logger('OAuth'); + +type LaunchStyle = 'popup' | 'redirect' | undefined; + +const createLoginToken = async (userId: string) => { + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, stampedToken); + + return stampedToken.token; +}; + +const sendPopupMessage = (res: Response, payload: unknown) => { + const nonce = crypto.randomBytes(16).toString('base64'); + res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`); + + res.send(` + + `); +}; + +const handlePopupStyleOAuth = async (res: Response, oAuthUser: IUser, loginClient?: string) => { + const secondFactorMethod = doesUserRequire2FA(oAuthUser); + + if (secondFactorMethod) { + const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); + sendPopupMessage(res, { + externalCommand: 'go', + path: `/2fa/${secondFactorMethod.method}/${challengeId}${loginClient ? `?loginClient=${loginClient}` : ''}`, + }); + return; + } + + const token = await createLoginToken(oAuthUser._id); + + if (loginClient) { + sendPopupMessage(res, { + externalCommand: 'go', + path: `home?resumeToken=${token}&userId=${oAuthUser._id}&loginClient=${loginClient}`, + }); + return; + } + + sendPopupMessage(res, { externalCommand: 'login-with-token', token }); +}; + +export const passportOAuthCallback = + (siteUrl: string, launchStyle: LaunchStyle = 'redirect') => + async (req: Request, res: Response) => { + const oAuthUser = req.user as IUser; + + if (!oAuthUser) { + return res.redirect('/login'); + } + + const { loginClient } = req.session; + + if (launchStyle === 'popup') { + await handlePopupStyleOAuth(res, oAuthUser, loginClient); + return; + } + + const secondFactorMethod = doesUserRequire2FA(oAuthUser); + + if (secondFactorMethod) { + const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); + const twoFARedirectUrl = new URL(`/2fa/${secondFactorMethod.method}/${challengeId}`, siteUrl); + + if (loginClient) { + twoFARedirectUrl.searchParams.set('loginClient', loginClient); + } + + return res.redirect(twoFARedirectUrl.toString()); + } + + const loginCode = await LoginCodes.createCode(oAuthUser._id); + + const redirectUrl = new URL(`/home`, siteUrl); + + redirectUrl.searchParams.set('loginCode', loginCode); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + setImmediate(() => res.redirect(redirectUrl.toString())); + + req.session.destroy((err) => { + if (err) { + logger.error({ msg: 'Error destroying OAuth session', err }); + } + }); + }; diff --git a/apps/meteor/server/lib/oauth/twoFactorAuth.ts b/apps/meteor/server/lib/oauth/twoFactorAuth.ts new file mode 100644 index 0000000000000..2c97ff5fc5244 --- /dev/null +++ b/apps/meteor/server/lib/oauth/twoFactorAuth.ts @@ -0,0 +1,37 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { getRememberDate } from '../../../app/2fa/server/code'; +import { EmailCheckForOAuth } from '../../../app/2fa/server/code/EmailCheckForOAuth'; +import { TOTPCheckForOAuth } from '../../../app/2fa/server/code/TOTPCheckForOAuth'; + +export const emailCheckForOAuth = new EmailCheckForOAuth(); +export const totpCheckForOAuth = new TOTPCheckForOAuth(); + +const twoFACheckMethodsForOAuth = { + [emailCheckForOAuth.method]: emailCheckForOAuth, + [totpCheckForOAuth.method]: totpCheckForOAuth, +}; + +export const getTwoFAMethodForOAuth = (method: 'email' | 'totp') => { + return twoFACheckMethodsForOAuth[method]; +}; + +const getSecondFactorMethod = (user: IUser) => { + return Array.from(Object.values(twoFACheckMethodsForOAuth)).find((method) => method.isEnabled(user)); +}; + +export const doesUserRequire2FA = (user: IUser) => { + const rememberAfterRegistration = getRememberDate(user.createdAt); + + if (rememberAfterRegistration && rememberAfterRegistration > new Date()) { + return false; + } + + const secondFactorMethod = getSecondFactorMethod(user); + + if (!secondFactorMethod) { + return false; + } + + return secondFactorMethod; +}; diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index 393474fe4b24e..a01755e0a8cfe 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -7,6 +7,7 @@ import type { } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration } from '@rocket.chat/models'; +import { addPassportCustomOAuth } from './addPassportCustomOAuth'; import { logger } from './logger'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { @@ -19,6 +20,10 @@ export async function updateOAuthServices(): Promise { const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); for await (const [key, value] of filteredServices) { + if (key === 'Accounts_OAuth_Use_Modern_Flow') { + continue; + } + logger.debug({ oauth_updated: key }); let serviceName = key.replace('Accounts_OAuth_', ''); if (serviceName === 'Meteor') { @@ -68,7 +73,7 @@ export async function updateOAuthServices(): Promise { data.rolesToSync = settings.get(`${key}-roles_to_sync`); data.showButton = settings.get(`${key}-show_button`); - new CustomOAuth(serviceKey, { + const config = { serverURL: data.serverURL, tokenPath: data.tokenPath, identityPath: data.identityPath, @@ -93,7 +98,12 @@ export async function updateOAuthServices(): Promise { rolesToSync: data.rolesToSync, accessTokenParam: data.accessTokenParam, showButton: data.showButton, - }); + clientSecret: data.secret, + clientId: data.clientId, + }; + + new CustomOAuth(serviceKey, config); + addPassportCustomOAuth(serviceKey, config, true); } if (serviceName === 'Facebook') { (data as FacebookOAuthConfiguration).appId = data.clientId as string; diff --git a/apps/meteor/server/lib/oauth/verifyFunction.ts b/apps/meteor/server/lib/oauth/verifyFunction.ts new file mode 100644 index 0000000000000..c5f6fa873cafb --- /dev/null +++ b/apps/meteor/server/lib/oauth/verifyFunction.ts @@ -0,0 +1,40 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import type { DoneCallback, Profile } from 'passport'; + +export const verifyFunction = async ( + accessToken: string, + refreshToken: string, + profile: Profile, + done: DoneCallback, + serviceName: string, +) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + serviceName, + { + accessToken, + refreshToken, + name: profile.displayName, + ...profile, + ...restProfile, + ..._json, + email: profile?.emails?.[0]?.value, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); +}; diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 98eb364cecb38..9fdb6425d5eb5 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -50,6 +50,7 @@ import { OAuthAccessTokensRaw, OAuthAppsRaw, OAuthAuthCodesRaw, + LoginCodesRaw, OAuthRefreshTokensRaw, OEmbedCacheRaw, PermissionsRaw, @@ -75,6 +76,7 @@ import { WebdavAccountsRaw, WorkspaceCredentialsRaw, AbacAttributesRaw, + TwoFactorChallengesRaw, SamlUsedAssertionsRaw, } from '@rocket.chat/models'; import type { Collection } from 'mongodb'; @@ -138,6 +140,7 @@ registerModel('INpsVoteModel', new NpsVoteRaw(db)); registerModel('IOAuthAccessTokensModel', new OAuthAccessTokensRaw(db)); registerModel('IOAuthAppsModel', new OAuthAppsRaw(db)); registerModel('IOAuthAuthCodesModel', new OAuthAuthCodesRaw(db)); +registerModel('ILoginCodesModel', new LoginCodesRaw(db)); registerModel('IOAuthRefreshTokensModel', new OAuthRefreshTokensRaw(db)); registerModel('IOEmbedCacheModel', new OEmbedCacheRaw(db)); registerModel('IPermissionsModel', new PermissionsRaw(db, trashCollection)); @@ -162,4 +165,5 @@ registerModel('IVideoConferenceModel', new VideoConferenceRaw(db)); registerModel('IWebdavAccountsModel', new WebdavAccountsRaw(db)); registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); registerModel('IAbacAttributesModel', new AbacAttributesRaw(db)); +registerModel('ITwoFactorChallengesModel', new TwoFactorChallengesRaw(db)); registerModel('ISamlUsedAssertionsModel', new SamlUsedAssertionsRaw(db)); diff --git a/apps/meteor/server/settings/oauth.ts b/apps/meteor/server/settings/oauth.ts index 2aea9b119cf83..24b9a95579c60 100644 --- a/apps/meteor/server/settings/oauth.ts +++ b/apps/meteor/server/settings/oauth.ts @@ -1,7 +1,16 @@ +import { Random } from '@rocket.chat/random'; + import { settingsRegistry } from '../../app/settings/server'; export const createOauthSettings = () => settingsRegistry.addGroup('OAuth', async function () { + await this.add('Accounts_OAuth_Use_Modern_Flow', false, { + type: 'boolean', + public: true, + i18nLabel: 'Accounts_OAuth_Use_Modern_Flow_Label', + i18nDescription: 'Accounts_OAuth_Use_Modern_Flow_Description', + }); + await this.section('Drupal', async function () { const enableQuery = { _id: 'Accounts_OAuth_Drupal', @@ -405,6 +414,11 @@ export const createOauthSettings = () => enableQuery, }); }); + await this.add('Accounts_OAuth_Session_Secret', Random.secret(), { + type: 'string', + secret: true, + hidden: true, + }); return this.section('Proxy', async function () { await this.add('Accounts_OAuth_Proxy_host', 'https://oauth-proxy.rocket.chat', { type: 'string', diff --git a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts index 5721266b728d5..70885f1a634fb 100644 --- a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts +++ b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts @@ -15,4 +15,7 @@ export default async function addCustomOAuth(): Promise { await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test`, { data: { value: false }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-url`, { data: { value: 'https://rocket.chat' }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-login_style`, { data: { value: 'redirect' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-id`, { data: { value: 'abc' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-secret`, { data: { value: 'def' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-authorize_path`, { data: { value: '/authorize' }, headers }); } diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index 6257d9d1a2b41..bd716319b7e67 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -117,6 +117,7 @@ test.describe('OAuth', () => { await expect((await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', 'test')).status()).toBe(200); await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect')).status()).toBe(200); await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Use_Modern_Flow', false)).status()).toBe(200); await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('redirect'); }); @@ -124,6 +125,7 @@ test.describe('OAuth', () => { await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', ''); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Use_Modern_Flow', true)).status()).toBe(200); }); test('redirect login through the proxy', async ({ page }) => { diff --git a/apps/meteor/tests/end-to-end/api/login-code.ts b/apps/meteor/tests/end-to-end/api/login-code.ts new file mode 100644 index 0000000000000..1215b5b40e8d0 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/login-code.ts @@ -0,0 +1,140 @@ +import { randomBytes } from 'crypto'; + +import type { ILoginCode, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { api, request, getCredentials } from '../../data/api-data'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser } from '../../data/users.helper'; +import { URL_MONGODB } from '../../e2e/config/constants'; + +const CODE_TTL_MS = 60 * 1000; + +async function insertLoginCode(connection: MongoClient, userId: string, insertExpiredToken: boolean = false): Promise { + const now = new Date(); + const code = randomBytes(32).toString('hex'); + + let expireAt = new Date(now.getTime() + CODE_TTL_MS); + + if (insertExpiredToken) { + expireAt = new Date(now.getTime() - 1); + } + + await connection.db().collection('rocketchat_login_codes').insertOne({ + _id: code, + userId, + createdAt: now, + expireAt, + }); + + return code; +} + +describe('[Login Codes]', () => { + let connection: MongoClient; + + before((done) => getCredentials(done)); + + before(async () => { + connection = await MongoClient.connect(URL_MONGODB); + }); + + after(async () => { + await connection.close(); + }); + + describe('POST [/loginCode.redeem]', () => { + let testUser: TestUser; + + before(async () => { + testUser = await createUser(); + }); + + after(async () => { + await deleteUser(testUser); + }); + + it('should fail when code does not exist in the database', async () => { + await request + .post(api('loginCode.redeem')) + .send({ code: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail when code is an empty string', async () => { + await request + .post(api('loginCode.redeem')) + .send({ code: '' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should return a loginToken and userId for a valid code', async () => { + const code = await insertLoginCode(connection, testUser._id); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('loginToken').that.is.a('string').and.is.not.empty; + expect(res.body).to.have.property('userId', testUser._id); + }); + }); + + it('should invalidate a code after it has been redeemed', async () => { + const code = await insertLoginCode(connection, testUser._id); + + await request.post(api('loginCode.redeem')).send({ code }).expect(200); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-code'); + }); + }); + + it('should return a loginToken that can be used for subsequent authenticated requests', async () => { + const code = await insertLoginCode(connection, testUser._id); + + const redeemRes = await request.post(api('loginCode.redeem')).send({ code }).expect(200); + + const { loginToken, userId } = redeemRes.body; + + await request + .get(api('me')) + .set('X-Auth-Token', loginToken) + .set('X-User-Id', userId) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', testUser._id); + }); + }); + + it('should fail when code is expired', async () => { + const code = await insertLoginCode(connection, testUser._id, true); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-code'); + }); + }); + }); +}); diff --git a/packages/core-typings/src/ILoginCode.ts b/packages/core-typings/src/ILoginCode.ts new file mode 100644 index 0000000000000..e636da58ad7c0 --- /dev/null +++ b/packages/core-typings/src/ILoginCode.ts @@ -0,0 +1,6 @@ +export interface ILoginCode { + _id: string; + userId: string; + createdAt: Date; + expireAt: Date; +} diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 76a4779e806e9..c3fb05fa8e18f 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -34,6 +34,11 @@ export type OAuthConfiguration = { mergeRoles: boolean; rolesToSync: string; showButton: boolean; + addAutopublishFields?: { + forLoggedInUser: string[]; + forOtherUsers: string[]; + }; + pkce?: boolean; }; export type FacebookOAuthConfiguration = Omit, 'clientId'> & { diff --git a/packages/core-typings/src/ITwoFactorChallenge.ts b/packages/core-typings/src/ITwoFactorChallenge.ts new file mode 100644 index 0000000000000..783309001ee5d --- /dev/null +++ b/packages/core-typings/src/ITwoFactorChallenge.ts @@ -0,0 +1,7 @@ +export interface ITwoFactorChallenge { + _id: string; + userId: string; + method: 'email' | 'totp'; + expireAt: Date; + createdAt: Date; +} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index d39969fe266ee..3b5f908bca857 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -243,7 +243,7 @@ export interface IUser extends IRocketChatRecord { isOAuthUser?: boolean; // client only field __rooms?: string[]; inactiveReason?: 'deactivated' | 'pending_approval' | 'idle_too_long'; - + providerId?: string; abacAttributes?: IAbacAttributeDefinition[]; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 14170eb7d971f..f44681ebc9f60 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -54,6 +54,7 @@ export type * from './IEmojiCustom'; export type * from './ICustomEmojiDescriptor'; export type * from './IAnalytics'; export type * from './ICredentialToken'; +export type * from './ILoginCode'; export type * from './ISamlUsedAssertions'; export type * from './IAvatar'; export type * from './ICustomUserStatus'; @@ -136,3 +137,5 @@ export type * from './ServerAudit/IAuditServerAbacAction'; export type * from './ServerAudit/IAuditUserChangedEvent'; export { schemas } from './Ajv'; + +export type * from './ITwoFactorChallenge'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 0727bbbcc45d6..00a7147e1afd5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -375,6 +375,8 @@ "Accounts_OAuth_Wordpress_server_type_wordpress_com": "Wordpress.com", "Accounts_OAuth_Wordpress_server_type_wp_oauth_server": "WP OAuth Server Plugin", "Accounts_OAuth_Wordpress_token_path": "Token Path", + "Accounts_OAuth_Use_Modern_Flow_Label": "Use Modern OAuth Flow", + "Accounts_OAuth_Use_Modern_Flow_Description": "When enabled, OAuth authentication uses the modern Passport-based flow with system browser support on mobile and deep-link callbacks (recommended). When disabled, the original Meteor OAuth implementation is used for backward compatibility.", "Accounts_PasswordReset": "Password Reset", "Accounts_Password_Policy_AtLeastOneLowercase": "At Least One Lowercase", "Accounts_Password_Policy_AtLeastOneLowercase_Description": "Enforce that a password contain at least one lowercase character.", @@ -1104,6 +1106,7 @@ "Categories": "Categories", "Categories*": "Categories*", "Certificates_and_Keys": "Certificates and Keys", + "Challenge_expired_please_try_again_later": "Challenge expired. Please try again later.", "Change_Room_Type": "Changing the Room Type", "Changed_from": "Changed from", "Changed_to": "Changed to", @@ -3190,6 +3193,7 @@ "Leave_the_current_channel": "Leave the current channel", "Leave_the_description_field_blank_if_you_dont_want_to_show_the_role": "Leave the description field blank if you don't want to show the role", "Left": "Left", + "Legacy_Meteor_OAuth": "Legacy Meteor OAuth", "Let_moderators_know_what_the_issue_is": "Let moderators know what the issue is", "Let_them_know": "Let them know", "Lets_get_you_new_one_": "Let's get you a new one!", @@ -3458,6 +3462,7 @@ "Max_number_of_chats_per_agent_description": "The max. number of simultaneous chats that the agents can attend", "Max_number_of_uses": "Max number of uses", "Maximum": "Maximum", + "Maximum_number_of_attempts_reached_please_try_again_later": "Maximum number of attempts reached. Please try again later.", "Maximum_number_of_guests_reached": "Maximum number of guests reached", "Me": "Me", "Media": "Media", @@ -3708,6 +3713,7 @@ "Moderation_User_deactivated": "User deactivated", "Moderation_User_deleted_warning": "The user who sent the message(s) no longer exists or has been removed.", "Moderators": "Moderators", + "Modern_OAuth_System_Browser_and_Deep_Links_Recommended": "Modern OAuth (System Browser & Deep Links) (Recommended)", "Monday": "Monday", "MongoDB": "MongoDB", "MongoDB_Deprecated": "MongoDB Deprecated", @@ -6918,6 +6924,7 @@ "registration.component.form.usernameAlreadyInUse": "Username already in use", "registration.component.form.usernameContainsInvalidChars": "Username contains invalid characters", "registration.component.login": "Login", + "registration.component.login.onWeb": "Login on web", "registration.component.login.incorrectPassword": "Incorrect password", "registration.component.login.userNotFound": "User not found", "registration.component.resetPassword": "Reset password", diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index d6638a92c272c..04fa2cb0cff6e 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -6,6 +6,7 @@ export type * from './models/IBaseModel'; export type * from './models/IBaseUploadsModel'; export type * from './models/ICannedResponseModel'; export type * from './models/ICredentialTokensModel'; +export type * from './models/ILoginCodesModel'; export type * from './models/ICustomSoundsModel'; export type * from './models/ICustomUserStatusModel'; export type * from './models/IEmailInboxModel'; @@ -81,4 +82,5 @@ export type * from './updater'; export type * from './models/IWorkspaceCredentialsModel'; export type * from './models/ICallHistoryModel'; export type * from './models/IAbacAttributesModel'; +export type * from './models/ITwoFactorChallengesModel'; export type * from './models/ISamlUsedAssertionsModel'; diff --git a/packages/model-typings/src/models/ILoginCodesModel.ts b/packages/model-typings/src/models/ILoginCodesModel.ts new file mode 100644 index 0000000000000..16228ecda6d62 --- /dev/null +++ b/packages/model-typings/src/models/ILoginCodesModel.ts @@ -0,0 +1,8 @@ +import type { ILoginCode } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export interface ILoginCodesModel extends IBaseModel { + createCode(userId: string): Promise; + findOneNotExpiredByCodeAndDelete(code: string): Promise; +} diff --git a/packages/model-typings/src/models/ITwoFactorChallengesModel.ts b/packages/model-typings/src/models/ITwoFactorChallengesModel.ts new file mode 100644 index 0000000000000..e0f523ae49104 --- /dev/null +++ b/packages/model-typings/src/models/ITwoFactorChallengesModel.ts @@ -0,0 +1,10 @@ +import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; +import type { DeleteResult, FindOptions } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface ITwoFactorChallengesModel extends IBaseModel { + findOneByPendingChallengeId(id: string, options?: FindOptions): Promise; + removeByPendingChallengeId(id: string): Promise; + createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index a54acad092a2a..698c14d572f17 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -40,6 +40,7 @@ import type { IOAuthAppsModel, IOAuthAuthCodesModel, IOAuthAccessTokensModel, + ILoginCodesModel, IOAuthRefreshTokensModel, IOEmbedCacheModel, IPushTokenModel, @@ -80,6 +81,7 @@ import type { IMediaCallNegotiationsModel, ICallHistoryModel, IAbacAttributesModel, + ITwoFactorChallengesModel, ISamlUsedAssertionsModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -175,6 +177,7 @@ export const NpsVote = proxify('INpsVoteModel'); export const OAuthApps = proxify('IOAuthAppsModel'); export const OAuthAuthCodes = proxify('IOAuthAuthCodesModel'); export const OAuthAccessTokens = proxify('IOAuthAccessTokensModel'); +export const LoginCodes = proxify('ILoginCodesModel'); export const OAuthRefreshTokens = proxify('IOAuthRefreshTokensModel'); export const OEmbedCache = proxify('IOEmbedCacheModel'); export const PushToken = proxify('IPushTokenModel'); @@ -209,6 +212,7 @@ export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); export const WorkspaceCredentials = proxify('IWorkspaceCredentialsModel'); export const AbacAttributes = proxify('IAbacAttributesModel'); +export const TwoFactorChallenges = proxify('ITwoFactorChallengesModel'); export const SamlUsedAssertions = proxify('ISamlUsedAssertionsModel'); export function registerServiceModels(db: Db, trash?: Collection>): void { diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 0807ea23a5950..c45d811ec2645 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -41,6 +41,7 @@ export * from './models/NpsVote'; export * from './models/OAuthAccessTokens'; export * from './models/OAuthApps'; export * from './models/OAuthAuthCodes'; +export * from './models/LoginCodes'; export * from './models/OAuthRefreshTokens'; export * from './models/OEmbedCache'; export * from './models/Permissions'; @@ -74,3 +75,4 @@ export * from './models/MediaCallNegotiations'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; export * from './models/CallHistory'; +export * from './models/TwoFactorChallenges'; diff --git a/packages/models/src/models/LoginCodes.ts b/packages/models/src/models/LoginCodes.ts new file mode 100644 index 0000000000000..9f33da823b64d --- /dev/null +++ b/packages/models/src/models/LoginCodes.ts @@ -0,0 +1,38 @@ +import { randomBytes } from 'crypto'; + +import type { ILoginCode } from '@rocket.chat/core-typings'; +import type { ILoginCodesModel } from '@rocket.chat/model-typings'; +import type { Db, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +const CODE_TTL_MS = 60 * 1000; + +export class LoginCodesRaw extends BaseRaw implements ILoginCodesModel { + constructor(db: Db) { + super(db, 'login_codes'); + } + + override modelIndexes(): IndexDescription[] { + return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }]; + } + + async createCode(userId: string): Promise { + const now = new Date(); + const code = randomBytes(32).toString('hex'); + await this.insertOne({ + _id: code, + userId, + createdAt: now, + expireAt: new Date(now.getTime() + CODE_TTL_MS), + }); + return code; + } + + findOneNotExpiredByCodeAndDelete(code: string): Promise { + return this.findOneAndDelete({ + _id: code, + expireAt: { $gt: new Date() }, + }); + } +} diff --git a/packages/models/src/models/TwoFactorChallenges.ts b/packages/models/src/models/TwoFactorChallenges.ts new file mode 100644 index 0000000000000..2d4fb748c1d15 --- /dev/null +++ b/packages/models/src/models/TwoFactorChallenges.ts @@ -0,0 +1,38 @@ +import { randomBytes } from 'crypto'; + +import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; +import type { ITwoFactorChallengesModel } from '@rocket.chat/model-typings'; +import type { Db, FindOptions, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class TwoFactorChallengesRaw extends BaseRaw implements ITwoFactorChallengesModel { + constructor(db: Db) { + super(db, 'two_factor_challenges'); + } + + override modelIndexes(): IndexDescription[] { + return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }]; + } + + findOneByPendingChallengeId(pendingChallengeId: string, options?: FindOptions) { + return this.findOne({ _id: pendingChallengeId }, options); + } + + removeByPendingChallengeId(pendingChallengeId: string) { + return this.deleteOne({ _id: pendingChallengeId }); + } + + async createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise { + const now = new Date(); + const challengeId = randomBytes(32).toString('hex'); + await this.insertOne({ + _id: challengeId, + userId, + method, + createdAt: now, + expireAt: new Date(now.getTime() + 1000 * 60 * 5), + }); + return challengeId; + } +} diff --git a/packages/passport-x/jest.config.ts b/packages/passport-x/jest.config.ts new file mode 100644 index 0000000000000..90df717c76945 --- /dev/null +++ b/packages/passport-x/jest.config.ts @@ -0,0 +1,12 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + projects: [ + { + displayName: 'server', + preset: server.preset, + testMatch: ['/src/**/*.spec.[jt]s?(x)'], + }, + ], +} satisfies Config; diff --git a/packages/passport-x/package.json b/packages/passport-x/package.json new file mode 100644 index 0000000000000..5c4715edfdd7a --- /dev/null +++ b/packages/passport-x/package.json @@ -0,0 +1,31 @@ +{ + "name": "@rocket.chat/passport-x", + "version": "0.0.1", + "private": true, + "description": "Fork of passport-twitter for X (Twitter) OAuth", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "testunit": "jest" + }, + "dependencies": { + "passport-oauth1": "~1.3.0" + }, + "devDependencies": { + "@types/express": "^4.17.25", + "@types/passport": "~1.0.17", + "eslint": "~9.39.4", + "jest": "~30.2.0", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/passport-x/src/APIError.ts b/packages/passport-x/src/APIError.ts new file mode 100644 index 0000000000000..cf1583a647a26 --- /dev/null +++ b/packages/passport-x/src/APIError.ts @@ -0,0 +1,15 @@ +/** + * `APIError` error. + */ +export class APIError extends Error { + public override readonly name: string = 'APIError'; + + public status: number = 500; + + constructor( + message: string, + public readonly code: number, + ) { + super(message); + } +} diff --git a/packages/passport-x/src/Strategy.spec.ts b/packages/passport-x/src/Strategy.spec.ts new file mode 100644 index 0000000000000..63f28979148c8 --- /dev/null +++ b/packages/passport-x/src/Strategy.spec.ts @@ -0,0 +1,99 @@ +import type { Request } from 'express'; + +import { Strategy } from './Strategy'; + +const defaultOptions = { + consumerKey: 'ABC123', + consumerSecret: 'secret', + callbackURL: 'http://www.example.test/callback', +}; + +function noop() { + // verify callback placeholder +} + +describe('Strategy', () => { + describe('constructed', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should be named twitter', () => { + expect(strategy.name).toBe('twitter'); + }); + }); + + describe('constructed with undefined options', () => { + it('should throw', () => { + expect(() => { + // @ts-expect-error testing invalid input + new Strategy(undefined, noop); + }).toThrow(); + }); + }); + + describe('failure caused by user denying request', () => { + it('should call fail()', () => { + const strategy = new Strategy(defaultOptions, noop); + strategy.fail = jest.fn(); + + const req = { query: { denied: '8L74Y149' } } as unknown as Request; + strategy.authenticate(req); + + expect(strategy.fail).toHaveBeenCalled(); + }); + }); + + describe('userAuthorizationParams', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should return empty object when no options', () => { + expect(strategy.userAuthorizationParams({})).toEqual({}); + }); + + it('should map forceLogin to force_login', () => { + const params = strategy.userAuthorizationParams({ forceLogin: true }); + expect(params).toEqual({ force_login: true }); + }); + + it('should map screenName to screen_name', () => { + const params = strategy.userAuthorizationParams({ screenName: 'bob' }); + expect(params).toEqual({ screen_name: 'bob' }); + }); + + it('should map both options', () => { + const params = strategy.userAuthorizationParams({ forceLogin: true, screenName: 'bob' }); + expect(params).toEqual({ force_login: true, screen_name: 'bob' }); + }); + }); + + describe('parseErrorResponse', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should parse JSON error response', () => { + const body = '{"errors":[{"code":32,"message":"Could not authenticate you."}]}'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Could not authenticate you.'); + }); + + it('should parse XML error response', () => { + const body = + '\n\n This client application\'s callback url has been locked\n /oauth/request_token\n\n'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe("This client application's callback url has been locked"); + }); + + it('should return plain text as error when body is not JSON or XML', () => { + const body = 'Invalid request token.'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Invalid request token.'); + }); + + it('should return undefined for JSON without errors array', () => { + const body = '{"foo":"bar"}'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeUndefined(); + }); + }); +}); diff --git a/packages/passport-x/src/Strategy.ts b/packages/passport-x/src/Strategy.ts new file mode 100644 index 0000000000000..3c59aa39446aa --- /dev/null +++ b/packages/passport-x/src/Strategy.ts @@ -0,0 +1,228 @@ +import { format, parse as urlParse } from 'url'; + +import type { Request } from 'express'; +import OAuthStrategy, { InternalOAuthError, type OAuthClient } from 'passport-oauth1'; + +import { APIError } from './APIError'; +import { parse as parseProfile } from './profile'; +import type { Profile } from './profile'; + +// --------------------------------------------------------------------------- +// Strategy options +// --------------------------------------------------------------------------- + +export interface IStrategyOptions { + consumerKey: string; + consumerSecret: string; + callbackURL: string; + requestTokenURL?: string; + accessTokenURL?: string; + userAuthorizationURL?: string; + sessionKey?: string; + userProfileURL?: string; + skipExtendedUserProfile?: boolean; + includeEmail?: boolean; + includeStatus?: boolean; + includeEntities?: boolean; + forceLogin?: boolean; + screenName?: string; +} + +// --------------------------------------------------------------------------- +// Strategy +// --------------------------------------------------------------------------- + +type DoneCallback = (err: Error | null, profile?: Profile | { provider: string; id: string; username: string }) => void; + +/** + * `Strategy` constructor. + * + * The Twitter/X authentication strategy authenticates requests by delegating to + * Twitter using the OAuth protocol. + * + * Applications must supply a `verify` callback which accepts a `token`, + * `tokenSecret` and service-specific `profile`, and then calls the `cb` + * callback supplying a `user`, which should be set to `false` if the + * credentials are not valid. If an exception occurred, `err` should be set. + * + * Options: + * - `consumerKey` identifies client to Twitter + * - `consumerSecret` secret used to establish ownership of the consumer key + * - `callbackURL` URL to which Twitter will redirect the user after obtaining authorization + * + * Examples: + * + * passport.use(new Strategy({ + * consumerKey: '123-456-789', + * consumerSecret: 'shhh-its-a-secret', + * callbackURL: 'https://www.example.net/auth/twitter/callback' + * }, + * function(token, tokenSecret, profile, cb) { + * User.findOrCreate(..., function (err, user) { + * cb(err, user); + * }); + * } + * )); + */ +export class Strategy { + public name: string; + + private _userProfileURL: string; + + private _skipExtendedUserProfile: boolean; + + private _includeEmail: boolean; + + private _includeStatus: boolean; + + private _includeEntities: boolean; + + // Provided by OAuthStrategy base + declare _oauth: OAuthClient; + + declare fail: (challenge?: unknown, status?: number) => void; + + declare error: (err: Error) => void; + + declare success: (user: unknown, info?: unknown) => void; + + declare redirect: (url: string, status?: number) => void; + + constructor(options: IStrategyOptions, verify: (...args: unknown[]) => void) { + const opts: Record = { + ...options, + requestTokenURL: options.requestTokenURL || 'https://api.twitter.com/oauth/request_token', + accessTokenURL: options.accessTokenURL || 'https://api.twitter.com/oauth/access_token', + userAuthorizationURL: options.userAuthorizationURL || 'https://api.twitter.com/oauth/authenticate', + sessionKey: options.sessionKey || 'oauth:twitter', + }; + + OAuthStrategy.call(this as unknown as OAuthStrategy, opts, verify); + this.name = 'twitter'; + this._userProfileURL = options.userProfileURL || 'https://api.twitter.com/1.1/account/verify_credentials.json'; + this._skipExtendedUserProfile = options.skipExtendedUserProfile !== undefined ? options.skipExtendedUserProfile : false; + this._includeEmail = options.includeEmail !== undefined ? options.includeEmail : false; + this._includeStatus = options.includeStatus !== undefined ? options.includeStatus : true; + this._includeEntities = options.includeEntities !== undefined ? options.includeEntities : true; + } + + /** + * Authenticate request by delegating to Twitter using OAuth. + */ + authenticate(req: Request, options?: Record): void { + if (req.query && (req.query as Record).denied) { + this.fail(); + return; + } + + OAuthStrategy.prototype.authenticate.call(this as unknown as OAuthStrategy, req, options); + } + + /** + * Retrieve user profile from Twitter. + */ + userProfile(token: string, tokenSecret: string, params: Record, done: DoneCallback): void { + if (!this._skipExtendedUserProfile) { + const url = urlParse(this._userProfileURL, true); + + if (url.pathname?.endsWith('/users/show.json')) { + url.query.user_id = params.user_id; + } + if (this._includeEmail) { + url.query.include_email = 'true'; + } + if (!this._includeStatus) { + url.query.skip_status = 'true'; + } + if (!this._includeEntities) { + url.query.include_entities = 'false'; + } + url.search = null; + + this._oauth.get(format(url), token, tokenSecret, (err, body, res) => { + if (err) { + let json: { errors?: { message: string; code: number }[] } | undefined; + const oauthErr = err; + if (oauthErr.data) { + try { + json = JSON.parse(oauthErr.data); + } catch { + // ignore parse error + } + } + + if (json?.errors?.length) { + const e = json.errors[0]; + return done(new APIError(e.message, e.code)); + } + return done(new InternalOAuthError('Failed to fetch user profile', err)); + } + + let json: Record; + try { + json = JSON.parse(body!); + } catch { + return done(new Error('Failed to parse user profile')); + } + + const profile: Profile = { + ...parseProfile(json), + provider: 'twitter', + _raw: body!, + _json: json, + _accessLevel: res?.headers['x-access-level'], + }; + + return done(null, profile); + }); + } else { + done(null, { + provider: 'twitter', + id: params.user_id, + username: params.screen_name, + }); + } + } + + /** + * Return extra Twitter-specific parameters to be included in the user + * authorization request. + */ + userAuthorizationParams(options: Record): Record { + const params: Record = {}; + if (options.forceLogin) { + params.force_login = options.forceLogin; + } + if (options.screenName) { + params.screen_name = options.screenName; + } + return params; + } + + /** + * Parse error response from Twitter OAuth endpoint. + */ + parseErrorResponse(body: string, _status: number): Error | undefined { + try { + const json: unknown = JSON.parse(body); + if (typeof json === 'object' && json !== null && 'errors' in json && Array.isArray(json.errors) && json.errors.length > 0) { + const first: unknown = json.errors[0]; + if (typeof first === 'object' && first !== null && 'message' in first && typeof first.message === 'string') { + return new Error(first.message); + } + } + } catch { + // Not JSON — try XML + const match = /(.*?)<\/error>/.exec(body); + if (match) { + return new Error(match[1]); + } + return new Error(body); + } + + return undefined; + } +} + +// Wire up prototype chain: Strategy extends OAuthStrategy +Object.setPrototypeOf(Strategy.prototype, OAuthStrategy.prototype); diff --git a/packages/passport-x/src/Strategy.userProfile.spec.ts b/packages/passport-x/src/Strategy.userProfile.spec.ts new file mode 100644 index 0000000000000..86d50a91de959 --- /dev/null +++ b/packages/passport-x/src/Strategy.userProfile.spec.ts @@ -0,0 +1,197 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { APIError } from './APIError'; +import { Strategy } from './Strategy'; +import type { Profile } from './profile'; + +function loadFixture(relativePath: string): string { + return readFileSync(join(__dirname, '__fixtures__', relativePath), 'utf8'); +} + +const defaultOptions = { + consumerKey: 'ABC123', + consumerSecret: 'secret', + callbackURL: 'http://www.example.test/callback', +}; + +function noop() { + // verify callback placeholder +} + +function getUserProfile( + strategy: Strategy, + token: string, + tokenSecret: string, + params: Record, +): Promise<{ err: Error | null; profile?: Profile | { provider: string; id: string; username: string } }> { + return new Promise((resolve) => { + strategy.userProfile(token, tokenSecret, params, (err, profile) => { + resolve({ err, profile }); + }); + }); +} + +describe('Strategy#userProfile', () => { + describe('fetched from default endpoint', () => { + const strategy = new Strategy(defaultOptions, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should parse profile', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + const p = profile as Profile; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('38895958'); + expect(p.username).toBe('theSeanCook'); + expect(p.displayName).toBe('Sean Cook'); + expect(typeof p._raw).toBe('string'); + expect(typeof p._json).toBe('object'); + expect(p._accessLevel).toBe('read'); + }); + }); + + describe('fetched from default endpoint, with email included', () => { + const strategy = new Strategy({ ...defaultOptions, includeEmail: true }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('include_email=true'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include email param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from default endpoint, with status excluded', () => { + const strategy = new Strategy({ ...defaultOptions, includeStatus: false }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('skip_status=true'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include skip_status param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from default endpoint, with entities excluded', () => { + const strategy = new Strategy({ ...defaultOptions, includeEntities: false }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('include_entities=false'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include include_entities param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from legacy users/show endpoint', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + const fixtureBody = loadFixture('users/rsarver.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('user_id=6253282'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should parse profile and include user_id in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + const p = profile as Profile; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('795649'); + expect(p.username).toBe('rsarver'); + expect(p.displayName).toBe('Ryan Sarver'); + expect(p._accessLevel).toBe('read'); + }); + }); + + describe('skipping extended profile', () => { + const strategy = new Strategy({ ...defaultOptions, skipExtendedUserProfile: true }, noop); + + strategy._oauth.get = () => { + throw new Error('should not fetch profile'); + }; + + it('should return minimal profile from params', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { + user_id: '1705', + screen_name: 'jaredhanson', + }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + const p = profile as { provider: string; id: string; username: string }; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('1705'); + expect(p.username).toBe('jaredhanson'); + }); + }); + + describe('error caused by invalid token', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + const body = '{"errors":[{"message":"Invalid or expired token","code":89}]}'; + callback({ statusCode: 401, data: body }); + }; + + it('should return APIError', async () => { + const { err, profile } = await getUserProfile(strategy, 'x-token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(APIError); + expect(err?.message).toBe('Invalid or expired token'); + expect((err as APIError).code).toBe(89); + expect((err as APIError).status).toBe(500); + expect(profile).toBeUndefined(); + }); + }); + + describe('error caused by malformed response', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(null, 'Hello, world.', undefined); + }; + + it('should return parse error', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Failed to parse user profile'); + expect(profile).toBeUndefined(); + }); + }); + + describe('internal error', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(new Error('something went wrong')); + }; + + it('should return InternalOAuthError', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(Error); + expect(err?.constructor.name).toBe('InternalOAuthError'); + expect(err?.message).toBe('Failed to fetch user profile'); + expect(profile).toBeUndefined(); + }); + }); +}); diff --git a/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json b/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json new file mode 100644 index 0000000000000..66ec3b3e85d92 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json @@ -0,0 +1,8 @@ +{ + "id": 38895958, + "id_str": "38895958", + "name": "Sean Cook", + "screen_name": "theSeanCook", + "email": "theSeanCook@example.test", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG" +} diff --git a/packages/passport-x/src/__fixtures__/account/theSeanCook.json b/packages/passport-x/src/__fixtures__/account/theSeanCook.json new file mode 100644 index 0000000000000..f78c91bdfdbb3 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/account/theSeanCook.json @@ -0,0 +1,7 @@ +{ + "id": 38895958, + "id_str": "38895958", + "name": "Sean Cook", + "screen_name": "theSeanCook", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG" +} diff --git a/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json b/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json new file mode 100644 index 0000000000000..ae79194a0f0f0 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json @@ -0,0 +1,6 @@ +{ + "id": 795649, + "name": "Ryan Sarver", + "screen_name": "rsarver", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png" +} diff --git a/packages/passport-x/src/__fixtures__/users/rsarver.json b/packages/passport-x/src/__fixtures__/users/rsarver.json new file mode 100644 index 0000000000000..7fede20e276d7 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/users/rsarver.json @@ -0,0 +1,7 @@ +{ + "id": 795649, + "id_str": "795649", + "name": "Ryan Sarver", + "screen_name": "rsarver", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png" +} diff --git a/packages/passport-x/src/index.ts b/packages/passport-x/src/index.ts new file mode 100644 index 0000000000000..6d2e6af0ab60d --- /dev/null +++ b/packages/passport-x/src/index.ts @@ -0,0 +1,3 @@ +export { Strategy } from './Strategy'; +export type { IStrategyOptions as StrategyOptions } from './Strategy'; +export type { Profile } from './profile'; diff --git a/packages/passport-x/src/passport-oauth1.d.ts b/packages/passport-x/src/passport-oauth1.d.ts new file mode 100644 index 0000000000000..9953d434db26e --- /dev/null +++ b/packages/passport-x/src/passport-oauth1.d.ts @@ -0,0 +1,41 @@ +declare module 'passport-oauth1' { + import type { Request } from 'express'; + + interface OAuthGetCallback { + (err: OAuthError | null, body?: string, res?: { headers: Record }): void; + } + + interface OAuthError { + statusCode?: number; + data?: string; + } + + interface OAuthClient { + get(url: string, token: string, tokenSecret: string, callback: OAuthGetCallback): void; + } + + class OAuthStrategy { + name: string; + + _oauth: OAuthClient; + + constructor(options: Record, verify: (...args: unknown[]) => void); + + authenticate(req: Request, options?: Record): void; + + fail(challenge?: unknown, status?: number): void; + + error(err: Error): void; + + success(user: unknown, info?: unknown): void; + + redirect(url: string, status?: number): void; + } + + class InternalOAuthError extends Error { + constructor(message: string, err: unknown); + } + + export = OAuthStrategy; + export { InternalOAuthError, OAuthClient, OAuthError, OAuthGetCallback }; +} diff --git a/packages/passport-x/src/profile.spec.ts b/packages/passport-x/src/profile.spec.ts new file mode 100644 index 0000000000000..81808d0b1c8d7 --- /dev/null +++ b/packages/passport-x/src/profile.spec.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { parse } from './profile'; + +function loadFixture(relativePath: string): string { + return readFileSync(join(__dirname, '__fixtures__', relativePath), 'utf8'); +} + +describe('Profile.parse', () => { + describe('theSeanCook profile', () => { + const profile = parse(loadFixture('account/theSeanCook.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('38895958'); + expect(profile.username).toBe('theSeanCook'); + expect(profile.displayName).toBe('Sean Cook'); + expect(profile.emails).toBeUndefined(); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG'); + }); + }); + + describe('theSeanCook profile with email', () => { + const profile = parse(loadFixture('account/theSeanCook-include_email.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('38895958'); + expect(profile.username).toBe('theSeanCook'); + expect(profile.displayName).toBe('Sean Cook'); + expect(profile.emails).toHaveLength(1); + expect(profile.emails![0].value).toBe('theSeanCook@example.test'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG'); + }); + }); + + describe('rsarver profile', () => { + const profile = parse(loadFixture('users/rsarver.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('795649'); + expect(profile.username).toBe('rsarver'); + expect(profile.displayName).toBe('Ryan Sarver'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png'); + }); + }); + + describe('rsarver profile without id_str', () => { + const profile = parse(loadFixture('users/rsarver-without-id_str.json')); + + it('should parse profile using numeric id', () => { + expect(profile.id).toBe('795649'); + expect(profile.username).toBe('rsarver'); + expect(profile.displayName).toBe('Ryan Sarver'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png'); + }); + }); +}); diff --git a/packages/passport-x/src/profile.ts b/packages/passport-x/src/profile.ts new file mode 100644 index 0000000000000..1b9d1dc7509ac --- /dev/null +++ b/packages/passport-x/src/profile.ts @@ -0,0 +1,28 @@ +export type Profile = { + id: string; + username: string; + displayName: string; + emails?: { value: string }[]; + photos: { value: string }[]; + provider?: string; + _raw?: string; + _json?: Record; + _accessLevel?: string; +}; + +export function parse(json: string | Record): Profile { + const data: Record = typeof json === 'string' ? JSON.parse(json) : json; + + const profile: Profile = { + id: data.id_str ? String(data.id_str) : String(data.id), + username: data.screen_name as string, + displayName: data.name as string, + photos: [{ value: data.profile_image_url_https as string }], + }; + + if (data.email) { + profile.emails = [{ value: data.email as string }]; + } + + return profile; +} diff --git a/packages/passport-x/tsconfig.json b/packages/passport-x/tsconfig.json new file mode 100644 index 0000000000000..cb95b38fb6568 --- /dev/null +++ b/packages/passport-x/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 9c25e22e983f8..ef1f94da30eb1 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -41,6 +41,7 @@ import type { SettingsEndpoints } from './v1/settings'; import type { StatisticsEndpoints } from './v1/statistics'; import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints'; import type { TeamsEndpoints } from './v1/teams'; +import type { TwoFactorChallengesEndpoints } from './v1/twoFactorChallenges'; import type { UsersEndpoints } from './v1/users'; import type { VideoConferenceEndpoints } from './v1/videoConference'; @@ -90,6 +91,7 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, ServerEventsEndpoints, + TwoFactorChallengesEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -264,6 +266,7 @@ export * from './v1/auth'; export * from './v1/cloud'; export * from './v1/banners'; export * from './default'; +export * from './v1/twoFactorChallenges'; // Export the ajv instance for use in other packages export * from './v1/Ajv'; diff --git a/packages/rest-typings/src/v1/twoFactorChallenges.ts b/packages/rest-typings/src/v1/twoFactorChallenges.ts new file mode 100644 index 0000000000000..42b471b244a48 --- /dev/null +++ b/packages/rest-typings/src/v1/twoFactorChallenges.ts @@ -0,0 +1,43 @@ +import { ajv } from './Ajv'; + +type TwoFactorChallengesSendEmailCode = { challengeId: string }; + +const TwoFactorChallengesSendEmailCodeSchema = { + type: 'object', + properties: { + challengeId: { + type: 'string', + }, + }, + required: ['challengeId'], + additionalProperties: false, +}; + +export const isTwoFactorChallengesSendEmailCodeParamsPOST = ajv.compile( + TwoFactorChallengesSendEmailCodeSchema, +); + +type TwoFactorChallengesVerifyChallenge = { challengeId: string; code: string }; + +const TwoFactorChallengesVerifyChallengeSchema = { + type: 'object', + properties: { + challengeId: { type: 'string' }, + code: { type: 'string' }, + }, + required: ['challengeId', 'code'], + additionalProperties: false, +}; + +export const isTwoFactorChallengesVerifyChallengeParamsPOST = ajv.compile( + TwoFactorChallengesVerifyChallengeSchema, +); + +export type TwoFactorChallengesEndpoints = { + '/v1/twoFactorChallenges.sendEmailCode': { + POST: (params: TwoFactorChallengesSendEmailCode) => void; + }; + '/v1/twoFactorChallenges.verifyChallenge': { + POST: (params: TwoFactorChallengesVerifyChallenge) => { loginToken: string; userId: string }; + }; +}; diff --git a/packages/web-ui-registration/global.d.ts b/packages/web-ui-registration/global.d.ts new file mode 100644 index 0000000000000..51c30e263ccad --- /dev/null +++ b/packages/web-ui-registration/global.d.ts @@ -0,0 +1,7 @@ +import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; + +declare global { + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index 845fbbfaf5d9e..ecfea54932dc6 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,22 +1,43 @@ -import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Divider } from '@rocket.chat/fuselage'; import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; -import type { Dispatch, SetStateAction } from 'react'; +import { useMemo, type Dispatch, type SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginErrorState } from './LoginForm'; import LoginServicesButton from './LoginServicesButton'; +const servicesToBeShownOnDesktop = ['saml', 'cas', 'ldap']; + export type LoginServicesProps = { disabled?: boolean; setError: Dispatch> }; const LoginServices = ({ disabled, setError }: LoginServicesProps) => { const { t } = useTranslation(); const services = useLoginServices(); const showFormLogin = useSetting('Accounts_ShowFormLogin'); + const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true); + + const isDesktopApp = !!window.RocketChatDesktop?.openInBrowser && enableModernOAuthFlow; + + const servicesToShow = useMemo( + () => (isDesktopApp ? services.filter(({ service }) => servicesToBeShownOnDesktop.includes(service)) : services), + [isDesktopApp, services], + ); if (services.length === 0) { return null; } + const handleLoginOnWeb = () => { + if (!isDesktopApp) { + return; + } + + const redirectUrl = new URL(window.location.href); + redirectUrl.searchParams.set('loginClient', 'desktop'); + + window.RocketChatDesktop?.openInBrowser(redirectUrl.toString()); + }; + return ( <> {showFormLogin && ( @@ -24,11 +45,26 @@ const LoginServices = ({ disabled, setError }: LoginServicesProps) => { {t('registration.component.form.divider')} )} - - {services.map((service) => ( - - ))} - + + {servicesToShow.length > 0 && ( + + {servicesToShow.map((service) => ( + + ))} + + )} + + {isDesktopApp && ( + + )} ); }; diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index 7d3232ec6b435..5cdbe35c02585 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'; import type { LoginErrorState, LoginErrors } from './LoginForm'; +const servicesSupportedByMeteor = ['saml', 'cas', 'ldap']; + const LoginServicesButton = ({ buttonLabelText, icon, @@ -18,23 +20,51 @@ const LoginServicesButton = ({ setError, buttonColor, buttonLabelColor, + loginStyle, + enableModernOAuthFlow, ...props }: T & { className?: string; disabled?: boolean; + loginStyle?: 'popup' | 'redirect' | ''; setError?: Dispatch>; + enableModernOAuthFlow?: boolean; }) => { const { t } = useTranslation(); const handler = useLoginWithService({ service, buttonLabelText, ...props }); const handleOnClick = useCallback(() => { + if (!servicesSupportedByMeteor.includes(service) && enableModernOAuthFlow) { + const url = new URL(window.location.href); + const queryParams = url.searchParams; + const loginClient = queryParams.get('loginClient'); + + if (loginStyle === 'popup') { + window.open( + `/oauth/${service}${loginClient ? `?loginClient=${loginClient}` : ''}`, + 'oauth', + 'popup=yes,width=500,height=700,left=100,top=100', + ); + return; + } + + const redirectUrl = new URL(`/oauth/${service}`, window.location.origin); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + window.location.href = redirectUrl.toString(); + return; + } + handler().catch((e: { error?: LoginErrors; reason?: string }) => { if (!e.error || typeof e.error !== 'string') { return; } setError?.([e.error, e.reason]); }); - }, [handler, setError]); + }, [handler, setError, service, enableModernOAuthFlow, loginStyle]); return (