diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml
index 4c5780f8b29..83c14bad2dd 100644
--- a/.maestro/tests/assorted/deeplink.yaml
+++ b/.maestro/tests/assorted/deeplink.yaml
@@ -310,3 +310,80 @@ 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, '&forceLoginPrompt=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, '&forceLoginPrompt=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'
+ rightOf:
+ text: 'Cancel'
+- 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/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 18337835f5e..0e9fdce9400 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -105,11 +105,11 @@
+ android:exported="false" />
+ android:exported="false" >
`${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';
export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY';
diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts
index d27819472d4..cf23db8ef0e 100644
--- a/app/lib/methods/logout.ts
+++ b/app/lib/methods/logout.ts
@@ -8,16 +8,26 @@ 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,
+ getLegacyUserTokenKey,
+ getServerUserIdKey,
+ getUserTokenKey
+} from '../constants/keys';
import UserPreferences from './userPreferences';
import { removePushToken } from '../services/restApi';
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(`${TOKEN_KEY}-${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}`);
@@ -29,7 +39,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) {
@@ -62,9 +72,9 @@ 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(`${TOKEN_KEY}-${userId}`);
+ const resume = UserPreferences.getString(getUserTokenKey(server, userId));
try {
const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: isSsl(server) });
diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts
new file mode 100644
index 00000000000..3d92257b771
--- /dev/null
+++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts
@@ -0,0 +1,167 @@
+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, getServerUserIdKey, 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.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('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]);
+
+ 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..f547d6838f6
--- /dev/null
+++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts
@@ -0,0 +1,71 @@
+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';
+
+// 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 {
+ if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) {
+ return;
+ }
+ const serversDB = database.servers;
+ const servers = await serversDB.get('servers').query().fetch();
+
+ 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;
+ }
+ const sharing = serversByUserId.get(userId);
+ if (sharing) {
+ sharing.push(server);
+ } else {
+ serversByUserId.set(userId, [server]);
+ }
+ }
+
+ 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 (sharing.length > 1) {
+ UserPreferences.removeItem(legacyKey);
+ return;
+ }
+ const newKey = getUserTokenKey(sharing[0], userId);
+ const token = UserPreferences.getString(legacyKey);
+ if (token && !UserPreferences.getString(newKey)) {
+ UserPreferences.setString(newKey, token);
+ }
+ 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.
+ const liveKeys = new Set();
+ serversByUserId.forEach((sharing, userId) => {
+ sharing.forEach(server => {
+ liveKeys.add(getServerUserIdKey(server));
+ liveKeys.add(getUserTokenKey(server, userId));
+ });
+ });
+ UserPreferences.getAllKeys()
+ .filter(key => key.startsWith(`${TOKEN_KEY}-`) && !liveKeys.has(key) && isLegacyUserTokenKey(key))
+ .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 e6ceccde958..215b2526233 100644
--- a/app/sagas/__tests__/deepLinking.test.ts
+++ b/app/sagas/__tests__/deepLinking.test.ts
@@ -82,6 +82,10 @@ jest.mock('i18n-js', () => ({
default: { t: (k: string) => k }
}));
+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),
@@ -99,10 +103,13 @@ 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, SERVER } 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';
@@ -123,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));
@@ -130,6 +141,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';
@@ -217,6 +241,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();
@@ -236,6 +264,149 @@ describe('deepLinking saga — Regression race (new server + token + room path)'
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
});
+ // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must
+ // skip the already-fired METEOR.SUCCESS take instead of hanging.
+ it('completes the chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => {
+ const store = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched.
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
+ await flushSagaMicrotasks();
+
+ store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
+ await flushSagaMicrotasks();
+
+ store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
+ await flushSagaMicrotasks();
+ await flushSagaMicrotasks();
+
+ expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
+ });
+
+ // 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();
+ 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();
+
+ // Server selected but socket not connected yet → still parked at the gate.
+ expect(loginRequested()).toBe(false);
+
+ store.dispatch(connectSuccess());
+ await flushSagaMicrotasks();
+
+ // Socket connected → gate released, loginRequest dispatched.
+ expect(loginRequested()).toBe(true);
+ });
+
+ 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();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken()));
+ await flushSagaMicrotasks();
+ await jest.advanceTimersByTimeAsync(1000);
+ await flushSagaMicrotasks();
+
+ // 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(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);
+ // 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)).not.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 `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;
+ 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 forceLoginPrompt 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 forceLoginPrompt=true', async () => {
+ const store = setupStore();
+
+ store.dispatch(deepLinkingOpen(makeParamsWithToken({ forceLoginPrompt: '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.
@@ -253,6 +424,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();
@@ -285,6 +459,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.
@@ -314,6 +491,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();
@@ -349,6 +529,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();
diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js
index fac9e3292db..fcf08abafc7 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';
@@ -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,21 @@ const roomTypes = {
channels: 'l'
};
+const confirmDeepLinkLogin = (host, params = {}) =>
+ new Promise(resolve => {
+ if (process.env.RUNNING_E2E_TESTS === 'true' && params.forceLoginPrompt !== 'true') {
+ resolve(true);
+ return;
+ }
+ 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/', '');
@@ -128,6 +144,15 @@ const fallbackNavigation = function* fallbackNavigation() {
yield put(appInit());
};
+const declineDeepLinkLogin = function* declineDeepLinkLogin() {
+ // 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();
+};
+
let consumedOAuthToken;
const handleOAuth = function* handleOAuth({ params }) {
@@ -145,7 +170,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());
@@ -193,7 +218,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);
@@ -221,6 +246,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 declineDeepLinkLogin();
+ return;
+ }
+ }
// if deep link is from a different server
const result = yield getServerInfo(host);
if (!result.success) {
@@ -244,6 +278,11 @@ const handleOpen = function* handleOpen({ params }) {
if (params.token) {
if (!hostAlreadyConnected) {
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);
+ }
}
yield put(loginRequest({ resume: params.token }, true));
yield take(types.LOGIN.SUCCESS);
@@ -305,7 +344,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);
@@ -326,6 +365,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 declineDeepLinkLogin();
+ return;
+ }
+ }
// if deep link is from a different server
const result = yield getServerInfo(host);
if (!result.success) {
diff --git a/app/sagas/init.js b/app/sagas/init.js
index d9d6024abe8..10cc5896166 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 } 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';
import { setAllPreferences } from '../actions/sortPreferences';
import { APP } from '../actions/actionsTypes';
@@ -23,8 +24,10 @@ export const initLocalSettings = function* initLocalSettings() {
const restore = function* restore() {
try {
+ 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 }));
@@ -37,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 6d5f1f8e7d9..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 } 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,8 +343,8 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
}
});
- UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id);
- UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token);
+ UserPreferences.setString(getServerUserIdKey(server), user.id);
+ UserPreferences.setString(getUserTokenKey(server, user.id), user.token);
UserPreferences.setString(CURRENT_SERVER, server);
EventEmitter.emit('connected');
const currentRoot = yield select(state => state.app.root);
@@ -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 5373f6b0fcf..a126e27153e 100644
--- a/app/sagas/selectServer.ts
+++ b/app/sagas/selectServer.ts
@@ -29,7 +29,8 @@ 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, 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';
import { getServerInfo } from '../lib/methods/getServerInfo';
@@ -150,7 +151,8 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
yield put(inquiryReset());
yield put(encryptionStop());
yield put(clearActiveUsers());
- const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
+ yield* call(migrateTokenKeysToServerScoped);
+ const userId = UserPreferences.getString(getServerUserIdKey(server));
let user = null;
if (userId) {
// search credentials on database
@@ -171,7 +173,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/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 });
}
diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift
index f825b4d2cfc..8c0156fa406 100644
--- a/ios/Shared/RocketChat/MMKV.swift
+++ b/ios/Shared/RocketChat/MMKV.swift
@@ -18,11 +18,18 @@ 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 {
+ // 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
+ }
+ // 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 userToken
+ return string(forKey: "reactnativemeteor_usertoken-\(userId)")
}
func userId(for server: String) -> String? {
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];
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
}