From eefff9f4bd77cfaebb1f7c092b32277cf34a4500 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 25 Jun 2026 20:47:28 -0300 Subject: [PATCH 01/21] fix(security): scope auth token to (server, userId), stop exporting notification receivers, confirm deep-link login --- android/app/src/main/AndroidManifest.xml | 9 +++- .../reactnative/notification/Ejson.java | 35 ++++++++++---- app/i18n/locales/en.json | 2 + app/lib/constants/keys.ts | 11 +++++ app/lib/methods/logout.ts | 13 ++++- app/sagas/__tests__/deepLinking.test.ts | 29 +++++++++++- app/sagas/deepLinking.js | 25 ++++++++++ app/sagas/init.js | 47 ++++++++++++++++++- app/sagas/login.js | 4 +- app/sagas/selectServer.ts | 4 +- ios/Shared/RocketChat/MMKV.swift | 14 ++++-- ios/Shared/RocketChat/Storage.swift | 2 +- ios/Watch/WatchConnection.swift | 2 +- 13 files changed, 171 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 18337835f5e..58fec6d2fd0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -102,14 +102,19 @@ + + android:exported="false" /> + android:exported="false" > `${TOKEN_KEY}-${server}-${userId}`; export const CURRENT_SERVER = 'currentServer'; export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY'; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..efd7d253108 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -8,7 +8,14 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; -import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; +import { + CURRENT_SERVER, + E2E_PRIVATE_KEY, + E2E_PUBLIC_KEY, + E2E_RANDOM_PASSWORD_KEY, + TOKEN_KEY, + getUserTokenKey +} from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; import { roomsSubscription } from './subscriptions/rooms'; @@ -17,6 +24,8 @@ import { _activeUsersSubTimeout } from './getUsersPresence'; function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) { UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); if (userId) { + UserPreferences.removeItem(getUserTokenKey(server, userId)); + // Also remove the legacy non-server-scoped token slot, in case this server predates the migration. UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); @@ -64,7 +73,7 @@ export async function removeServer({ server }: { server: string }): Promise ({ default: { t: (k: string) => k } })); +// Deep-link resume login asks for confirmation before authenticating. Default to confirming +// (invoke onPress) so the existing login-path tests run; the decline path is covered explicitly. +jest.mock('../../lib/methods/helpers/info', () => ({ + showConfirmationAlert: jest.fn(({ onPress }: { onPress: () => void }) => onPress()) +})); + // Mock helpers to avoid auxStore (getUidDirectMessage / getRoomTitle call reduxStore.getState()) jest.mock('../../lib/methods/helpers', () => ({ getUidDirectMessage: jest.fn(() => null), @@ -100,11 +106,12 @@ import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; import { connectSuccess } from '../../actions/connect'; import { appStart } from '../../actions/app'; -import { LOGIN } from '../../actions/actionsTypes'; +import { APP, LOGIN } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; +import { showConfirmationAlert } from '../../lib/methods/helpers/info'; import { getServerById } from '../../lib/database/services/Server'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; @@ -305,6 +312,26 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(loginRequested()).toBe(true); }); + // Security: a deep-link resume token must not silently authenticate. If the user declines + // the confirmation, no login is attempted and the app lands outside (manual login screen). + it('does not dispatch loginRequest when the deep-link login confirmation is declined', async () => { + jest.mocked(showConfirmationAlert).mockClear(); + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // User declined → confirmation shown, no login, parked outside. + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + expect(loginRequested()).toBe(false); + expect(actions.some(a => a.type === APP.START && (a as any).root === RootEnum.ROOT_OUTSIDE)).toBe(true); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 9ca8d2261fb..b0b240fcb3e 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -21,6 +21,7 @@ import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; import log from '../lib/methods/helpers/log'; +import { showConfirmationAlert } from '../lib/methods/helpers/info'; import { showToast } from '../lib/methods/helpers/showToast'; import UserPreferences from '../lib/methods/userPreferences'; import { videoConfJoin } from '../lib/methods/videoConf'; @@ -37,6 +38,23 @@ const roomTypes = { channels: 'l' }; +/** + * A `rocketchat://auth?host=…&token=…` deep link can silently authenticate the user to an + * arbitrary server. Any app (or web page) can fire it, so before consuming a resume token for a + * server the user is not already signed in to, ask for explicit confirmation. Resolves true if + * the user confirms, false otherwise. + */ +const confirmDeepLinkLogin = host => + new Promise(resolve => { + showConfirmationAlert({ + title: I18n.t('Deep_link_login_title'), + message: I18n.t('Deep_link_login_description', { server: host }), + confirmationText: I18n.t('Login'), + onPress: () => resolve(true), + onCancel: () => resolve(false) + }); + }); + const handleInviteLink = function* handleInviteLink({ params, requireLogin = false }) { if (params.path && params.path.startsWith('invite/')) { const token = params.path.replace('invite/', ''); @@ -236,6 +254,13 @@ const handleOpen = function* handleOpen({ params }) { } if (params.token) { + // A resume token in a deep link silently authenticates the user to `host`. Since any + // app or web page can fire this link, require explicit confirmation before consuming it. + const confirmed = yield call(confirmDeepLinkLogin, host); + if (!confirmed) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } if (!hostAlreadyConnected) { yield take(types.SERVER.SELECT_SUCCESS); // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..90b82c9ca87 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -2,7 +2,7 @@ import { call, put, select, takeLatest } from 'redux-saga/effects'; import RNBootSplash from 'react-native-bootsplash'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; @@ -21,8 +21,53 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; + +/** + * One-time migration: move auth tokens from the legacy non-server-scoped slot + * `${TOKEN_KEY}-${userId}` to the server-scoped slot `${TOKEN_KEY}-${server}-${userId}`. + * See `getUserTokenKey` for why the legacy scheme was ambiguous (token confusion). + * + * Done in two passes so a userId shared by multiple servers is copied to every server's + * slot before any legacy slot is removed. + */ +const migrateTokenKeysToServerScoped = function* migrateTokenKeysToServerScoped() { + try { + if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { + return; + } + const serversDB = database.servers; + const servers = yield serversDB.get('servers').query().fetch(); + const legacyKeys = []; + // Pass 1: copy each server's token into the new server-scoped slot. + for (let i = 0; i < servers.length; i += 1) { + const server = servers[i].id; + const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + if (!userId) { + continue; + } + const newKey = getUserTokenKey(server, userId); + if (!UserPreferences.getString(newKey)) { + const legacyKey = `${TOKEN_KEY}-${userId}`; + const token = UserPreferences.getString(legacyKey); + if (token) { + UserPreferences.setString(newKey, token); + legacyKeys.push(legacyKey); + } + } + } + // Pass 2: drop the legacy slots now that every server has been migrated. + legacyKeys.forEach(key => UserPreferences.removeItem(key)); + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + } catch (e) { + log(e); + } +}; + const restore = function* restore() { try { + yield call(migrateTokenKeysToServerScoped); + const server = UserPreferences.getString(CURRENT_SERVER); let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); diff --git a/app/sagas/login.js b/app/sagas/login.js index 10740600a33..d1c673d62ed 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -22,7 +22,7 @@ import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry' import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; import { RootEnum } from '../definitions'; import sdk from '../lib/services/sdk'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; import { getCustomEmojis } from '../lib/methods/getCustomEmojis'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules'; @@ -341,7 +341,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { }); UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id); - UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token); + UserPreferences.setString(getUserTokenKey(server, user.id), user.token); UserPreferences.setString(CURRENT_SERVER, server); EventEmitter.emit('connected'); const currentRoot = yield select(state => state.app.root); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..4641c0d3cc9 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -29,7 +29,7 @@ import UserPreferences from '../lib/methods/userPreferences'; import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { type IServerInfo, RootEnum, type TServerModel } from '../definitions'; -import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions'; import { getLoginSettings, setSettings } from '../lib/methods/getSettings'; import { getServerInfo } from '../lib/methods/getServerInfo'; @@ -171,7 +171,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch requirePasswordChange: userRecord.requirePasswordChange }; } else { - const token = UserPreferences.getString(`${TOKEN_KEY}-${userId}`); + const token = UserPreferences.getString(getUserTokenKey(server, userId)); if (token) { user = { token }; } diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index f825b4d2cfc..3bb092ad797 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -18,11 +18,17 @@ extension MMKVBridge { return MMKVBridge(id: "default", cryptKey: cryptKey, rootPath: mmkvPath) } - func userToken(for userId: String) -> String? { - guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else { - return nil + // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. Keep in sync with + // getUserTokenKey() (JS) and Ejson.token() (Android). The token used to be stored under + // reactnativemeteor_usertoken-{userId} (no server component), which let a lookup resolve a + // token belonging to a different server when two servers shared a userId (token confusion). + // Read the server-scoped slot first, falling back to the legacy slot only for the window + // between an app update and the JS migration running on next launch. + func userToken(for userId: String, server: String) -> String? { + if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") { + return userToken } - return userToken + return string(forKey: "reactnativemeteor_usertoken-\(userId)") } func userId(for server: String) -> String? { diff --git a/ios/Shared/RocketChat/Storage.swift b/ios/Shared/RocketChat/Storage.swift index 63ce28eeef3..db7680169db 100644 --- a/ios/Shared/RocketChat/Storage.swift +++ b/ios/Shared/RocketChat/Storage.swift @@ -12,7 +12,7 @@ final class Storage { // Read credentials from MMKV (shared via app group) // Credentials are stored during login in React Native guard let userId = mmkv.userId(for: server), - let userToken = mmkv.userToken(for: userId) else { + let userToken = mmkv.userToken(for: userId, server: server) else { return nil } return Credentials(userId: userId, userToken: userToken) diff --git a/ios/Watch/WatchConnection.swift b/ios/Watch/WatchConnection.swift index 4e7069fff6e..a2fa0f7863c 100644 --- a/ios/Watch/WatchConnection.swift +++ b/ios/Watch/WatchConnection.swift @@ -41,7 +41,7 @@ final class WatchConnection: NSObject { } let servers = serversQuery.compactMap { item -> WatchMessage.Server? in - guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId) else { + guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId, server: item.identifier) else { return nil } From 8c9c9087362bab8bbca2684caf0bc8da715374b4 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 25 Jun 2026 23:48:05 +0000 Subject: [PATCH 02/21] action: organized translations --- app/i18n/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 59b37326e45..5692ba8792b 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -230,8 +230,8 @@ "Dark": "Dark", "Dark_level": "Dark level", "decline": "Decline", - "Deep_link_login_title": "Sign in to this server?", "Deep_link_login_description": "A link is asking to sign you in to {{server}}. Only continue if you opened this link yourself and trust it.", + "Deep_link_login_title": "Sign in to this server?", "Default": "Default", "Default_browser": "Default browser", "Defined_user_as_role": "defined {{user}} as {{role}}", From 7f783138d6768f7065a624c22c5aebe2f49e4c05 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 1 Jul 2026 13:44:24 -0300 Subject: [PATCH 03/21] fix: test --- app/containers/markdown/__snapshots__/Markdown.test.tsx.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap index 009e5615a36..2517ef4eb26 100644 --- a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap +++ b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap @@ -5882,7 +5882,7 @@ exports[`Story Snapshots: Timestamp should match snapshot 1`] = ` ] } > - a year ago + 2 years ago From ccadcd054b2a4558bfdef92ac2406600bd252c0c Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 1 Jul 2026 15:45:36 -0300 Subject: [PATCH 04/21] fix: e2e tests --- app/sagas/deepLinking.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 44a4f860274..7d51f9070cc 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -46,6 +46,13 @@ const roomTypes = { */ const confirmDeepLinkLogin = host => new Promise(resolve => { + // E2E tests bootstrap the session via a deep link and can't dismiss a native Alert, so + // auto-confirm under RUNNING_E2E_TESTS. This preserves the pre-fix silent behavior for + // tests only; real users still get the security prompt. + if (process.env.RUNNING_E2E_TESTS === 'true') { + resolve(true); + return; + } showConfirmationAlert({ title: I18n.t('Deep_link_login_title'), message: I18n.t('Deep_link_login_description', { server: host }), From f46aea1d18b9871b25103033f237fdf29a9ee837 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 1 Jul 2026 17:19:41 -0300 Subject: [PATCH 05/21] fix(security): scope token migration to unambiguous userIds and run it before direct server selects --- app/sagas/init.js | 34 ++++++++++++++++++++++++++-------- app/sagas/selectServer.ts | 5 +++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/sagas/init.js b/app/sagas/init.js index 90b82c9ca87..085cf6bc578 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -28,35 +28,53 @@ const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; * `${TOKEN_KEY}-${userId}` to the server-scoped slot `${TOKEN_KEY}-${server}-${userId}`. * See `getUserTokenKey` for why the legacy scheme was ambiguous (token confusion). * - * Done in two passes so a userId shared by multiple servers is copied to every server's - * slot before any legacy slot is removed. + * Only userIds referenced by a single server are migrated: the legacy slot can hold just one + * token, so when several servers share a userId we can't tell which server it belongs to. + * Migrating an ambiguous token would plant one server's token into another server's slot — + * exactly the confusion this change exists to prevent — so those slots are dropped instead, + * forcing a safe re-authentication. */ -const migrateTokenKeysToServerScoped = function* migrateTokenKeysToServerScoped() { +export const migrateTokenKeysToServerScoped = function* migrateTokenKeysToServerScoped() { try { if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { return; } const serversDB = database.servers; const servers = yield serversDB.get('servers').query().fetch(); - const legacyKeys = []; - // Pass 1: copy each server's token into the new server-scoped slot. + + // Map each server to its userId and count how many servers reference each userId. + const serverUserIds = []; + const serverCountByUserId = {}; for (let i = 0; i < servers.length; i += 1) { const server = servers[i].id; const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); if (!userId) { continue; } + serverUserIds.push({ server, userId }); + serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; + } + + // Collected in a Set so an ambiguous userId shared by N servers is removed once. + const legacyKeys = new Set(); + for (let i = 0; i < serverUserIds.length; i += 1) { + const { server, userId } = serverUserIds[i]; + const legacyKey = `${TOKEN_KEY}-${userId}`; + // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates. + if (serverCountByUserId[userId] > 1) { + legacyKeys.add(legacyKey); + continue; + } const newKey = getUserTokenKey(server, userId); if (!UserPreferences.getString(newKey)) { - const legacyKey = `${TOKEN_KEY}-${userId}`; const token = UserPreferences.getString(legacyKey); if (token) { UserPreferences.setString(newKey, token); - legacyKeys.push(legacyKey); + legacyKeys.add(legacyKey); } } } - // Pass 2: drop the legacy slots now that every server has been migrated. + // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done. legacyKeys.forEach(key => UserPreferences.removeItem(key)); UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); } catch (e) { diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 4641c0d3cc9..966c2dce620 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -30,6 +30,7 @@ import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { type IServerInfo, RootEnum, type TServerModel } from '../definitions'; import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; +import { migrateTokenKeysToServerScoped } from './init'; import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions'; import { getLoginSettings, setSettings } from '../lib/methods/getSettings'; import { getServerInfo } from '../lib/methods/getServerInfo'; @@ -150,6 +151,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(inquiryReset()); yield put(encryptionStop()); yield put(clearActiveUsers()); + // Deep-link and share-extension startup can dispatch selectServerRequest() directly, without + // going through appInit(). Run the (idempotent, flag-guarded) token migration here too so the + // server-scoped read below finds legacy sessions instead of falling through to a login screen. + yield* call(migrateTokenKeysToServerScoped); const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); let user = null; if (userId) { From 169246df3e55956be725f2eba99e693aa04cdd53 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 16:35:49 -0300 Subject: [PATCH 06/21] chore: simplify comments --- android/app/src/main/AndroidManifest.xml | 5 ----- .../chat/rocket/reactnative/notification/Ejson.java | 7 ++----- app/lib/constants/keys.ts | 12 ++++-------- app/sagas/__tests__/deepLinking.test.ts | 4 ---- app/sagas/deepLinking.js | 8 -------- app/sagas/init.js | 12 +++--------- ios/Shared/RocketChat/MMKV.swift | 7 ++----- 7 files changed, 11 insertions(+), 44 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 58fec6d2fd0..0e9fdce9400 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -102,11 +102,6 @@ - `${TOKEN_KEY}-${server}-${userId}`; export const CURRENT_SERVER = 'currentServer'; diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index ae34d63f07e..13ad3306c24 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -82,8 +82,6 @@ jest.mock('i18n-js', () => ({ default: { t: (k: string) => k } })); -// Deep-link resume login asks for confirmation before authenticating. Default to confirming -// (invoke onPress) so the existing login-path tests run; the decline path is covered explicitly. jest.mock('../../lib/methods/helpers/info', () => ({ showConfirmationAlert: jest.fn(({ onPress }: { onPress: () => void }) => onPress()) })); @@ -313,8 +311,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(loginRequested()).toBe(true); }); - // Security: a deep-link resume token must not silently authenticate. If the user declines - // the confirmation, no login is attempted and the app lands outside (manual login screen). it('does not dispatch loginRequest when the deep-link login confirmation is declined', async () => { jest.mocked(showConfirmationAlert).mockClear(); jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 7d51f9070cc..81cb8c152b4 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -38,12 +38,6 @@ const roomTypes = { channels: 'l' }; -/** - * A `rocketchat://auth?host=…&token=…` deep link can silently authenticate the user to an - * arbitrary server. Any app (or web page) can fire it, so before consuming a resume token for a - * server the user is not already signed in to, ask for explicit confirmation. Resolves true if - * the user confirms, false otherwise. - */ const confirmDeepLinkLogin = host => new Promise(resolve => { // E2E tests bootstrap the session via a deep link and can't dismiss a native Alert, so @@ -267,8 +261,6 @@ const handleOpen = function* handleOpen({ params }) { } if (params.token) { - // A resume token in a deep link silently authenticates the user to `host`. Since any - // app or web page can fire this link, require explicit confirmation before consuming it. const confirmed = yield call(confirmDeepLinkLogin, host); if (!confirmed) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); diff --git a/app/sagas/init.js b/app/sagas/init.js index 085cf6bc578..ac844416271 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -24,15 +24,9 @@ export const initLocalSettings = function* initLocalSettings() { const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; /** - * One-time migration: move auth tokens from the legacy non-server-scoped slot - * `${TOKEN_KEY}-${userId}` to the server-scoped slot `${TOKEN_KEY}-${server}-${userId}`. - * See `getUserTokenKey` for why the legacy scheme was ambiguous (token confusion). - * - * Only userIds referenced by a single server are migrated: the legacy slot can hold just one - * token, so when several servers share a userId we can't tell which server it belongs to. - * Migrating an ambiguous token would plant one server's token into another server's slot — - * exactly the confusion this change exists to prevent — so those slots are dropped instead, - * forcing a safe re-authentication. + * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the + * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds + * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication. */ export const migrateTokenKeysToServerScoped = function* migrateTokenKeysToServerScoped() { try { diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index 3bb092ad797..442199c126b 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -19,11 +19,8 @@ extension MMKVBridge { } // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. Keep in sync with - // getUserTokenKey() (JS) and Ejson.token() (Android). The token used to be stored under - // reactnativemeteor_usertoken-{userId} (no server component), which let a lookup resolve a - // token belonging to a different server when two servers shared a userId (token confusion). - // Read the server-scoped slot first, falling back to the legacy slot only for the window - // between an app update and the JS migration running on next launch. + // getUserTokenKey() (JS) and Ejson.token() (Android); falls back to the legacy + // userId-only slot until the JS migration runs. func userToken(for userId: String, server: String) -> String? { if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") { return userToken From 0781686652bb7fcac8d68e1cfa3ed1c3665df38a Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 16:41:49 -0300 Subject: [PATCH 07/21] chore: i18n translation --- app/i18n/locales/ar.json | 4 +++- app/i18n/locales/bn-IN.json | 4 +++- app/i18n/locales/cs.json | 4 +++- app/i18n/locales/de.json | 4 +++- app/i18n/locales/es.json | 4 +++- app/i18n/locales/fi.json | 4 +++- app/i18n/locales/fr.json | 4 +++- app/i18n/locales/hi-IN.json | 4 +++- app/i18n/locales/hu.json | 4 +++- app/i18n/locales/it.json | 4 +++- app/i18n/locales/ja.json | 4 +++- app/i18n/locales/nl.json | 4 +++- app/i18n/locales/nn.json | 4 +++- app/i18n/locales/no.json | 4 +++- app/i18n/locales/pt-BR.json | 4 +++- app/i18n/locales/pt-PT.json | 4 +++- app/i18n/locales/ru.json | 4 +++- app/i18n/locales/sl-SI.json | 4 +++- app/i18n/locales/sv.json | 4 +++- app/i18n/locales/ta-IN.json | 4 +++- app/i18n/locales/te-IN.json | 4 +++- app/i18n/locales/tr.json | 4 +++- app/i18n/locales/zh-CN.json | 4 +++- app/i18n/locales/zh-TW.json | 4 +++- 24 files changed, 72 insertions(+), 24 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 53e24e6431b..e8b292dda55 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -148,6 +148,8 @@ "Custom": "مخصص", "Dark": "داكن", "Dark_level": "مستوى السمة الداكنة", + "Deep_link_login_description": "يوجد رابط يطلب تسجيل دخولك إلى {{server}}. تابع فقط إذا كنت قد فتحت هذا الرابط بنفسك وتثق به.", + "Deep_link_login_title": "تسجيل الدخول إلى هذا الخادم؟", "Default": "افتراضي", "Default_browser": "المتصفح الأساسي", "DELETE": "حذف", @@ -713,4 +715,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} \ No newline at end of file +} diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 9daf3675e2f..d4952674049 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -213,6 +213,8 @@ "Dark": "অন্ধকার", "Dark_level": "অন্ধকার স্তর", "decline": "অস্বীকার করুন", + "Deep_link_login_description": "একটি লিঙ্ক আপনাকে {{server}}-এ সাইন ইন করাতে চাইছে। আপনি নিজে এই লিঙ্কটি খুলে থাকেন এবং এটিকে বিশ্বাস করেন তবেই এগিয়ে যান।", + "Deep_link_login_title": "এই সার্ভারে সাইন ইন করবেন?", "Default": "ডিফল্ট", "Default_browser": "ডিফল্ট ব্রাউজার", "Defined_user_as_role": "{{user}} একটি {{role}} হিসেবে নির্ধারণ করেছে", @@ -974,4 +976,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} \ No newline at end of file +} diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 1d18ac3efbb..2270b5705ec 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -225,6 +225,8 @@ "Dark": "Temný", "Dark_level": "Temná úroveň", "decline": "Pokles", + "Deep_link_login_description": "Odkaz se vás pokouší přihlásit do {{server}}. Pokračujte pouze, pokud jste tento odkaz otevřeli sami a důvěřujete mu.", + "Deep_link_login_title": "Přihlásit se k tomuto serveru?", "Default": "Výchozí", "Default_browser": "Výchozí prohlížeč", "Defined_user_as_role": "definováno {{user}} jako {{role}}", @@ -1048,4 +1050,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index c1925e399ea..ffa30042449 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -207,6 +207,8 @@ "Dark": "Dunkel", "Dark_level": "Dunkelstufe", "decline": "Ablehnen", + "Deep_link_login_description": "Ein Link versucht, Sie bei {{server}} anzumelden. Fahren Sie nur fort, wenn Sie diesen Link selbst geöffnet haben und ihm vertrauen.", + "Deep_link_login_title": "Bei diesem Server anmelden?", "Default": "Standard", "Default_browser": "Standard-Browser", "Defined_user_as_role": "hat {{user}} als {{role}} gesetzt", @@ -964,4 +966,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} \ No newline at end of file +} diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index d15ce6d6cb1..1231b020478 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -132,6 +132,8 @@ "Custom": "Personalizado", "Dark": "Oscuro", "Dark_level": "Nivel de oscuridad", + "Deep_link_login_description": "Un enlace está intentando iniciar sesión en {{server}}. Continúa solo si abriste este enlace tú mismo y confías en él.", + "Deep_link_login_title": "¿Iniciar sesión en este servidor?", "Default": "Por defecto", "DELETE": "ELIMINAR", "Delete": "Eliminar", @@ -534,4 +536,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 45e7e8c9264..fd37e0f50bb 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -196,6 +196,8 @@ "Custom": "Mukautettu", "Dark": "Tumma", "Dark_level": "Tumman taso", + "Deep_link_login_description": "Linkki yrittää kirjata sinut palvelimeen {{server}}. Jatka vain, jos avasit tämän linkin itse ja luotat siihen.", + "Deep_link_login_title": "Kirjaudutaanko tälle palvelimelle?", "Default": "Oletus", "Default_browser": "Oletusselain", "Defined_user_as_role": "määritti käyttäjän {{user}} rooliin {{role}}", @@ -936,4 +938,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index fa613486ddc..41ce1a69643 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -173,6 +173,8 @@ "Custom": "Personnalisé", "Dark": "Sombre", "Dark_level": "Niveau d'obscurité", + "Deep_link_login_description": "Un lien tente de vous connecter à {{server}}. Continuez uniquement si vous avez ouvert ce lien vous-même et que vous lui faites confiance.", + "Deep_link_login_title": "Se connecter à ce serveur ?", "Default": "Défaut", "Default_browser": "Navigateur par défaut", "DELETE": "SUPPRIMER", @@ -858,4 +860,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 97f3343177e..7a0791ddc16 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -213,6 +213,8 @@ "Dark": "डार्क", "Dark_level": "डार्क स्तर", "decline": "तिरस्कार", + "Deep_link_login_description": "एक लिंक आपको {{server}} में साइन इन कराना चाहता है। केवल तभी आगे बढ़ें जब आपने यह लिंक स्वयं खोला हो और इस पर भरोसा करते हों।", + "Deep_link_login_title": "इस सर्वर में साइन इन करें?", "Default": "डिफ़ॉल्ट", "Default_browser": "डिफ़ॉल्ट ब्राउज़र", "Defined_user_as_role": "{{user}} को {{role}} के रूप में परिभाषित किया गया है", @@ -974,4 +976,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 89f68d4a3b2..88f24f25429 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -213,6 +213,8 @@ "Dark": "Sötét", "Dark_level": "Sötét szint", "decline": "Elutasítom", + "Deep_link_login_description": "Egy hivatkozás be szeretné jelentkeztetni a(z) {{server}} kiszolgálóra. Csak akkor folytassa, ha ezt a hivatkozást Ön nyitotta meg, és megbízik benne.", + "Deep_link_login_title": "Bejelentkezik erre a kiszolgálóra?", "Default": "Alapértelmezett", "Default_browser": "Alapértelmezett böngésző", "Defined_user_as_role": "a(z) {{user}} felhasználót mint {{role}} definiálták", @@ -977,4 +979,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} \ No newline at end of file +} diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 5bd67b24060..bb843e8b41c 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -155,6 +155,8 @@ "Custom": "Personalizzato", "Dark": "Scuro", "Dark_level": "Contrasto", + "Deep_link_login_description": "Un link sta tentando di farti accedere a {{server}}. Continua solo se hai aperto tu questo link e ti fidi.", + "Deep_link_login_title": "Accedere a questo server?", "Default": "Predefinito", "Default_browser": "Browser predefinito", "DELETE": "ELIMINA", @@ -761,4 +763,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 86da11d01d9..49bef2c387c 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -147,6 +147,8 @@ "Custom": "カスタム", "Dark": "ダーク", "Dark_level": "ダークレベル", + "Deep_link_login_description": "リンクが {{server}} へのサインインを求めています。このリンクを自分で開き、信頼できる場合にのみ続行してください。", + "Deep_link_login_title": "このサーバーにサインインしますか?", "Default": "デフォルト", "Default_browser": "デフォルトのブラウザ", "DELETE": "削除", @@ -632,4 +634,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 0d86ce4995c..4f51facddc2 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -173,6 +173,8 @@ "Custom": "Aangepast", "Dark": "Donker", "Dark_level": "Donker niveau", + "Deep_link_login_description": "Een link probeert je aan te melden bij {{server}}. Ga alleen verder als je deze link zelf hebt geopend en je deze vertrouwt.", + "Deep_link_login_title": "Aanmelden bij deze server?", "Default": "Standaard", "Default_browser": "Standaard browser", "DELETE": "VERWIJDEREN", @@ -858,4 +860,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 84f8b93d623..fdd0cda5640 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -117,6 +117,8 @@ "Current_Status": "Nåværende status", "Custom": "Tilpasset", "decline": "Avslå", + "Deep_link_login_description": "Ei lenkje prøver å logge deg inn på {{server}}. Hald berre fram dersom du opna denne lenkja sjølv og stolar på henne.", + "Deep_link_login_title": "Logge inn på denne serveren?", "Default": "Misligholde", "Delete": "Slett", "Delete_Account": "Slett konto", @@ -481,4 +483,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} \ No newline at end of file +} diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 097da054fa0..8b701a8d9f4 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -217,6 +217,8 @@ "Dark": "Mørk", "Dark_level": "Mørkt nivå", "decline": "Avslå", + "Deep_link_login_description": "En lenke prøver å logge deg inn på {{server}}. Fortsett bare hvis du åpnet denne lenken selv og stoler på den.", + "Deep_link_login_title": "Logge inn på denne serveren?", "Default": "Standard", "Default_browser": "Standard nettleser", "Defined_user_as_role": "definert {{user}} som {{role}}", @@ -1024,4 +1026,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 9b2e3db9f8f..c9747ed420b 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -226,6 +226,8 @@ "Dark": "Escuro", "Dark_level": "Nível escuro", "decline": "Recusar", + "Deep_link_login_description": "Um link está tentando fazer seu login em {{server}}. Continue apenas se você abriu esse link por conta própria e confia nele.", + "Deep_link_login_title": "Fazer login neste servidor?", "Default": "Padrão", "Default_browser": "Navegador padrão", "Defined_user_as_role": "definiu {{user}} como {{role}}", @@ -1061,4 +1063,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 22b18117bb4..5744674b3cb 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -143,6 +143,8 @@ "Custom": "Personalizado", "Dark": "Escuro", "Dark_level": "Nível Escuro", + "Deep_link_login_description": "Um link está a tentar iniciar a sua sessão em {{server}}. Continue apenas se abriu este link por si próprio e confia nele.", + "Deep_link_login_title": "Iniciar sessão neste servidor?", "Default": "Predefinição", "Default_browser": "Navegador predefinido", "DELETE": "APAGAR", @@ -593,4 +595,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 950a186f227..e86d23caf64 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -190,6 +190,8 @@ "Custom": "Пользовательский", "Dark": "Темный", "Dark_level": "Уровень затемненности", + "Deep_link_login_description": "Ссылка пытается выполнить вход в {{server}}. Продолжайте, только если вы сами открыли эту ссылку и доверяете ей.", + "Deep_link_login_title": "Войти на этот сервер?", "Default": "По умолчанию", "Default_browser": "Браузер по умолчанию", "DELETE": "УДАЛИТЬ", @@ -904,4 +906,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 0957302775d..9bd5fbdb446 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -178,6 +178,8 @@ "Custom": "Po meri", "Dark": "Temno", "Dark_level": "Temna raven", + "Deep_link_login_description": "Povezava vas poskuša prijaviti v {{server}}. Nadaljujte le, če ste to povezavo odprli sami in ji zaupate.", + "Deep_link_login_title": "Se želite prijaviti v ta strežnik?", "Default": "Privzeto", "Default_browser": "Privzeti brskalnik", "DELETE": "Izbrisati", @@ -873,4 +875,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 3466cfea7ca..5d19db5c6ae 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -196,6 +196,8 @@ "Custom": "Anpassad", "Dark": "Mörk", "Dark_level": "Mörk nivå", + "Deep_link_login_description": "En länk försöker logga in dig på {{server}}. Fortsätt bara om du själv öppnade den här länken och litar på den.", + "Deep_link_login_title": "Logga in på den här servern?", "Default": "Standard", "Default_browser": "Standardwebbläsare", "Defined_user_as_role": "angav {{user}} som {{role}}", @@ -934,4 +936,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 83f1a3251fd..94155bbda48 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -213,6 +213,8 @@ "Dark": "கருப்பு", "Dark_level": "கருப்பு அளவு", "decline": "மறுக்கு", + "Deep_link_login_description": "ஒரு இணைப்பு உங்களை {{server}} இல் உள்நுழையச் கேட்கிறது. இந்த இணைப்பை நீங்களே திறந்து அதைப் நம்பினால் மட்டுமே தொடரவும்.", + "Deep_link_login_title": "இந்த சர்வரில் உள்நுழையவா?", "Default": "இயல்புநிலை", "Default_browser": "இயல்புநிலை உலாவி", "Defined_user_as_role": "{{user}} ஐ {{role}} என்று வெளியிடப்பட்டார்", @@ -974,4 +976,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} \ No newline at end of file +} diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index dab8b05080e..84248a570d3 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -212,6 +212,8 @@ "Dark": "గాఢంగా", "Dark_level": "గాఢంగా స్థాయి", "decline": "నిరాకరించు", + "Deep_link_login_description": "ఒక లింక్ మిమ్మల్ని {{server}} లో సైన్ ఇన్ చేయాలని కోరుతోంది. మీరు ఈ లింక్‌ను స్వయంగా తెరిచి దానిని నమ్మితేనే కొనసాగండి.", + "Deep_link_login_title": "ఈ సర్వర్‌లో సైన్ ఇన్ చేయాలా?", "Default": "స్వచ్ఛందం", "Default_browser": "స్వచ్ఛంద బ్రౌజర్", "Defined_user_as_role": "{{user}} నియమాలను {{role}} గా ప్రవృత్తించారు", @@ -973,4 +975,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} \ No newline at end of file +} diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 8c03a15c015..bd4e6d038a4 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -147,6 +147,8 @@ "Custom": "Özel", "Dark": "Karanlık", "Dark_level": "Karanlık Seviyesi", + "Deep_link_login_description": "Bir bağlantı sizi {{server}} sunucusunda oturum açtırmak istiyor. Yalnızca bu bağlantıyı kendiniz açtıysanız ve güveniyorsanız devam edin.", + "Deep_link_login_title": "Bu sunucuda oturum açılsın mı?", "Default": "Varsayılan", "Default_browser": "Varsayılan tarayıcı", "DELETE": "SİL", @@ -744,4 +746,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 396b899cefe..be222414968 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -146,6 +146,8 @@ "Custom": "自定义", "Dark": "深色", "Dark_level": "深色程度", + "Deep_link_login_description": "有一个链接正尝试让你登录到 {{server}}。只有在你亲自打开此链接并且信任它的情况下才继续。", + "Deep_link_login_title": "登录到此服务器?", "Default": "默認", "Default_browser": "预设浏览器", "DELETE": "删除", @@ -704,4 +706,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index a82f36c4aa9..d3c62b17903 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -148,6 +148,8 @@ "Custom": "自訂", "Dark": "深色", "Dark_level": "深色程度", + "Deep_link_login_description": "有一個連結正嘗試讓你登入 {{server}}。只有在你親自開啟此連結並且信任它的情況下才繼續。", + "Deep_link_login_title": "登入此伺服器?", "Default": "預設", "Default_browser": "預設瀏覽器", "DELETE": "刪除", @@ -733,4 +735,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} \ No newline at end of file +} From bdf531a89d69d3f062ac1d13b9ba54be7db93a18 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 16:50:15 -0300 Subject: [PATCH 08/21] refactor: extract token migration out of init saga into a tested async function --- app/lib/constants/keys.ts | 1 + .../migrateTokenKeysToServerScoped.test.ts | 112 ++++++++++++++++++ .../methods/migrateTokenKeysToServerScoped.ts | 57 +++++++++ app/sagas/init.js | 58 +-------- app/sagas/selectServer.ts | 2 +- 5 files changed, 173 insertions(+), 57 deletions(-) create mode 100644 app/lib/methods/migrateTokenKeysToServerScoped.test.ts create mode 100644 app/lib/methods/migrateTokenKeysToServerScoped.ts diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 820ec37c0c2..4289979b498 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -30,5 +30,6 @@ export const TOKEN_KEY = 'reactnativemeteor_usertoken'; * and the migration in the init saga. */ export const getUserTokenKey = (server: string, userId: string): string => `${TOKEN_KEY}-${server}-${userId}`; +export const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; export const CURRENT_SERVER = 'currentServer'; export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY'; diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts new file mode 100644 index 00000000000..2ff8f49a778 --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts @@ -0,0 +1,112 @@ +import { migrateTokenKeysToServerScoped } from './migrateTokenKeysToServerScoped'; +import UserPreferences from './userPreferences'; +import database from '../database'; +import log from './helpers/log'; +import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys'; + +jest.mock('../database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn() + } + } +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockedFetch = jest.fn(); + +// Configure which server records `database.servers.get('servers').query().fetch()` resolves to. +const setServers = (serverIds: string[]) => { + mockedFetch.mockResolvedValue(serverIds.map(id => ({ id }))); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); +}; + +describe('migrateTokenKeysToServerScoped', () => { + beforeEach(() => { + jest.clearAllMocks(); + UserPreferences.clearAll(); + }); + + it('is a no-op when the migration flag is already set', async () => { + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + setServers(['https://open.rocket.chat']); + + await migrateTokenKeysToServerScoped(); + + expect(database.servers.get).not.toHaveBeenCalled(); + }); + + it('migrates the legacy token to the server-scoped slot and drops the legacy slot', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'the-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('the-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('drops the legacy slot without migrating when the userId is shared by multiple servers', async () => { + const serverA = 'https://a.rocket.chat'; + const serverB = 'https://b.rocket.chat'; + const userId = 'shared'; + UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'ambiguous-token'); + setServers([serverA, serverB]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverA, userId))).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverB, userId))).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('does not overwrite an existing server-scoped token', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token'); + UserPreferences.setString(getUserTokenKey(server, userId), 'existing-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('existing-token'); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('skips servers that have no stored userId', async () => { + const server = 'https://open.rocket.chat'; + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('logs and swallows errors instead of throwing, leaving the flag unset', async () => { + const error = new Error('db exploded'); + mockedFetch.mockRejectedValue(error); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); + + await expect(migrateTokenKeysToServerScoped()).resolves.toBeUndefined(); + + expect(log).toHaveBeenCalledWith(error); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBeNull(); + }); +}); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts new file mode 100644 index 00000000000..ca21437522a --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -0,0 +1,57 @@ +import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys'; +import UserPreferences from './userPreferences'; +import database from '../database'; +import log from './helpers/log'; + +/** + * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the + * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds + * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication. + */ +export const migrateTokenKeysToServerScoped = async (): Promise => { + try { + if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { + return; + } + const serversDB = database.servers; + const servers = await serversDB.get('servers').query().fetch(); + + // Map each server to its userId and count how many servers reference each userId. + const serverUserIds: { server: string; userId: string }[] = []; + const serverCountByUserId: Record = {}; + for (let i = 0; i < servers.length; i += 1) { + const server = servers[i].id; + const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + if (!userId) { + continue; + } + serverUserIds.push({ server, userId }); + serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; + } + + // Collected in a Set so an ambiguous userId shared by N servers is removed once. + const legacyKeys = new Set(); + for (let i = 0; i < serverUserIds.length; i += 1) { + const { server, userId } = serverUserIds[i]; + const legacyKey = `${TOKEN_KEY}-${userId}`; + // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates. + if (serverCountByUserId[userId] > 1) { + legacyKeys.add(legacyKey); + continue; + } + const newKey = getUserTokenKey(server, userId); + if (!UserPreferences.getString(newKey)) { + const token = UserPreferences.getString(legacyKey); + if (token) { + UserPreferences.setString(newKey, token); + legacyKeys.add(legacyKey); + } + } + } + // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done. + legacyKeys.forEach(key => UserPreferences.removeItem(key)); + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + } catch (e) { + log(e); + } +}; diff --git a/app/sagas/init.js b/app/sagas/init.js index ac844416271..1ea6b7373a4 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -2,8 +2,9 @@ import { call, put, select, takeLatest } from 'redux-saga/effects'; import RNBootSplash from 'react-native-bootsplash'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; @@ -21,61 +22,6 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; -const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; - -/** - * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the - * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds - * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication. - */ -export const migrateTokenKeysToServerScoped = function* migrateTokenKeysToServerScoped() { - try { - if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { - return; - } - const serversDB = database.servers; - const servers = yield serversDB.get('servers').query().fetch(); - - // Map each server to its userId and count how many servers reference each userId. - const serverUserIds = []; - const serverCountByUserId = {}; - for (let i = 0; i < servers.length; i += 1) { - const server = servers[i].id; - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); - if (!userId) { - continue; - } - serverUserIds.push({ server, userId }); - serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; - } - - // Collected in a Set so an ambiguous userId shared by N servers is removed once. - const legacyKeys = new Set(); - for (let i = 0; i < serverUserIds.length; i += 1) { - const { server, userId } = serverUserIds[i]; - const legacyKey = `${TOKEN_KEY}-${userId}`; - // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates. - if (serverCountByUserId[userId] > 1) { - legacyKeys.add(legacyKey); - continue; - } - const newKey = getUserTokenKey(server, userId); - if (!UserPreferences.getString(newKey)) { - const token = UserPreferences.getString(legacyKey); - if (token) { - UserPreferences.setString(newKey, token); - legacyKeys.add(legacyKey); - } - } - } - // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done. - legacyKeys.forEach(key => UserPreferences.removeItem(key)); - UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); - } catch (e) { - log(e); - } -}; - const restore = function* restore() { try { yield call(migrateTokenKeysToServerScoped); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 966c2dce620..06b755d1a58 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -30,7 +30,7 @@ import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { type IServerInfo, RootEnum, type TServerModel } from '../definitions'; import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; -import { migrateTokenKeysToServerScoped } from './init'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions'; import { getLoginSettings, setSettings } from '../lib/methods/getSettings'; import { getServerInfo } from '../lib/methods/getServerInfo'; From 36d748b8543f465899caa7605bf3d6ff8875135d Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 19:50:50 +0000 Subject: [PATCH 09/21] action: organized translations --- app/i18n/locales/ar.json | 2 +- app/i18n/locales/bn-IN.json | 2 +- app/i18n/locales/cs.json | 2 +- app/i18n/locales/de.json | 2 +- app/i18n/locales/es.json | 2 +- app/i18n/locales/fi.json | 2 +- app/i18n/locales/fr.json | 2 +- app/i18n/locales/hi-IN.json | 2 +- app/i18n/locales/hu.json | 2 +- app/i18n/locales/it.json | 2 +- app/i18n/locales/ja.json | 2 +- app/i18n/locales/nl.json | 2 +- app/i18n/locales/nn.json | 2 +- app/i18n/locales/no.json | 2 +- app/i18n/locales/pt-BR.json | 2 +- app/i18n/locales/pt-PT.json | 2 +- app/i18n/locales/ru.json | 2 +- app/i18n/locales/sl-SI.json | 2 +- app/i18n/locales/sv.json | 2 +- app/i18n/locales/ta-IN.json | 2 +- app/i18n/locales/te-IN.json | 2 +- app/i18n/locales/tr.json | 2 +- app/i18n/locales/zh-CN.json | 2 +- app/i18n/locales/zh-TW.json | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index e8b292dda55..12c58ecf7fa 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -715,4 +715,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} +} \ No newline at end of file diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index d4952674049..0af42395d7b 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -976,4 +976,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} +} \ No newline at end of file diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 2270b5705ec..aba97a51fd9 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -1050,4 +1050,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index ffa30042449..f874c61860d 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -966,4 +966,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} +} \ No newline at end of file diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 1231b020478..4afb4e86850 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -536,4 +536,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index fd37e0f50bb..53867e698be 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -938,4 +938,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 41ce1a69643..f488d0a3967 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -860,4 +860,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 7a0791ddc16..62502372b56 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -976,4 +976,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 88f24f25429..02e5a3812ac 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -979,4 +979,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} +} \ No newline at end of file diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index bb843e8b41c..21e6661be1c 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -763,4 +763,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 49bef2c387c..e39519f170f 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -634,4 +634,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 4f51facddc2..a2ee78bf978 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -860,4 +860,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index fdd0cda5640..a8c718c2419 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -483,4 +483,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} +} \ No newline at end of file diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 8b701a8d9f4..84994a28492 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -1026,4 +1026,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index c9747ed420b..77c8517b57f 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -1063,4 +1063,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 5744674b3cb..0e3ae5756e9 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -595,4 +595,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index e86d23caf64..5cb29e37d94 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -906,4 +906,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 9bd5fbdb446..d977b39d383 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -875,4 +875,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 5d19db5c6ae..89ea2f47d8c 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -936,4 +936,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 94155bbda48..89f752326ba 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -976,4 +976,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} +} \ No newline at end of file diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 84248a570d3..13db35a4f5c 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -975,4 +975,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} +} \ No newline at end of file diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index bd4e6d038a4..1f494c84a26 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -746,4 +746,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index be222414968..15050547652 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -706,4 +706,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index d3c62b17903..a709d9d8740 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -735,4 +735,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} +} \ No newline at end of file From 109a79ca4fe9945dc5dfd4bc34210c223fb91d82 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 16:53:05 -0300 Subject: [PATCH 10/21] refactor(android): extract legacy token fallback into deprecated method --- .../rocket/reactnative/notification/Ejson.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 0cccba61772..b028bfff9d2 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -162,9 +162,7 @@ public String token() { } if (token == null || token.isEmpty()) { - // Legacy fallback (pre-migration). Safe because the exported-receiver vector that - // abused this ambiguity is closed (ReplyBroadcast/DismissNotification are not exported). - token = mmkv.decodeString(TOKEN_KEY.concat(userId)); + token = decodeLegacyUserIdScopedToken(mmkv, userId); } if (token == null || token.isEmpty()) { @@ -176,6 +174,17 @@ public String token() { return token != null ? token : ""; } + /** + * Reads the token from the legacy userId-only slot, used until {@code migrateTokenKeysToServerScoped} + * (JS init saga) moves it to the server-scoped slot and deletes it. + * + * @deprecated remove once the migration is universal. + */ + @Deprecated + private String decodeLegacyUserIdScopedToken(MMKV mmkv, String userId) { + return mmkv.decodeString(TOKEN_KEY.concat(userId)); + } + public String userId() { String serverURL = serverURL(); From 473d490d8aa400d0d62829677f1c3bec6fdd1f88 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 2 Jul 2026 17:02:28 -0300 Subject: [PATCH 11/21] refactor: dedupe migration constant, extract legacy token fallback, and simplify comments --- .maestro/tests/assorted/deeplink.yaml | 75 +++++++++++++++++++++++++ app/sagas/__tests__/deepLinking.test.ts | 43 ++++++++++++++ app/sagas/deepLinking.js | 11 ++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 4c5780f8b29..5c2c6fb16d9 100644 --- a/.maestro/tests/assorted/deeplink.yaml +++ b/.maestro/tests/assorted/deeplink.yaml @@ -310,3 +310,78 @@ tags: visible: id: 'new-server-view' timeout: 60000 + +# should show a security confirmation prompt on deep link login and abort when cancelled. +- runFlow: + file: '../../helpers/launch-app.yaml' +- stopApp: ${APP_ID} +- runFlow: + file: '../../helpers/open-deeplink.yaml' + env: + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&e2eConfirmPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +- assertVisible: + text: '.*A link is asking to sign you in.*' +# decline the prompt — the app must NOT sign in or navigate to the room +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'android:id/button2' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Cancel' +- extendedWaitUntil: + visible: + id: 'workspace-view' + timeout: 60000 +- assertNotVisible: + id: 'room-view-title-${output.room.name}' + +# should show a security confirmation prompt on deep link login and sign in when confirmed +- runFlow: + file: '../../helpers/launch-app.yaml' +- stopApp: ${APP_ID} +- runFlow: + file: '../../helpers/open-deeplink.yaml' + env: + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&e2eConfirmPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +# confirm the prompt — the app must sign in and navigate to the room. +# android:id/button1 is the positive (confirm) button, disambiguating it from the "Login" button +# rendered on the workspace screen behind the alert. +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'android:id/button1' + timeout: 60000 + - tapOn: + id: 'android:id/button1' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Login' +- extendedWaitUntil: + visible: + id: 'room-view-title-${output.room.name}' + timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 13ad3306c24..fcda4137409 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -329,6 +329,49 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(actions.some(a => a.type === APP.START && (a as any).root === RootEnum.ROOT_OUTSIDE)).toBe(true); }); + // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a + // native Alert — except when the deep link carries `e2eConfirmPrompt=true`, which opts a + // dedicated e2e flow back into the real prompt (see the deeplink.yaml Maestro test). + describe('RUNNING_E2E_TESTS auto-confirm gate', () => { + const original = process.env.RUNNING_E2E_TESTS; + beforeEach(() => { + process.env.RUNNING_E2E_TESTS = 'true'; + jest.mocked(showConfirmationAlert).mockClear(); + }); + afterEach(() => { + process.env.RUNNING_E2E_TESTS = original; + }); + + it('auto-confirms without showing the prompt when no e2eConfirmPrompt marker is present', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // No prompt shown, yet login still proceeds — pre-fix silent behavior preserved. + expect(jest.mocked(showConfirmationAlert)).not.toHaveBeenCalled(); + expect(loginRequested()).toBe(true); + }); + + it('shows the real prompt when the deep link carries e2eConfirmPrompt=true', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken({ e2eConfirmPrompt: 'true' }))); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + }); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 81cb8c152b4..288098b0f8d 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -38,12 +38,11 @@ const roomTypes = { channels: 'l' }; -const confirmDeepLinkLogin = host => +const confirmDeepLinkLogin = (host, params = {}) => new Promise(resolve => { - // E2E tests bootstrap the session via a deep link and can't dismiss a native Alert, so - // auto-confirm under RUNNING_E2E_TESTS. This preserves the pre-fix silent behavior for - // tests only; real users still get the security prompt. - if (process.env.RUNNING_E2E_TESTS === 'true') { + // Under E2E tests, auto-confirm so flows don't have to dismiss the native Alert. + // `e2eConfirmPrompt=true` opts back into the real prompt to test it. Test-only; real users always get the prompt. + if (process.env.RUNNING_E2E_TESTS === 'true' && params.e2eConfirmPrompt !== 'true') { resolve(true); return; } @@ -261,7 +260,7 @@ const handleOpen = function* handleOpen({ params }) { } if (params.token) { - const confirmed = yield call(confirmDeepLinkLogin, host); + const confirmed = yield call(confirmDeepLinkLogin, host, params); if (!confirmed) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); return; From 9f209aeb68927f121b180b0aa91acdea4732645c Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 3 Jul 2026 17:35:44 -0300 Subject: [PATCH 12/21] fix: e2e test login on ios --- .maestro/tests/assorted/deeplink.yaml | 2 ++ app/sagas/__tests__/deepLinking.test.ts | 35 +++++++++++++++++++++++++ app/sagas/deepLinking.js | 8 +++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 5c2c6fb16d9..d8c805b730c 100644 --- a/.maestro/tests/assorted/deeplink.yaml +++ b/.maestro/tests/assorted/deeplink.yaml @@ -376,6 +376,8 @@ tags: commands: - tapOn: text: 'Login' + rightOf: + text: 'Cancel' - extendedWaitUntil: visible: id: 'room-view-title-${output.room.name}' diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index fcda4137409..d5ae4672d44 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -288,6 +288,41 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + // Ordering race: the confirm prompt stays open (real native Alert) long enough for the + // NewServer connection — kicked off *before* the prompt — to complete. SERVER.SELECT_SUCCESS + // then fires while the user is still deciding, so the take must be guarded or the saga hangs + // on WorkspaceView without ever logging in (the iOS deep-link symptom). + it('completes the chain when SERVER.SELECT_SUCCESS fires while the confirm prompt is open', async () => { + // Capture onPress instead of auto-confirming, mimicking a prompt the user hasn't tapped yet. + let confirm: (() => void) | undefined; + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onPress }: any) => { + confirm = onPress; + }); + + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Prompt is open, not yet confirmed. The connection completes now: both + // SERVER.SELECT_SUCCESS and the socket connect fire before the saga reaches its takes. + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Still parked in the prompt — no premature login. + expect(loginRequested()).toBe(false); + + // User confirms. The guard sees the server is already selected + connected and skips the + // stale takes, so loginRequest fires instead of the saga hanging. + confirm?.(); + await flushSagaMicrotasks(); + expect(loginRequested()).toBe(true); + }); + // loginRequest must not fire until the socket is connected (locks the gate). it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { const { store, actions } = setupRecordingStore(); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 288098b0f8d..23d0661263d 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -266,7 +266,13 @@ const handleOpen = function* handleOpen({ params }) { return; } if (!hostAlreadyConnected) { - yield take(types.SERVER.SELECT_SUCCESS); + // The confirm prompt can sit open for seconds while the NewServer connection + // (emitted above) completes, so SERVER.SELECT_SUCCESS may have already fired by + // the time we get here. Guard the take instead of hanging on an event that's past. + const serverSelected = yield select(state => state.server.server === host && state.server.connected); + if (!serverSelected) { + yield take(types.SERVER.SELECT_SUCCESS); + } // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. const connected = yield select(state => state.meteor.connected); if (!connected) { From f1f8fdd9d687f474ed4d8d013c4fbc6fc6cdb74e Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 12 Aug 2026 16:14:33 -0300 Subject: [PATCH 13/21] fix: tests --- .../__snapshots__/Markdown.test.tsx.snap | 2 +- app/sagas/__tests__/deepLinking.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap index 7967e1294f1..5756d2d0cd9 100644 --- a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap +++ b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap @@ -9538,7 +9538,7 @@ exports[`Story Snapshots: Timestamp should match snapshot 1`] = ` ] } > - 2 years ago + a year ago diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 2bcdc71e8c3..291c2cb4efd 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -103,6 +103,7 @@ import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLin import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; +import { connectSuccess } from '../../actions/connect'; import { APP, LOGIN } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; @@ -136,6 +137,19 @@ function setupStore(preloadedState?: PreloadedState) { return store; } +/** Same as setupStore, plus an `actions` array recording everything dispatched. */ +function setupRecordingStore(preloadedState?: PreloadedState) { + const sagaMiddleware = createSagaMiddleware(); + const actions: any[] = []; + const recorder = () => (next: any) => (action: any) => { + actions.push(action); + return next(action); + }; + const store = createStore(reducers, preloadedState, applyMiddleware(recorder, sagaMiddleware)); + sagaMiddleware.run(deepLinkingRoot); + return { store, actions }; +} + // ─── Factories ──────────────────────────────────────────────────────────────── const HOST = 'https://open.rocket.chat'; @@ -223,6 +237,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Saga is now waiting for METEOR.SUCCESS — loginRequest is gated on the socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Saga is now waiting for LOGIN.SUCCESS expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); @@ -405,6 +423,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -437,6 +458,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush. // The reducer processes both dispatches before the saga's select runs, // so the select sees ROOT_INSIDE and skips the take. @@ -466,6 +490,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -501,6 +528,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); From 6eb4c88d2ed6903993e068be5b3b0dec49997980 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 12:31:23 -0300 Subject: [PATCH 14/21] chore: trim explanatory comments from server-scoped token keys --- .../chat/rocket/reactnative/notification/Ejson.java | 5 ++--- app/lib/constants/keys.ts | 7 +------ app/lib/methods/logout.ts | 1 - app/lib/methods/migrateTokenKeysToServerScoped.ts | 11 ++--------- app/sagas/deepLinking.js | 6 ------ app/sagas/selectServer.ts | 3 --- ios/Shared/RocketChat/MMKV.swift | 5 ++--- 7 files changed, 7 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index b028bfff9d2..545fdead59e 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -149,9 +149,8 @@ public String token() { return ""; } - // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. - // Keep in sync with getUserTokenKey() on the JS side; falls back to the legacy - // userId-only slot until the JS migration runs. + // Keep in sync with getUserTokenKey() (JS); falls back to the legacy userId-only slot + // until the JS migration runs. String token = null; if (serverURL != null && !serverURL.isEmpty()) { String key = TOKEN_KEY.concat(serverURL).concat("-").concat(userId); diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 4289979b498..6e6ce9faac8 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -23,12 +23,7 @@ export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERE export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY'; export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY'; export const TOKEN_KEY = 'reactnativemeteor_usertoken'; -/** - * MMKV key for the auth token, scoped to (server, userId). Scoping by userId alone was ambiguous: - * two servers can share a userId (a malicious one can force it), so lookups could resolve another - * server's token (token confusion / exfiltration). Keep in sync with `Ejson.token()` on Android - * and the migration in the init saga. - */ +// Keep in sync with Ejson.token() (Android) and MMKV.userToken(for:server:) (iOS). export const getUserTokenKey = (server: string, userId: string): string => `${TOKEN_KEY}-${server}-${userId}`; export const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; export const CURRENT_SERVER = 'currentServer'; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index efd7d253108..6421943dd72 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -25,7 +25,6 @@ function removeServerKeys({ server, userId }: { server: string; userId?: string UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); if (userId) { UserPreferences.removeItem(getUserTokenKey(server, userId)); - // Also remove the legacy non-server-scoped token slot, in case this server predates the migration. UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts index ca21437522a..4d257d73893 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -3,11 +3,6 @@ import UserPreferences from './userPreferences'; import database from '../database'; import log from './helpers/log'; -/** - * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the - * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds - * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication. - */ export const migrateTokenKeysToServerScoped = async (): Promise => { try { if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { @@ -16,7 +11,6 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { const serversDB = database.servers; const servers = await serversDB.get('servers').query().fetch(); - // Map each server to its userId and count how many servers reference each userId. const serverUserIds: { server: string; userId: string }[] = []; const serverCountByUserId: Record = {}; for (let i = 0; i < servers.length; i += 1) { @@ -29,12 +23,12 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; } - // Collected in a Set so an ambiguous userId shared by N servers is removed once. const legacyKeys = new Set(); for (let i = 0; i < serverUserIds.length; i += 1) { const { server, userId } = serverUserIds[i]; const legacyKey = `${TOKEN_KEY}-${userId}`; - // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates. + // A userId claimed by more than one server is ambiguous: drop the legacy slot instead of + // migrating it, so the session re-authenticates. if (serverCountByUserId[userId] > 1) { legacyKeys.add(legacyKey); continue; @@ -48,7 +42,6 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { } } } - // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done. legacyKeys.forEach(key => UserPreferences.removeItem(key)); UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); } catch (e) { diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 6146877b3ab..9b17cdc7bec 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -40,8 +40,6 @@ const roomTypes = { const confirmDeepLinkLogin = (host, params = {}) => new Promise(resolve => { - // Under E2E tests, auto-confirm so flows don't have to dismiss the native Alert. - // `e2eConfirmPrompt=true` opts back into the real prompt to test it. Test-only; real users always get the prompt. if (process.env.RUNNING_E2E_TESTS === 'true' && params.e2eConfirmPrompt !== 'true') { resolve(true); return; @@ -266,14 +264,10 @@ const handleOpen = function* handleOpen({ params }) { return; } if (!hostAlreadyConnected) { - // The confirm prompt can sit open for seconds while the NewServer connection - // (emitted above) completes, so SERVER.SELECT_SUCCESS may have already fired by - // the time we get here. Guard the take instead of hanging on an event that's past. const serverSelected = yield select(state => state.server.server === host && state.server.connected); if (!serverSelected) { yield take(types.SERVER.SELECT_SUCCESS); } - // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. const connected = yield select(state => state.meteor.connected); if (!connected) { yield take(types.METEOR.SUCCESS); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 06b755d1a58..3d6c45acc5c 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -151,9 +151,6 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(inquiryReset()); yield put(encryptionStop()); yield put(clearActiveUsers()); - // Deep-link and share-extension startup can dispatch selectServerRequest() directly, without - // going through appInit(). Run the (idempotent, flag-guarded) token migration here too so the - // server-scoped read below finds legacy sessions instead of falling through to a login screen. yield* call(migrateTokenKeysToServerScoped); const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); let user = null; diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index 442199c126b..1087ceceba2 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -18,9 +18,8 @@ extension MMKVBridge { return MMKVBridge(id: "default", cryptKey: cryptKey, rootPath: mmkvPath) } - // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. Keep in sync with - // getUserTokenKey() (JS) and Ejson.token() (Android); falls back to the legacy - // userId-only slot until the JS migration runs. + // Keep in sync with getUserTokenKey() (JS) and Ejson.token() (Android); falls back to the + // legacy userId-only slot until the JS migration runs. func userToken(for userId: String, server: String) -> String? { if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") { return userToken From 835485fa738822adcef4ae081f704fbe86cdb548 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 12:33:39 -0300 Subject: [PATCH 15/21] chore: drop dead legacy token removal on logout --- app/lib/methods/logout.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index 6421943dd72..9df1e3ab753 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -25,7 +25,6 @@ function removeServerKeys({ server, userId }: { server: string; userId?: string UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); if (userId) { UserPreferences.removeItem(getUserTokenKey(server, userId)); - UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); UserPreferences.removeItem(`${server}-${E2E_PUBLIC_KEY}`); From 808c25f000e8ebd351866e12bd3a29b5818a7426 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 12:59:41 -0300 Subject: [PATCH 16/21] refactor: route token key namespace through helpers in keys.ts --- app/lib/constants/keys.ts | 3 ++- app/lib/methods/logout.ts | 8 ++++---- app/lib/methods/migrateTokenKeysToServerScoped.ts | 6 +++--- app/sagas/deepLinking.js | 8 ++++---- app/sagas/init.js | 6 +++--- app/sagas/login.js | 8 ++++---- app/sagas/selectServer.ts | 4 ++-- app/views/RoomsListView/components/ServersList.tsx | 4 ++-- 8 files changed, 24 insertions(+), 23 deletions(-) diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 6e6ce9faac8..dac7ea72265 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -23,7 +23,8 @@ export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERE export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY'; export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY'; export const TOKEN_KEY = 'reactnativemeteor_usertoken'; -// Keep in sync with Ejson.token() (Android) and MMKV.userToken(for:server:) (iOS). +export const getServerUserIdKey = (server: string): string => `${TOKEN_KEY}-${server}`; +export const getLegacyUserTokenKey = (userId: string): string => `${TOKEN_KEY}-${userId}`; export const getUserTokenKey = (server: string, userId: string): string => `${TOKEN_KEY}-${server}-${userId}`; export const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; export const CURRENT_SERVER = 'currentServer'; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index 9df1e3ab753..651e09621f5 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -13,7 +13,7 @@ import { E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, - TOKEN_KEY, + getServerUserIdKey, getUserTokenKey } from '../constants/keys'; import UserPreferences from './userPreferences'; @@ -22,7 +22,7 @@ import { roomsSubscription } from './subscriptions/rooms'; import { _activeUsersSubTimeout } from './getUsersPresence'; function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) { - UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); + UserPreferences.removeItem(getServerUserIdKey(server)); if (userId) { UserPreferences.removeItem(getUserTokenKey(server, userId)); } @@ -36,7 +36,7 @@ export async function removeServerData({ server }: { server: string }): Promise< try { const batch: Model[] = []; const serversDB = database.servers; - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); const usersCollection = serversDB.get('users'); if (userId) { @@ -69,7 +69,7 @@ export async function removeServerDatabase({ server }: { server: string }): Prom export async function removeServer({ server }: { server: string }): Promise { try { - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); if (userId) { const resume = UserPreferences.getString(getUserTokenKey(server, userId)); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts index 4d257d73893..be93e030df8 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -1,4 +1,4 @@ -import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys'; +import { TOKEN_KEY_SERVER_SCOPED_MIGRATED, getLegacyUserTokenKey, getServerUserIdKey, getUserTokenKey } from '../constants/keys'; import UserPreferences from './userPreferences'; import database from '../database'; import log from './helpers/log'; @@ -15,7 +15,7 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { const serverCountByUserId: Record = {}; for (let i = 0; i < servers.length; i += 1) { const server = servers[i].id; - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); if (!userId) { continue; } @@ -26,7 +26,7 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { const legacyKeys = new Set(); for (let i = 0; i < serverUserIds.length; i += 1) { const { server, userId } = serverUserIds[i]; - const legacyKey = `${TOKEN_KEY}-${userId}`; + const legacyKey = getLegacyUserTokenKey(userId); // A userId claimed by more than one server is ambiguous: drop the legacy slot instead of // migrating it, so the session re-authenticates. if (serverCountByUserId[userId] > 1) { diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 9b17cdc7bec..56cd40de378 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -10,7 +10,7 @@ import { inviteLinksRequest, inviteLinksSetToken } from '../actions/inviteLinks' import { loginRequest } from '../actions/login'; import { selectServerRequest, serverInitAdd } from '../actions/server'; import { RootEnum } from '../definitions'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey } from '../lib/constants/keys'; import database from '../lib/database'; import { getServerById } from '../lib/database/services/Server'; import { canOpenRoom } from '../lib/methods/canOpenRoom'; @@ -161,7 +161,7 @@ const handleOAuth = function* handleOAuth({ params }) { const handleShareExtension = function* handleOpen({ params }) { const server = UserPreferences.getString(CURRENT_SERVER); - const user = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const user = UserPreferences.getString(getServerUserIdKey(server)); if (!user) { yield put(appInit()); @@ -209,7 +209,7 @@ const handleOpen = function* handleOpen({ params }) { const [server, user] = yield all([ UserPreferences.getString(CURRENT_SERVER), - UserPreferences.getString(`${TOKEN_KEY}-${host}`) + UserPreferences.getString(getServerUserIdKey(host)) ]); const serverRecord = yield getServerById(host); @@ -333,7 +333,7 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { const [server, user] = yield all([ UserPreferences.getString(CURRENT_SERVER), - UserPreferences.getString(`${TOKEN_KEY}-${host}`) + UserPreferences.getString(getServerUserIdKey(host)) ]); const serverRecord = yield getServerById(host); diff --git a/app/sagas/init.js b/app/sagas/init.js index 1ea6b7373a4..10cc5896166 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -2,7 +2,7 @@ import { call, put, select, takeLatest } from 'redux-saga/effects'; import RNBootSplash from 'react-native-bootsplash'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { selectServerRequest } from '../actions/server'; @@ -27,7 +27,7 @@ const restore = function* restore() { yield call(migrateTokenKeysToServerScoped); const server = UserPreferences.getString(CURRENT_SERVER); - let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + let userId = UserPreferences.getString(getServerUserIdKey(server)); if (!server) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); @@ -40,7 +40,7 @@ const restore = function* restore() { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + userId = UserPreferences.getString(getServerUserIdKey(newServer)); if (userId) { return yield put(selectServerRequest(newServer, newServer.version)); } diff --git a/app/sagas/login.js b/app/sagas/login.js index 4feac6b3447..162503d8dc3 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -22,7 +22,7 @@ import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry' import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; import { RootEnum } from '../definitions'; import sdk from '../lib/services/sdk'; -import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys'; import { getCustomEmojis } from '../lib/methods/getCustomEmojis'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules'; @@ -343,7 +343,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { } }); - UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id); + UserPreferences.setString(getServerUserIdKey(server), user.id); UserPreferences.setString(getUserTokenKey(server, user.id), user.token); UserPreferences.setString(CURRENT_SERVER, server); EventEmitter.emit('connected'); @@ -388,7 +388,7 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + const token = UserPreferences.getString(getServerUserIdKey(newServer)); if (token) { yield put(selectServerRequest(newServer, newServer.version)); return; @@ -455,7 +455,7 @@ const handleDeleteAccount = function* handleDeleteAccount() { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + const token = UserPreferences.getString(getServerUserIdKey(newServer)); if (token) { yield put(selectServerRequest(newServer, newServer.version)); return; diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 3d6c45acc5c..a126e27153e 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -29,7 +29,7 @@ import UserPreferences from '../lib/methods/userPreferences'; import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { type IServerInfo, RootEnum, type TServerModel } from '../definitions'; -import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; +import { CERTIFICATE_KEY, CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys'; import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions'; import { getLoginSettings, setSettings } from '../lib/methods/getSettings'; @@ -152,7 +152,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(encryptionStop()); yield put(clearActiveUsers()); yield* call(migrateTokenKeysToServerScoped); - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); let user = null; if (userId) { // search credentials on database diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 29616a6ad8c..57845f72262 100644 --- a/app/views/RoomsListView/components/ServersList.tsx +++ b/app/views/RoomsListView/components/ServersList.tsx @@ -11,7 +11,7 @@ import * as List from '../../../containers/List'; import ServerItem from '../../../containers/ServerItem'; import { RootEnum, type TServerModel } from '../../../definitions'; import I18n from '../../../i18n'; -import { TOKEN_KEY } from '../../../lib/constants/keys'; +import { getServerUserIdKey } from '../../../lib/constants/keys'; import database from '../../../lib/database'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { useMasterDetail } from '../../../lib/hooks/useMasterDetail'; @@ -77,7 +77,7 @@ const ServersList = () => { close(); if (server !== serverParam) { logEvent(events.RL_CHANGE_SERVER); - const userId = UserPreferences.getString(`${TOKEN_KEY}-${serverParam}`); + const userId = UserPreferences.getString(getServerUserIdKey(serverParam)); if (isMasterDetail) { goRoom({ item: {}, isMasterDetail }); } From 4f6976e41c380c4567c0ec0776d8ab9325714e4d Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 14:14:29 -0300 Subject: [PATCH 17/21] refactor: group token migration by userId with a single map --- .../methods/migrateTokenKeysToServerScoped.ts | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts index be93e030df8..9e059df5749 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -11,38 +11,39 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { const serversDB = database.servers; const servers = await serversDB.get('servers').query().fetch(); - const serverUserIds: { server: string; userId: string }[] = []; - const serverCountByUserId: Record = {}; + const serversByUserId = new Map(); for (let i = 0; i < servers.length; i += 1) { const server = servers[i].id; const userId = UserPreferences.getString(getServerUserIdKey(server)); if (!userId) { continue; } - serverUserIds.push({ server, userId }); - serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; + const sharing = serversByUserId.get(userId); + if (sharing) { + sharing.push(server); + } else { + serversByUserId.set(userId, [server]); + } } - const legacyKeys = new Set(); - for (let i = 0; i < serverUserIds.length; i += 1) { - const { server, userId } = serverUserIds[i]; + serversByUserId.forEach((sharing, userId) => { const legacyKey = getLegacyUserTokenKey(userId); // A userId claimed by more than one server is ambiguous: drop the legacy slot instead of // migrating it, so the session re-authenticates. - if (serverCountByUserId[userId] > 1) { - legacyKeys.add(legacyKey); - continue; + if (sharing.length > 1) { + UserPreferences.removeItem(legacyKey); + return; } - const newKey = getUserTokenKey(server, userId); - if (!UserPreferences.getString(newKey)) { - const token = UserPreferences.getString(legacyKey); - if (token) { - UserPreferences.setString(newKey, token); - legacyKeys.add(legacyKey); - } + const newKey = getUserTokenKey(sharing[0], userId); + if (UserPreferences.getString(newKey)) { + return; } - } - legacyKeys.forEach(key => UserPreferences.removeItem(key)); + const token = UserPreferences.getString(legacyKey); + if (token) { + UserPreferences.setString(newKey, token); + UserPreferences.removeItem(legacyKey); + } + }); UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); } catch (e) { log(e); From ee73b9c4e1b955b9dc42bc1287bc00ade6f5dce7 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 14:17:30 -0300 Subject: [PATCH 18/21] fix: refuse legacy userId-only token lookup on native after migration --- .../java/chat/rocket/reactnative/notification/Ejson.java | 6 ++++++ ios/Shared/RocketChat/MMKV.swift | 5 +++++ ios/Shared/RocketChat/MMKVBridge.h | 1 + ios/Shared/RocketChat/MMKVBridge.mm | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 545fdead59e..034b76a1392 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -24,6 +24,7 @@ static public String toHex(String arg) { public class Ejson { private static final String TAG = "RocketChat.Ejson"; private static final String TOKEN_KEY = "reactnativemeteor_usertoken-"; + private static final String TOKEN_KEY_SERVER_SCOPED_MIGRATED = "RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED"; public String host; String rid; @@ -181,6 +182,11 @@ public String token() { */ @Deprecated private String decodeLegacyUserIdScopedToken(MMKV mmkv, String userId) { + // The legacy slot is ambiguous across servers sharing a userId, so it is only readable + // before the migration runs. + if (mmkv.decodeBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, false)) { + return null; + } return mmkv.decodeString(TOKEN_KEY.concat(userId)); } diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index 1087ceceba2..8c0156fa406 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -24,6 +24,11 @@ extension MMKVBridge { if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") { return userToken } + // The legacy slot is ambiguous across servers sharing a userId, so it is only readable + // before migrateTokenKeysToServerScoped (JS) runs. + if bool(forKey: "RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED") { + return nil + } return string(forKey: "reactnativemeteor_usertoken-\(userId)") } diff --git a/ios/Shared/RocketChat/MMKVBridge.h b/ios/Shared/RocketChat/MMKVBridge.h index 1c4cf510cd4..a4426c77462 100644 --- a/ios/Shared/RocketChat/MMKVBridge.h +++ b/ios/Shared/RocketChat/MMKVBridge.h @@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable NSString *)stringForKey:(NSString *)key; - (BOOL)setString:(NSString *)value forKey:(NSString *)key; +- (BOOL)boolForKey:(NSString *)key; - (nullable NSData *)dataForKey:(NSString *)key; - (BOOL)setData:(NSData *)value forKey:(NSString *)key; - (void)removeValueForKey:(NSString *)key; diff --git a/ios/Shared/RocketChat/MMKVBridge.mm b/ios/Shared/RocketChat/MMKVBridge.mm index db48f48476f..59e28334c5f 100644 --- a/ios/Shared/RocketChat/MMKVBridge.mm +++ b/ios/Shared/RocketChat/MMKVBridge.mm @@ -48,6 +48,11 @@ - (BOOL)setString:(NSString *)value forKey:(NSString *)key { return [_mmkvInstance setString:value forKey:key]; } +- (BOOL)boolForKey:(NSString *)key { + if (!_mmkvInstance) return NO; + return [_mmkvInstance getBoolForKey:key defaultValue:NO]; +} + - (nullable NSData *)dataForKey:(NSString *)key { if (!_mmkvInstance) return nil; return [_mmkvInstance getDataForKey:key]; From d82775b19a6162518dcbd926e94db396bd61a5cf Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 14:38:17 -0300 Subject: [PATCH 19/21] fix(deeplink): declining login no longer adds or connects the server --- app/sagas/__tests__/deepLinking.test.ts | 52 ++++++------------------- app/sagas/deepLinking.js | 29 +++++++++----- 2 files changed, 31 insertions(+), 50 deletions(-) diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 291c2cb4efd..0f757f3c4d7 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -104,7 +104,7 @@ import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; import { connectSuccess } from '../../actions/connect'; -import { APP, LOGIN } from '../../actions/actionsTypes'; +import { APP, LOGIN, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; @@ -287,41 +287,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); - // Ordering race: the confirm prompt stays open (real native Alert) long enough for the - // NewServer connection — kicked off *before* the prompt — to complete. SERVER.SELECT_SUCCESS - // then fires while the user is still deciding, so the take must be guarded or the saga hangs - // on WorkspaceView without ever logging in (the iOS deep-link symptom). - it('completes the chain when SERVER.SELECT_SUCCESS fires while the confirm prompt is open', async () => { - // Capture onPress instead of auto-confirming, mimicking a prompt the user hasn't tapped yet. - let confirm: (() => void) | undefined; - jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onPress }: any) => { - confirm = onPress; - }); - - const { store, actions } = setupRecordingStore(); - const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); - - store.dispatch(deepLinkingOpen(makeParamsWithToken())); - await flushSagaMicrotasks(); - await jest.advanceTimersByTimeAsync(1000); - await flushSagaMicrotasks(); - - // Prompt is open, not yet confirmed. The connection completes now: both - // SERVER.SELECT_SUCCESS and the socket connect fire before the saga reaches its takes. - store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); - store.dispatch(connectSuccess()); - await flushSagaMicrotasks(); - - // Still parked in the prompt — no premature login. - expect(loginRequested()).toBe(false); - - // User confirms. The guard sees the server is already selected + connected and skips the - // stale takes, so loginRequest fires instead of the saga hanging. - confirm?.(); - await flushSagaMicrotasks(); - expect(loginRequested()).toBe(true); - }); - // loginRequest must not fire until the socket is connected (locks the gate). it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { const { store, actions } = setupRecordingStore(); @@ -345,22 +310,27 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(loginRequested()).toBe(true); }); - it('does not dispatch loginRequest when the deep-link login confirmation is declined', async () => { + it('does not touch the deep link server when the login confirmation is declined', async () => { jest.mocked(showConfirmationAlert).mockClear(); jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + const emitSpy = jest.spyOn(EventEmitter, 'emit'); const { store, actions } = setupRecordingStore(); - const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); store.dispatch(deepLinkingOpen(makeParamsWithToken())); await flushSagaMicrotasks(); await jest.advanceTimersByTimeAsync(1000); await flushSagaMicrotasks(); - // User declined → confirmation shown, no login, parked outside. + // Prompt shown, and declining leaves the deep link's server entirely untouched: no + // connection attempt, no server added, no navigation away from where the user was. expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); - expect(loginRequested()).toBe(false); - expect(actions.some(a => a.type === APP.START && (a as any).root === RootEnum.ROOT_OUTSIDE)).toBe(true); + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(actions.some(a => a.type === APP.START)).toBe(false); + emitSpy.mockRestore(); }); // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 56cd40de378..c0981f419de 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -237,6 +237,15 @@ const handleOpen = function* handleOpen({ params }) { } catch (e) { // do nothing? } + // Consent before touching anything on the deep link's server: a resume token means this + // link can sign the user in, so ask while declining is still a no-op. + if (params.token) { + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield fallbackNavigation(); + return; + } + } // if deep link is from a different server const result = yield getServerInfo(host); if (!result.success) { @@ -258,16 +267,9 @@ const handleOpen = function* handleOpen({ params }) { } if (params.token) { - const confirmed = yield call(confirmDeepLinkLogin, host, params); - if (!confirmed) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - return; - } if (!hostAlreadyConnected) { - const serverSelected = yield select(state => state.server.server === host && state.server.connected); - if (!serverSelected) { - yield take(types.SERVER.SELECT_SUCCESS); - } + yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS can land after the socket is already connected. const connected = yield select(state => state.meteor.connected); if (!connected) { yield take(types.METEOR.SUCCESS); @@ -354,6 +356,15 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { yield handleNavigateCallRoom({ params }); return; } + // Consent before touching anything on the deep link's server: a resume token means this + // link can sign the user in, so ask while declining is still a no-op. + if (params.token) { + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield fallbackNavigation(); + return; + } + } // if deep link is from a different server const result = yield getServerInfo(host); if (!result.success) { From 88e6747162b93eace6d9caf58122d14f7e57afda Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 15:26:19 -0300 Subject: [PATCH 20/21] fix(security): clear legacy token slots and surface declined deep-link logins --- .maestro/tests/assorted/deeplink.yaml | 4 +- app/i18n/locales/en.json | 1 + app/lib/methods/logout.ts | 3 ++ .../migrateTokenKeysToServerScoped.test.ts | 29 +++++++++++++- .../methods/migrateTokenKeysToServerScoped.ts | 24 +++++++++--- app/sagas/__tests__/deepLinking.test.ts | 38 +++++++++++++++++-- app/sagas/deepLinking.js | 11 ++++-- 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index d8c805b730c..83c14bad2dd 100644 --- a/.maestro/tests/assorted/deeplink.yaml +++ b/.maestro/tests/assorted/deeplink.yaml @@ -318,7 +318,7 @@ tags: - runFlow: file: '../../helpers/open-deeplink.yaml' env: - link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&e2eConfirmPrompt=true')} + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')} - extendedWaitUntil: visible: text: '.*Sign in to this server.*' @@ -352,7 +352,7 @@ tags: - runFlow: file: '../../helpers/open-deeplink.yaml' env: - link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&e2eConfirmPrompt=true')} + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')} - extendedWaitUntil: visible: text: '.*Sign in to this server.*' diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 5692ba8792b..3a4cadd8e59 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -230,6 +230,7 @@ "Dark": "Dark", "Dark_level": "Dark level", "decline": "Decline", + "Deep_link_login_declined": "Sign-in link dismissed.", "Deep_link_login_description": "A link is asking to sign you in to {{server}}. Only continue if you opened this link yourself and trust it.", "Deep_link_login_title": "Sign in to this server?", "Default": "Default", diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index 651e09621f5..cf23db8ef0e 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -13,6 +13,7 @@ import { E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, + getLegacyUserTokenKey, getServerUserIdKey, getUserTokenKey } from '../constants/keys'; @@ -25,6 +26,8 @@ function removeServerKeys({ server, userId }: { server: string; userId?: string UserPreferences.removeItem(getServerUserIdKey(server)); if (userId) { UserPreferences.removeItem(getUserTokenKey(server, userId)); + // A logout before the migration ran leaves a token the native fallbacks would still read. + UserPreferences.removeItem(getLegacyUserTokenKey(userId)); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); UserPreferences.removeItem(`${server}-${E2E_PUBLIC_KEY}`); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts index 2ff8f49a778..4ae2d5004a2 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts @@ -2,7 +2,7 @@ import { migrateTokenKeysToServerScoped } from './migrateTokenKeysToServerScoped import UserPreferences from './userPreferences'; import database from '../database'; import log from './helpers/log'; -import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys'; +import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getServerUserIdKey, getUserTokenKey } from '../constants/keys'; jest.mock('../database', () => ({ __esModule: true, @@ -85,9 +85,36 @@ describe('migrateTokenKeysToServerScoped', () => { await migrateTokenKeysToServerScoped(); expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('existing-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('drops a legacy slot whose server has no record left in the database', async () => { + const orphanUserId = 'orphan'; + UserPreferences.setString(`${TOKEN_KEY}-${orphanUserId}`, 'orphan-token'); + setServers([]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${orphanUserId}`)).toBeNull(); expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); }); + it('leaves server-scoped keys untouched while dropping orphaned legacy slots', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token'); + UserPreferences.setString(`${TOKEN_KEY}-orphan`, 'orphan-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-orphan`)).toBeNull(); + }); + it('skips servers that have no stored userId', async () => { const server = 'https://open.rocket.chat'; setServers([server]); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts index 9e059df5749..05499995533 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -1,8 +1,16 @@ -import { TOKEN_KEY_SERVER_SCOPED_MIGRATED, getLegacyUserTokenKey, getServerUserIdKey, getUserTokenKey } from '../constants/keys'; +import { + TOKEN_KEY, + TOKEN_KEY_SERVER_SCOPED_MIGRATED, + getLegacyUserTokenKey, + getServerUserIdKey, + getUserTokenKey +} from '../constants/keys'; import UserPreferences from './userPreferences'; import database from '../database'; import log from './helpers/log'; +const isLegacyUserTokenKey = (key: string): boolean => key.startsWith(`${TOKEN_KEY}-`) && !key.includes('://'); + export const migrateTokenKeysToServerScoped = async (): Promise => { try { if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { @@ -35,15 +43,19 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { return; } const newKey = getUserTokenKey(sharing[0], userId); - if (UserPreferences.getString(newKey)) { - return; - } const token = UserPreferences.getString(legacyKey); - if (token) { + if (token && !UserPreferences.getString(newKey)) { UserPreferences.setString(newKey, token); - UserPreferences.removeItem(legacyKey); } + UserPreferences.removeItem(legacyKey); }); + + // Legacy slots whose server has no row left in the database are unreachable above, and the + // migrated flag stops the native fallbacks from reading them. Drop them instead of stranding them. + UserPreferences.getAllKeys() + .filter(isLegacyUserTokenKey) + .forEach(key => UserPreferences.removeItem(key)); + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); } catch (e) { log(e); diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 0f757f3c4d7..cbe133350c0 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -130,6 +130,10 @@ async function flushSagaMicrotasks(): Promise { type PreloadedState = Parameters[1]; +/** Messages pushed through showToast, which emits on the Toast LISTENER channel. */ +const toastedMessages = (emitSpy: jest.SpyInstance): string[] => + emitSpy.mock.calls.map(([, payload]: any[]) => payload?.message).filter(Boolean); + function setupStore(preloadedState?: PreloadedState) { const sagaMiddleware = createSagaMiddleware(); const store = createStore(reducers, preloadedState, applyMiddleware(sagaMiddleware)); @@ -330,11 +334,37 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); expect(actions.some(a => a.type === APP.START)).toBe(false); + // Cold start: normal init takes over instead of the deep link's server. + expect(actions.some(a => a.type === APP.INIT)).toBe(true); + expect(toastedMessages(emitSpy)).toContain('Deep_link_login_declined'); + emitSpy.mockRestore(); + }); + + it('leaves a running app where it was when the login confirmation is declined', async () => { + jest.mocked(showConfirmationAlert).mockClear(); + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + + const { store, actions } = setupRecordingStore({ app: { root: RootEnum.ROOT_INSIDE } } as PreloadedState); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(actions.some(a => a.type === APP.START)).toBe(false); + expect(actions.some(a => a.type === APP.INIT)).toBe(false); + expect(toastedMessages(emitSpy)).toContain('Deep_link_login_declined'); emitSpy.mockRestore(); }); // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a - // native Alert — except when the deep link carries `e2eConfirmPrompt=true`, which opts a + // native Alert — except when the deep link carries `forceLoginPrompt=true`, which opts a // dedicated e2e flow back into the real prompt (see the deeplink.yaml Maestro test). describe('RUNNING_E2E_TESTS auto-confirm gate', () => { const original = process.env.RUNNING_E2E_TESTS; @@ -346,7 +376,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' process.env.RUNNING_E2E_TESTS = original; }); - it('auto-confirms without showing the prompt when no e2eConfirmPrompt marker is present', async () => { + it('auto-confirms without showing the prompt when no forceLoginPrompt marker is present', async () => { const { store, actions } = setupRecordingStore(); const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); @@ -364,10 +394,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(loginRequested()).toBe(true); }); - it('shows the real prompt when the deep link carries e2eConfirmPrompt=true', async () => { + it('shows the real prompt when the deep link carries forceLoginPrompt=true', async () => { const store = setupStore(); - store.dispatch(deepLinkingOpen(makeParamsWithToken({ e2eConfirmPrompt: 'true' }))); + store.dispatch(deepLinkingOpen(makeParamsWithToken({ forceLoginPrompt: 'true' }))); await flushSagaMicrotasks(); await jest.advanceTimersByTimeAsync(1000); await flushSagaMicrotasks(); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index c0981f419de..064295d4465 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -40,7 +40,7 @@ const roomTypes = { const confirmDeepLinkLogin = (host, params = {}) => new Promise(resolve => { - if (process.env.RUNNING_E2E_TESTS === 'true' && params.e2eConfirmPrompt !== 'true') { + if (process.env.RUNNING_E2E_TESTS === 'true' && params.forceLoginPrompt !== 'true') { resolve(true); return; } @@ -144,6 +144,11 @@ const fallbackNavigation = function* fallbackNavigation() { yield put(appInit()); }; +const declineDeepLinkLogin = function* declineDeepLinkLogin() { + showToast(I18n.t('Deep_link_login_declined')); + yield fallbackNavigation(); +}; + let consumedOAuthToken; const handleOAuth = function* handleOAuth({ params }) { @@ -242,7 +247,7 @@ const handleOpen = function* handleOpen({ params }) { if (params.token) { const confirmed = yield call(confirmDeepLinkLogin, host, params); if (!confirmed) { - yield fallbackNavigation(); + yield declineDeepLinkLogin(); return; } } @@ -361,7 +366,7 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { if (params.token) { const confirmed = yield call(confirmDeepLinkLogin, host, params); if (!confirmed) { - yield fallbackNavigation(); + yield declineDeepLinkLogin(); return; } } From 33003ef72a0f97cd703cfb71a117324dc8b8a131 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 20 Aug 2026 15:40:10 -0300 Subject: [PATCH 21/21] fix(security): anchor the legacy token sweep on the userId shape --- .../migrateTokenKeysToServerScoped.test.ts | 28 +++++++++++++++++++ .../methods/migrateTokenKeysToServerScoped.ts | 12 ++++++-- app/sagas/__tests__/deepLinking.test.ts | 5 ++-- app/sagas/deepLinking.js | 6 +++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts index 4ae2d5004a2..3d92257b771 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts @@ -115,6 +115,34 @@ describe('migrateTokenKeysToServerScoped', () => { expect(UserPreferences.getString(`${TOKEN_KEY}-orphan`)).toBeNull(); }); + it('preserves keys for a schemeless subpath server', async () => { + // completeUrl leaves `example.com/rocketchat` as typed, so these keys carry no scheme. + const server = 'example.com/rocketchat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('legacy-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + }); + + it('does not sweep a schemeless server whose row is gone', async () => { + const server = 'example.com/rocketchat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token'); + setServers([]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token'); + }); + it('skips servers that have no stored userId', async () => { const server = 'https://open.rocket.chat'; setServers([server]); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts index 05499995533..f547d6838f6 100644 --- a/app/lib/methods/migrateTokenKeysToServerScoped.ts +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -9,7 +9,8 @@ import UserPreferences from './userPreferences'; import database from '../database'; import log from './helpers/log'; -const isLegacyUserTokenKey = (key: string): boolean => key.startsWith(`${TOKEN_KEY}-`) && !key.includes('://'); +// A bare alphanumeric suffix is a userId: server ids always carry a dot, a scheme or a path separator. +const isLegacyUserTokenKey = (key: string): boolean => /^[A-Za-z0-9]+$/.test(key.replace(`${TOKEN_KEY}-`, '')); export const migrateTokenKeysToServerScoped = async (): Promise => { try { @@ -52,8 +53,15 @@ export const migrateTokenKeysToServerScoped = async (): Promise => { // Legacy slots whose server has no row left in the database are unreachable above, and the // migrated flag stops the native fallbacks from reading them. Drop them instead of stranding them. + const liveKeys = new Set(); + serversByUserId.forEach((sharing, userId) => { + sharing.forEach(server => { + liveKeys.add(getServerUserIdKey(server)); + liveKeys.add(getUserTokenKey(server, userId)); + }); + }); UserPreferences.getAllKeys() - .filter(isLegacyUserTokenKey) + .filter(key => key.startsWith(`${TOKEN_KEY}-`) && !liveKeys.has(key) && isLegacyUserTokenKey(key)) .forEach(key => UserPreferences.removeItem(key)); UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index cbe133350c0..215b2526233 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -334,9 +334,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); expect(actions.some(a => a.type === APP.START)).toBe(false); - // Cold start: normal init takes over instead of the deep link's server. + // Cold start: normal init takes over instead of the deep link's server, and there is no + // mounted Toast to show a message on. expect(actions.some(a => a.type === APP.INIT)).toBe(true); - expect(toastedMessages(emitSpy)).toContain('Deep_link_login_declined'); + expect(toastedMessages(emitSpy)).not.toContain('Deep_link_login_declined'); emitSpy.mockRestore(); }); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 064295d4465..fcf08abafc7 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -145,7 +145,11 @@ const fallbackNavigation = function* fallbackNavigation() { }; const declineDeepLinkLogin = function* declineDeepLinkLogin() { - showToast(I18n.t('Deep_link_login_declined')); + // Only worth a toast while the app is up; on cold start there is no Toast mounted to show it. + const currentRoot = yield select(state => state.app.root); + if (currentRoot) { + showToast(I18n.t('Deep_link_login_declined')); + } yield fallbackNavigation(); };