diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 4c5780f8b29..f15c9816d39 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 workspace.*' + 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 workspace.*' + 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/hooks/useShortnameToUnicode/index.tsx b/app/lib/hooks/useShortnameToUnicode/index.tsx index d6f6d78a75c..890c8a71f1b 100644 --- a/app/lib/hooks/useShortnameToUnicode/index.tsx +++ b/app/lib/hooks/useShortnameToUnicode/index.tsx @@ -35,7 +35,7 @@ const useShortnameToUnicode = (isEmojiPicker?: boolean) => { const replaceShortnameWithUnicode = (shortname: string) => { const name = shortname.replace(/:/g, ''); - + // a custom emoji sharing a built-in shortcode/alias must win if (customEmojis(name)) { return shortname; diff --git a/app/lib/methods/loggedInServer.ts b/app/lib/methods/loggedInServer.ts index 6b4ef2ddb84..ba3f068062d 100644 --- a/app/lib/methods/loggedInServer.ts +++ b/app/lib/methods/loggedInServer.ts @@ -1,10 +1,10 @@ import { type TServerModel } from '../../definitions'; -import { TOKEN_KEY } from '../constants/keys'; +import { getServerUserIdKey } from '../constants/keys'; import { getAllServers } from '../database/services/Server'; import UserPreferences from './userPreferences'; export const isLoggedInServer = (serverId?: string | null): boolean => - !!serverId && !!UserPreferences.getString(`${TOKEN_KEY}-${serverId}`); + !!serverId && !!UserPreferences.getString(getServerUserIdKey(serverId)); export const findLoggedInServer = async (): Promise => (await getAllServers()).find(({ id }) => isLoggedInServer(id)); diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index f650be4b3d7..705ecf24516 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) { @@ -78,9 +88,9 @@ async function logoutFromServer(server: string, resume: string): Promise { 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)); if (resume) { await logoutFromServer(server, resume); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts new file mode 100644 index 00000000000..52016697a09 --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts @@ -0,0 +1,189 @@ +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('confines a shared userId to its own servers while the rest of the run migrates', async () => { + const serverA = 'https://a.rocket.chat'; + const serverB = 'https://b.rocket.chat'; + const serverC = 'https://c.rocket.chat'; + const sharedUserId = 'shared'; + const soleUserId = 'sole'; + UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, sharedUserId); + UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, sharedUserId); + UserPreferences.setString(`${TOKEN_KEY}-${serverC}`, soleUserId); + UserPreferences.setString(`${TOKEN_KEY}-${sharedUserId}`, 'ambiguous-token'); + UserPreferences.setString(`${TOKEN_KEY}-${soleUserId}`, 'sole-token'); + setServers([serverA, serverB, serverC]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(serverA, sharedUserId))).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverB, sharedUserId))).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverC, soleUserId))).toBe('sole-token'); + expect(UserPreferences.getString(getServerUserIdKey(serverA))).toBe(sharedUserId); + expect(UserPreferences.getString(getServerUserIdKey(serverB))).toBe(sharedUserId); + }); + + 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/lib/testUtils/sagaStore.ts b/app/lib/testUtils/sagaStore.ts index 50b5bc11bf8..ddbf7f7b370 100644 --- a/app/lib/testUtils/sagaStore.ts +++ b/app/lib/testUtils/sagaStore.ts @@ -19,16 +19,19 @@ export function cancelSagaTasks(): void { runningTasks.splice(0).forEach(task => task.cancel()); } +export type PreloadedState = Parameters[1]; + export interface RecordingStore { store: Store; dispatchedActions: AnyAction[]; } -export function createRecordingStore(rootSaga: Saga): RecordingStore { +export function createRecordingStore(rootSaga: Saga, preloadedState?: PreloadedState): RecordingStore { const dispatchedActions: AnyAction[] = []; const sagaMiddleware = createSagaMiddleware(); const store = createStore( reducers, + preloadedState, applyMiddleware( () => next => action => { dispatchedActions.push(action); diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index c7639a5349b..2fb3c5a8dd1 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -78,6 +78,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), @@ -92,10 +96,12 @@ import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLin import { loginFailure, loginSuccess } from '../../actions/login'; import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; -import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes'; +import { connectSuccess } from '../../actions/connect'; +import { APP, LOGIN, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; 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 { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; @@ -107,9 +113,13 @@ import sdk from '../../lib/services/sdk'; import database from '../../lib/database'; import EventEmitter from '../../lib/methods/helpers/events'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; -import type { RecordingStore } from '../../lib/testUtils/sagaStore'; +import type { PreloadedState, RecordingStore } from '../../lib/testUtils/sagaStore'; + +const setupStore = (preloadedState?: PreloadedState): RecordingStore => createRecordingStore(deepLinkingRoot, preloadedState); -const setupStore = (): RecordingStore => createRecordingStore(deepLinkingRoot); +/** 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); afterEach(cancelSagaTasks); @@ -200,6 +210,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(); @@ -214,6 +228,148 @@ 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(); + + 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, dispatchedActions } = setupStore(); + const loginRequested = () => dispatchedActions.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, dispatchedActions } = setupStore(); + + 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(dispatchedActions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(dispatchedActions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(dispatchedActions.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(dispatchedActions.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, dispatchedActions } = setupStore({ 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(dispatchedActions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(dispatchedActions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(dispatchedActions.some(a => a.type === APP.START)).toBe(false); + expect(dispatchedActions.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, dispatchedActions } = setupStore(); + const loginRequested = () => dispatchedActions.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. @@ -231,6 +387,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(); @@ -262,6 +421,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. @@ -290,6 +452,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(); @@ -324,6 +489,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/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts index 80ce61ab800..670c8ad4262 100644 --- a/app/sagas/__tests__/init.test.ts +++ b/app/sagas/__tests__/init.test.ts @@ -10,6 +10,10 @@ jest.mock('../../lib/database/services/Server', () => ({ getAllServers: jest.fn() })); +jest.mock('../../lib/methods/migrateTokenKeysToServerScoped', () => ({ + migrateTokenKeysToServerScoped: jest.fn(() => Promise.resolve()) +})); + jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ localAuthenticate: jest.fn() })); diff --git a/app/sagas/__tests__/login.switchCancel.test.ts b/app/sagas/__tests__/login.switchCancel.test.ts index 85c94e4301f..10bb40e17f2 100644 --- a/app/sagas/__tests__/login.switchCancel.test.ts +++ b/app/sagas/__tests__/login.switchCancel.test.ts @@ -99,7 +99,7 @@ import loginRoot from '../login'; import { loginSuccess } from '../../actions/login'; import { selectServerRequest, selectServerSuccess } from '../../actions/server'; import UserPreferences from '../../lib/methods/userPreferences'; -import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../../lib/constants/keys'; import { getPermissions } from '../../lib/methods/getPermissions'; import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; import type { RecordingStore } from '../../lib/testUtils/sagaStore'; @@ -115,7 +115,7 @@ const USER_B = { id: 'user-b', token: 'token-b', username: 'userb', name: 'User describe('login saga — a workspace switch cancels the login bootstrap', () => { beforeEach(() => { UserPreferences.removeItem(`${TOKEN_KEY}-${SERVER_A}`); - UserPreferences.removeItem(`${TOKEN_KEY}-${USER_B.id}`); + UserPreferences.removeItem(getUserTokenKey(SERVER_A, USER_B.id)); UserPreferences.removeItem(CURRENT_SERVER); jest.clearAllMocks(); }); @@ -144,7 +144,7 @@ describe('login saga — a workspace switch cancels the login bootstrap', () => await flushSagaMicrotasks(); expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBeNull(); - expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(SERVER_A, USER_B.id))).toBeNull(); expect(UserPreferences.getString(CURRENT_SERVER)).toBeNull(); }); @@ -158,7 +158,7 @@ describe('login saga — a workspace switch cancels the login bootstrap', () => await flushSagaMicrotasks(); expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBe(USER_B.id); - expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBe(USER_B.token); + expect(UserPreferences.getString(getUserTokenKey(SERVER_A, USER_B.id))).toBe(USER_B.token); expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_A); }); }); diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 7302338e5a0..0aa412be27b 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -70,7 +70,7 @@ import { RootEnum } from '../../definitions'; import { SERVER } from '../../actions/actionsTypes'; import UserPreferences from '../../lib/methods/userPreferences'; import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch'; -import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../../lib/constants/keys'; import { getLoggedUserById } from '../../lib/database/services/LoggedUser'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { connect } from '../../lib/services/connect'; @@ -83,7 +83,12 @@ const SERVER_URL = 'https://new.rocket.chat'; const USER_ID = 'user-new'; const TOKEN = 'token-new'; -const keysToClear = [`${TOKEN_KEY}-${SERVER_URL}`, `${TOKEN_KEY}-${USER_ID}`, `${BASIC_AUTH_KEY}-${SERVER_URL}`, CURRENT_SERVER]; +const keysToClear = [ + `${TOKEN_KEY}-${SERVER_URL}`, + getUserTokenKey(SERVER_URL, USER_ID), + `${BASIC_AUTH_KEY}-${SERVER_URL}`, + CURRENT_SERVER +]; const setupStore = (): RecordingStore => createRecordingStore(selectServerRoot); @@ -110,9 +115,9 @@ describe('selectServer saga — resolving the target workspace user', () => { expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_URL); }); - it('falls back to the token stored under the userId key when there is no record', async () => { + it('falls back to the token stored under the server-scoped key when there is no record', async () => { UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); - UserPreferences.setString(`${TOKEN_KEY}-${USER_ID}`, TOKEN); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockResolvedValue(null as any); const { store } = setupStore(); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 636dc297ab2..adb50455d91 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,29 @@ 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(); +}; + +// Consent before touching anything on the deep link's server: a resume token means this link can +// sign the user in, so every entry point asks through here while declining is still a no-op. +const ensureDeepLinkLoginConsent = function* ensureDeepLinkLoginConsent(host, params) { + if (!params.token) { + return true; + } + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield declineDeepLinkLogin(); + return false; + } + return true; +}; + let consumedOAuthToken; const handleOAuth = function* handleOAuth({ params }) { @@ -160,7 +199,7 @@ const handleSaml = function* handleSaml({ 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()); @@ -227,7 +266,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); @@ -255,6 +294,9 @@ const handleOpen = function* handleOpen({ params }) { } catch (e) { // do nothing? } + if (!(yield ensureDeepLinkLoginConsent(host, params))) { + return; + } // if deep link is from a different server const result = yield getServerInfo(host); if (!result.success) { @@ -278,6 +320,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); @@ -339,7 +386,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); @@ -360,6 +407,9 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { yield handleNavigateCallRoom({ params }); return; } + if (!(yield ensureDeepLinkLoginConsent(host, params))) { + 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 5c7b0487c1b..323dd8ff781 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -4,6 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { CURRENT_SERVER } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { findLoggedInServer, isLoggedInServer } from '../lib/methods/loggedInServer'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; @@ -36,6 +37,7 @@ const restoreServer = async () => { const getServerToRestore = function* getServerToRestore() { try { + yield call(migrateTokenKeysToServerScoped); return (yield call(restoreServer)) || null; } catch (e) { log(e); diff --git a/app/sagas/login.js b/app/sagas/login.js index 3a24534fb89..24dcd69c562 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -23,7 +23,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'; @@ -355,8 +355,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); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 24a8498f366..dfed8d105e8 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 }