diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 4c5780f8b29..50cd775b78e 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, '&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' +# declining leaves the deep link's server untouched, so the cold start falls back to normal init +- extendedWaitUntil: + visible: + id: 'new-server-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 alert's positive (confirm) button +- 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/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/loggedInServer.ts b/app/lib/methods/loggedInServer.ts index fa6f09f3d97..335ca7cc985 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..990a8d76506 --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -0,0 +1,70 @@ +import { + TOKEN_KEY, + TOKEN_KEY_SERVER_SCOPED_MIGRATED, + getLegacyUserTokenKey, + getServerUserIdKey, + getUserTokenKey +} from '../constants/keys'; +import UserPreferences from './userPreferences'; +import { getAllServers } from '../database/services/Server'; +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 servers = await getAllServers(); + + 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 9a1acef2568..44e092261de 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 6c39bff6574..dfbcd55c3b7 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/deepLinking 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 21f62bc0d83..0a7ccbd4a79 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 e9cc5ff0849..04e7a2e51c0 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 d90abe3e190..9eef659580f 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); @@ -99,6 +104,7 @@ beforeEach(() => { describe('selectServer saga — resolving the target workspace user', () => { it('sets the full user from the logged-user record and stamps CURRENT_SERVER', async () => { UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN, username: 'new' } as any); const { store, dispatchedActions } = setupStore(); @@ -110,9 +116,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(); @@ -123,6 +129,31 @@ describe('selectServer saga — resolving the target workspace user', () => { expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_URL); }); + it('uses the workspace-scoped token when the cached record holds another workspace token', async () => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); + jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: 'token-other-workspace', username: 'new' } as any); + + const { store } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().login.user.token).toBe(TOKEN); + }); + + it('does not restore the cached record token when the target workspace has no scoped token', async () => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: 'token-other-workspace' } as any); + + const { store } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(getLoggedUserById).not.toHaveBeenCalled(); + expect(store.getState().login.user).toEqual({}); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(OLD_SERVER); + }); + it('does not stamp CURRENT_SERVER when the target workspace has no credentials', async () => { const { store } = setupStore(); store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); @@ -135,6 +166,7 @@ describe('selectServer saga — resolving the target workspace user', () => { it('leaves CURRENT_SERVER on the previous workspace when the switch fails', async () => { UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); const { store, dispatchedActions } = setupStore(); @@ -151,6 +183,7 @@ describe('selectServer saga — resolving the target workspace user', () => { expect(RocketChatSettings.customHeaders).toHaveProperty('Authorization'); UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN } as any); const { store } = setupStore(); @@ -164,6 +197,7 @@ describe('selectServer saga — resolving the target workspace user', () => { describe('selectServer saga — version and name fallback', () => { beforeEach(() => { UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN } as any); }); @@ -208,6 +242,7 @@ describe('selectServer saga — version and name fallback', () => { describe('selectServer saga — user-facing root after a failed switch', () => { beforeEach(() => { UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(getUserTokenKey(SERVER_URL, USER_ID), TOKEN); jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); }); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 636dc297ab2..cff50479e6f 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,26 @@ const fallbackNavigation = function* fallbackNavigation() { yield put(appInit()); }; +const declineDeepLinkLogin = function* declineDeepLinkLogin() { + const currentRoot = yield select(state => state.app.root); + if (currentRoot) { + showToast(I18n.t('Deep_link_login_declined')); + } + yield fallbackNavigation(); +}; + +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 +196,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()); @@ -196,6 +232,65 @@ const handleShareExtension = function* handleOpen({ params }) { } }; +const loginWithDeepLinkToken = function* loginWithDeepLinkToken({ params, hostAlreadyConnected }) { + if (!hostAlreadyConnected) { + yield take(types.SERVER.SELECT_SUCCESS); + 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); + yield put(appReady({})); + + const currentRoot = yield select(state => state.app.root); + if (currentRoot !== RootEnum.ROOT_INSIDE) { + yield take(action => action.type === types.APP.START && action.root === RootEnum.ROOT_INSIDE); + } + yield completeDeepLinkNavigation(params); +}; + +const handleOpenDifferentServer = function* handleOpenDifferentServer({ params, server, user, serverRecord }) { + const { host } = params; + try { + if (user && serverRecord) { + yield localAuthenticate(host); + yield put(selectServerRequest(host, serverRecord.version, true, true)); + yield take(types.LOGIN.SUCCESS); + yield completeDeepLinkNavigation(params); + return; + } + } catch (e) { + // do nothing + } + if (!(yield ensureDeepLinkLoginConsent(host, params))) { + return; + } + const result = yield getServerInfo(host); + if (!result.success) { + if (params.voipAcceptFailed) { + yield call(handleVoipAcceptFailed, params); + return; + } + yield fallbackNavigation(); + return; + } + const hostAlreadyConnected = sdk.host === host; + if (!hostAlreadyConnected) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + yield put(serverInitAdd(server)); + yield delay(1000); + EventEmitter.emit('NewServer', { server: host }); + } + + if (params.token) { + yield loginWithDeepLinkToken({ params, hostAlreadyConnected }); + } else { + yield handleInviteLink({ params, requireLogin: true }); + } +}; + const handleOpen = function* handleOpen({ params }) { if (params.type === 'shareextension') { yield handleShareExtension({ params }); @@ -227,7 +322,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); @@ -243,55 +338,7 @@ const handleOpen = function* handleOpen({ params }) { } yield completeDeepLinkNavigation(params); } else { - // search if deep link's server already exists - try { - if (user && serverRecord) { - yield localAuthenticate(host); - yield put(selectServerRequest(host, serverRecord.version, true, true)); - yield take(types.LOGIN.SUCCESS); - yield completeDeepLinkNavigation(params); - return; - } - } catch (e) { - // do nothing? - } - // if deep link is from a different server - const result = yield getServerInfo(host); - if (!result.success) { - if (params.voipAcceptFailed) { - yield call(handleVoipAcceptFailed, params); - return; - } - // Fallback to prevent the app from being stuck on splash screen - yield fallbackNavigation(); - return; - } - // if the host is different from the current one, we need to connect to it before navigating - const hostAlreadyConnected = sdk.host === host; - if (!hostAlreadyConnected) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - yield put(serverInitAdd(server)); - yield delay(1000); - EventEmitter.emit('NewServer', { server: host }); - } - - if (params.token) { - if (!hostAlreadyConnected) { - yield take(types.SERVER.SELECT_SUCCESS); - } - yield put(loginRequest({ resume: params.token }, true)); - yield take(types.LOGIN.SUCCESS); - yield put(appReady({})); - // Wait for the login saga's appStart(ROOT_INSIDE) before navigating, so - // InsideStack is mounted and goRoom dispatches into the correct stack. - const currentRoot = yield select(state => state.app.root); - if (currentRoot !== RootEnum.ROOT_INSIDE) { - yield take(action => action.type === types.APP.START && action.root === RootEnum.ROOT_INSIDE); - } - yield completeDeepLinkNavigation(params); - } else { - yield handleInviteLink({ params, requireLogin: true }); - } + yield handleOpenDifferentServer({ params, server, user, serverRecord }); } }; @@ -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..af86607e735 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,32 +151,29 @@ 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)); + const token = userId ? UserPreferences.getString(getUserTokenKey(server, userId)) : null; let user = null; - if (userId) { + if (userId && token) { // search credentials on database const userRecord = yield* call(getLoggedUserById, userId); - if (userRecord) { - user = { - id: userRecord.id, - token: userRecord.token, - username: userRecord.username, - name: userRecord.name, - language: userRecord.language, - status: userRecord.status, - statusText: userRecord.statusText, - roles: userRecord.roles, - avatarETag: userRecord.avatarETag, - bio: userRecord.bio, - nickname: userRecord.nickname, - requirePasswordChange: userRecord.requirePasswordChange - }; - } else { - const token = UserPreferences.getString(`${TOKEN_KEY}-${userId}`); - if (token) { - user = { token }; - } - } + user = userRecord + ? { + id: userRecord.id, + token, + username: userRecord.username, + name: userRecord.name, + language: userRecord.language, + status: userRecord.status, + statusText: userRecord.statusText, + roles: userRecord.roles, + avatarETag: userRecord.avatarETag, + bio: userRecord.bio, + nickname: userRecord.nickname, + requirePasswordChange: userRecord.requirePasswordChange + } + : { token }; } const basicAuth = UserPreferences.getString(`${BASIC_AUTH_KEY}-${server}`); diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 248d67fe460..8c9e46c3e29 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 }