Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2243b8a
add deprecation warning
yash-rajpal Mar 20, 2026
b0cd754
chore: Passport login UI (#39848)
yash-rajpal Mar 27, 2026
6889133
feat: Configure Passport (#39604)
yash-rajpal Mar 27, 2026
a85ee03
merge develop and fix conflicts
yash-rajpal Apr 1, 2026
9313bce
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal Apr 7, 2026
b1b3d94
fix express user typing
yash-rajpal Apr 8, 2026
ae49afb
use site url instead of meteor absolute url
yash-rajpal Apr 8, 2026
ed170a8
fix async login token creation
yash-rajpal Apr 15, 2026
c558ae5
oops
yash-rajpal Apr 15, 2026
5628302
merge develop and fix conflicts
yash-rajpal Apr 15, 2026
a01dcdb
failure debugging
yash-rajpal Apr 15, 2026
3ad63c7
use flash for error debugging
yash-rajpal Apr 15, 2026
55d5a48
merge develop
yash-rajpal Apr 20, 2026
be66f93
lint
yash-rajpal Apr 20, 2026
4e185c8
chore: Passport Google OAuth (#40222)
yash-rajpal Apr 21, 2026
c874372
trust proxy
yash-rajpal Apr 21, 2026
7294f3c
merge and conflicts
yash-rajpal Apr 24, 2026
a0b3c2c
merge develop and fix conflicts
yash-rajpal May 11, 2026
500e008
feat: Custom OAuth using Passport (#40203)
yash-rajpal May 15, 2026
aa3602b
custom OAuth auto publish fields
yash-rajpal May 18, 2026
6e42e87
chore: LinkedIn custom OAuth (#40594)
yash-rajpal May 18, 2026
9a2d2b0
feat: Deeplink login for Mobile and Desktop clients (#40601)
yash-rajpal May 18, 2026
9cda3cf
feat: Secret setting for express-sessions secret (#40602)
yash-rajpal May 18, 2026
93af0a8
chore: Passport Nextcloud OAuth (#40591)
yash-rajpal May 18, 2026
33e32b9
merge develop and fix conflict
yash-rajpal May 18, 2026
c7f7306
chore: Passport Meteor OAuth (#40595)
yash-rajpal May 18, 2026
80a082b
me stupid
yash-rajpal May 18, 2026
afda09b
chore: Passport Github Enterprise OAuth (#40608)
yash-rajpal May 19, 2026
dc44d7c
chore: Passport Gitlab OAuth (#40609)
yash-rajpal May 19, 2026
f052ca8
chore: Passport Dolphin (#40611)
yash-rajpal May 19, 2026
02fa86b
chore: Passport Drupal OAuth (#40610)
yash-rajpal May 19, 2026
f709064
chore: Passport Wordpress OAuth (#40593)
yash-rajpal May 19, 2026
9509e9b
add rate limiter
yash-rajpal May 19, 2026
d394e16
exclude saml, cas, ldap from login flow
yash-rajpal May 19, 2026
df761f0
express sessions mongo store
yash-rajpal May 19, 2026
30c5ee1
rate limit only oauth req
yash-rajpal May 19, 2026
161212e
chore: Passport Twitter OAuth (#40563)
yash-rajpal May 19, 2026
77de0c2
feat: New 2FA flow for passport OAuth (#40106)
yash-rajpal May 19, 2026
de91e8f
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal May 19, 2026
732fc12
chore: Passport apple oauth (#40481)
yash-rajpal May 19, 2026
20db627
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal May 19, 2026
bdb7a2e
handle undefined client secrets
yash-rajpal May 20, 2026
fb20370
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal May 20, 2026
540c2b6
fix oauth e2e tests
yash-rajpal May 20, 2026
88a8099
isolate oauth middlewares
yash-rajpal May 20, 2026
1bb9ad4
Merge branch 'develop' into feat/phishing-resistant-mfa
yash-rajpal May 20, 2026
5f2b140
spread profile for all fields
yash-rajpal May 20, 2026
8a9a965
restore /_oauth/provider callback structure
yash-rajpal May 20, 2026
c1525a9
fix review
yash-rajpal May 20, 2026
873daad
add changeset
yash-rajpal May 20, 2026
d360cd2
fix unused tokenObject
yash-rajpal May 20, 2026
0b99664
remove unused deps
yash-rajpal May 20, 2026
396cf66
improve login redirect url handling
yash-rajpal May 20, 2026
2700a46
check for email method before sending email code
yash-rajpal May 20, 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
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);
}
}
37 changes: 37 additions & 0 deletions apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IUser } from '@rocket.chat/core-typings';
import { TwoFactorChallenges } from '@rocket.chat/models';

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<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> {
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;
}
}
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';
Comment thread
tassoevan marked this conversation as resolved.

public isEnabled(user: IUser): boolean {
if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) {
Expand Down
35 changes: 35 additions & 0 deletions apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { IUser } from '@rocket.chat/core-typings';
import { TwoFactorChallenges } from '@rocket.chat/models';

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);

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 @@ function getFingerprintFromConnection(connection: IMethodConnection): string {
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');

if (rememberFor <= 0) {
return;
Expand Down Expand Up @@ -118,6 +118,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> {
const currentToken = Accounts._getLoginToken(connection.id);

Expand Down Expand Up @@ -146,7 +160,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 @@ -174,7 +188,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
2 changes: 1 addition & 1 deletion apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import './v1/mailer';
import './v1/teams';
import './v1/moderation';
import './v1/uploads';

import './v1/twoFactorChallenges';
// This has to come last so all endpoints are registered before generating the OpenAPI documentation
import './default/openApi';

Expand Down
112 changes: 112 additions & 0 deletions apps/meteor/app/api/server/v1/twoFactorChallenges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../../../server/lib/oauth/twoFactorAuth';
import { getUserForCheck, rememberAuthorizationByToken } from '../../../2fa/server/code';
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

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);
Comment thread
yash-rajpal marked this conversation as resolved.

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,
});
},
},
);
7 changes: 3 additions & 4 deletions apps/meteor/app/apple/lib/handleIdentityToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function isValidAppleJWT(identityToken: string, header: any): Promise<bool
}
}

export async function handleIdentityToken(identityToken: string): Promise<{ id: string; email: string; name: string }> {
export async function handleIdentityToken(identityToken: string): Promise<Record<string, any>> {
const decodedToken = KJUR.jws.JWS.parse(identityToken);

if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) {
Expand All @@ -38,15 +38,14 @@ export async function handleIdentityToken(identityToken: string): Promise<{ id:
throw new Error('identityToken does not have a payload');
}

const { iss, sub, email } = decodedToken.payloadObj as any;
const { iss, sub } = decodedToken.payloadObj as any;
if (!iss) {
throw new Error('Insufficient data in auth response token');
}

const serviceData = {
id: sub,
email,
name: '',
...decodedToken.payloadObj,
};

return serviceData;
Expand Down
Loading
Loading