Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
05b51b5
Revert "revert: Phishing resistant multi factor authentication (#40679)"
yash-rajpal May 28, 2026
8f3741d
chore: Hide login buttons on mobile app (#40733)
yash-rajpal Jun 9, 2026
e80729e
feat: OAuth login using login codes (#40783)
yash-rajpal Jun 11, 2026
9933083
regression: fork `passport-twitter` (#40647)
tassoevan Jun 15, 2026
7160ce6
feat: `Accounts_OAuth_Use_Modern_Flow` Setting (#40876)
yash-rajpal Jun 15, 2026
026ca89
fix condition
yash-rajpal Jun 15, 2026
8cece39
fix: Popup style OAuth login for passport (#40882)
yash-rajpal Jun 16, 2026
df18e1b
fix: Passport OAuth accessible by URL even after disabled by settings
yash-rajpal Jun 16, 2026
09a531b
fix: Iframe support for passport OAuth (#40949)
yash-rajpal Jun 16, 2026
8cdc009
fix: Wordpress OAuth
yash-rajpal Jun 16, 2026
1a4748e
fix: missing apple oauth clientId crashes server
yash-rajpal Jun 16, 2026
aa2810a
fix: github_enterprise edge case
yash-rajpal Jun 16, 2026
4cd9910
chore: remove irrelevant code
yash-rajpal Jun 16, 2026
86b93a6
fix: Gitlab OAuth config
yash-rajpal Jun 16, 2026
6d06a1e
chore: move apple passport implementation
yash-rajpal Jun 16, 2026
38afbdb
merge develop and fix conflicts
yash-rajpal Jun 16, 2026
4228432
chore: toggle apple for passport and meteor routes
yash-rajpal Jun 16, 2026
9e919fc
fix: trust just 1 previous reverse proxy
yash-rajpal Jun 16, 2026
0a8058b
add changeset
yash-rajpal Jun 16, 2026
f0fc735
error handling
yash-rajpal Jun 16, 2026
3b69175
fix TS
yash-rajpal Jun 16, 2026
e939406
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal Jun 16, 2026
39aaaf0
fix wordpress config
yash-rajpal Jun 16, 2026
1e70d99
fix meteor OAuth service key
yash-rajpal Jun 16, 2026
f2bcd12
fix trailing slash in site url
yash-rajpal Jun 16, 2026
b7b0e3c
fix: watch for github_enterprise url setting change
yash-rajpal Jun 16, 2026
b23c8d0
chore: default wordpress paths config
yash-rajpal Jun 16, 2026
05e3602
fix settings for e2e tests
yash-rajpal Jun 17, 2026
2f9c086
refactor: prevent usage of global `Meteor` reference
tassoevan Jun 17, 2026
48b08c3
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
tassoevan Jun 17, 2026
3280137
use a map
KevLehman Jun 19, 2026
587c37d
exclude rooms prop
KevLehman Jun 19, 2026
2abde09
oauth
KevLehman Jun 19, 2026
4970930
issuer & exp
KevLehman Jun 19, 2026
2de57f0
avoid overriding email from spread
KevLehman Jun 19, 2026
d1f90ce
cubic
KevLehman Jun 19, 2026
bdd3f73
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 23, 2026
c0c9f4b
fix: missing validation & remove dead/duplicate code
cardoso Jun 23, 2026
0809a18
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 24, 2026
d84da30
fix: passport-apple name acquisition
cardoso Jun 24, 2026
6a88fa1
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 26, 2026
50f6c69
fix: Accounts_OAuth_Use_Modern_Flow default
cardoso Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/breezy-parts-kiss.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions .changeset/flat-poets-cheat.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions apps/meteor/app/2fa/server/code/EmailCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<number>('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts');
return (await Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts)) as boolean;
return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts);
}
}
38 changes: 38 additions & 0 deletions apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts
Original file line number Diff line number Diff line change
@@ -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';

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export class EmailCheckForOAuth extends EmailCheck {
public override readonly name = 'email-oauth';

public readonly method = 'email';

public async sendTwoFactorChallenge(user: IUser): Promise<string> {
const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email');
await this.sendEmailCode(user);
return challengeId;
}

public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise<boolean> {
Comment thread
yash-rajpal marked this conversation as resolved.
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);
Comment thread
yash-rajpal marked this conversation as resolved.

return true;
}
}
2 changes: 1 addition & 1 deletion apps/meteor/app/2fa/server/code/TOTPCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
36 changes: 36 additions & 0 deletions apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp');
}

public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise<boolean> {
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);
Comment thread
yash-rajpal marked this conversation as resolved.

return true;
}
}
21 changes: 17 additions & 4 deletions apps/meteor/app/2fa/server/code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>('Accounts_TwoFactorAuthentication_RememberFor');
Comment thread
yash-rajpal marked this conversation as resolved.

if (rememberFor <= 0) {
return;
Expand Down Expand Up @@ -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<void> {
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<void> {
// Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData`
// via `_getLoginToken`, REST falls back to the token carried on `connection.token`.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/app/api/server/ApiClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const rateLimiterDictionary: Record<
}
> = {};

const generateConnection = (
export const generateConnection = (
ipAddress: string,
httpHeaders: Record<string, any>,
): {
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/api/server/v1/custom-sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
validateInternalErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Meteor } from 'meteor/meteor';

import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants';
import { SystemLogger } from '../../../../server/lib/logger/system';
Expand Down Expand Up @@ -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, {
Expand Down
71 changes: 71 additions & 0 deletions apps/meteor/app/api/server/v1/loginCode.ts
Original file line number Diff line number Diff line change
@@ -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<LoginCodeRedeemParams> = {
type: 'object',
properties: {
code: { type: 'string', minLength: 64, maxLength: 64 },
},
required: ['code'],
additionalProperties: false,
};

const isLoginCodeRedeemParamsPOST = ajv.compile<LoginCodeRedeemParams>(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<typeof loginCodeEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LoginCodeEndpoints {}
}
15 changes: 13 additions & 2 deletions apps/meteor/app/api/server/v1/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,25 @@ API.v1.get(
},
async function action() {
const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray();
const isPassportFlowEnabled = settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow');

return API.v1.success({
services: oAuthServicesEnabled.map((service) => {
if (!service) {
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 {
Expand All @@ -203,6 +213,7 @@ API.v1.get(
buttonColor: service.buttonColor || '',
buttonLabelColor: service.buttonLabelColor || '',
custom: false,
hideButtonOnMobile: isPassportFlowEnabled,
Comment thread
KevLehman marked this conversation as resolved.
};
}),
});
Expand Down
Loading
Loading