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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loose-knives-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes the behavior when the login token expires to redirect the user to the login page
2 changes: 2 additions & 0 deletions apps/meteor/definition/externals/meteor/accounts-base.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ declare module 'meteor/accounts-base' {
callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void,
): string;

function _expireTokens(oldestValidDate?: Date, userId?: string): Promise<void>;

function _bcryptRounds(): number;

function _getLoginToken(connectionId: string): string | undefined;
Expand Down
46 changes: 45 additions & 1 deletion apps/meteor/server/services/meteor/userReactivity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
import { api } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { MongoInternals } from 'meteor/mongo';

const { _expireTokens: expireTokensOriginal } = Accounts;

Accounts._expireTokens = async () => {
// TODO need to get the real expiration time from the settings
const oldestValidDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);

const users = await Users.find(
{
$or: [
{ 'services.resume.loginTokens.when': { $lt: oldestValidDate } },
{ 'services.resume.loginTokens.when': { $lt: +oldestValidDate } },
],
},
{ projection: { services: 1 } },
).toArray();

const updates: { _id: string; validTokens: any[] }[] = [];

users.forEach(({ _id, services }) => {
const loginTokens = services?.resume?.loginTokens;

// remove all tokens that are expired
const validTokens = loginTokens?.filter((token: any) => token.when >= oldestValidDate) || [];

updates.push({ _id, validTokens });
});

await expireTokensOriginal.call(Accounts);

updates.forEach(({ _id, validTokens }) => {
void api.broadcast('watch.users', {
clientAction: 'updated',
id: _id,
diff: {
'services.resume.loginTokens': validTokens,
},
unset: {},
});
});
};

type Callbacks = {
added(id: string, record: object): void;
changed(id: string, record: object): void;
Expand Down Expand Up @@ -82,7 +126,7 @@ export const processOnChange = (diff: Record<string, any>, id: string): void =>
const cbs = userCallbacks.get(id);
if (cbs) {
[...cbs]
.filter(({ hashedToken }) => tokens === undefined || !tokens.includes(hashedToken))
.filter(({ hashedToken }) => !tokens?.includes(hashedToken))
.forEach((item) => {
item.callbacks.removed(id);
cbs.delete(item);
Expand Down
101 changes: 101 additions & 0 deletions apps/meteor/tests/e2e/session-expiration-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { MongoClient } from 'mongodb';

import { URL_MONGODB } from './config/constants';
import injectInitialData from './fixtures/inject-initial-data';
import { restoreState, Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel, deleteChannel } from './utils';
import { test, expect } from './utils/test';

const removeTokensFromdb = async (username: string): Promise<void> => {
const connection = await MongoClient.connect(URL_MONGODB);

await connection
.db()
.collection('users')
.updateOne({ username }, { $set: { 'services.resume.loginTokens': [] } });

await connection.close();
};

test.use({ storageState: Users.user1.state });

test.describe('Session Expiration Redirect', () => {
let poHomeChannel: HomeChannel;
let targetChannel: string;

test.beforeAll(async ({ api }) => {
targetChannel = await createTargetChannel(api, { members: ['user1'] });
});

test.afterAll(async ({ api }) => {
await deleteChannel(api, targetChannel);
});

test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await page.goto(`/channel/${targetChannel}`);
});
test.afterEach(async ({ page }) => {
await injectInitialData();
await restoreState(page, Users.user1);
});

test('should redirect to login page when server-side token is deleted and user tries to interact', async ({ page }) => {
await test.step('expect user to be logged in initially', async () => {
await expect(page.locator('#main-content')).toBeVisible();

const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId'));
const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken'));
expect(userId).not.toBeNull();
expect(loginToken).not.toBeNull();
});

await test.step('delete login tokens from database (simulating server-side expiration)', async () => {
await removeTokensFromdb(Users.user1.data.username);
});

await test.step('open room search messages (without page reload)', async () => {
await poHomeChannel.roomToolbar.btnSearchMessages.click();
});

await test.step('should redirect to login page', async () => {
await expect(page.getByRole('form', { name: 'Login' })).toBeVisible({ timeout: 10000 });
});

await test.step('verify localStorage was cleared', async () => {
const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId'));
const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken'));
const loginTokenExpires = await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires'));

expect(userId).toBeNull();
expect(loginToken).toBeNull();
expect(loginTokenExpires).toBeNull();
});
});

test('should redirect to login page when trying to send message with expired token', async ({ page }) => {
await test.step('type message', async () => {
await poHomeChannel.composer.inputMessage.fill('Test message');
});

await test.step('delete login tokens from database', async () => {
await removeTokensFromdb(Users.user1.data.username);
});

await test.step('try to send a message (should trigger auth error)', async () => {
await poHomeChannel.composer.btnSend.click();
});

await test.step('expect automatic redirect to login page', async () => {
await expect(page.getByRole('form', { name: 'Login' })).toBeVisible({ timeout: 10000 });
});

await test.step('verify localStorage was cleared', async () => {
const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId'));
const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken'));
expect(userId).toBeNull();
expect(loginToken).toBeNull();
});
});
});
2 changes: 1 addition & 1 deletion packages/core-services/src/events/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export type EventSignatures = {
}
| {
clientAction: 'updated';
diff: Record<string, number>;
diff: Record<string, any>;
unset: Record<string, number>;
}
),
Expand Down
Loading