diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 4c5780f8b29..83c14bad2dd 100644 --- a/.maestro/tests/assorted/deeplink.yaml +++ b/.maestro/tests/assorted/deeplink.yaml @@ -310,3 +310,80 @@ tags: visible: id: 'new-server-view' timeout: 60000 + +# should show a security confirmation prompt on deep link login and abort when cancelled. +- runFlow: + file: '../../helpers/launch-app.yaml' +- stopApp: ${APP_ID} +- runFlow: + file: '../../helpers/open-deeplink.yaml' + env: + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +- assertVisible: + text: '.*A link is asking to sign you in.*' +# decline the prompt — the app must NOT sign in or navigate to the room +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'android:id/button2' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Cancel' +- extendedWaitUntil: + visible: + id: 'workspace-view' + timeout: 60000 +- assertNotVisible: + id: 'room-view-title-${output.room.name}' + +# should show a security confirmation prompt on deep link login and sign in when confirmed +- runFlow: + file: '../../helpers/launch-app.yaml' +- stopApp: ${APP_ID} +- runFlow: + file: '../../helpers/open-deeplink.yaml' + env: + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&forceLoginPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +# confirm the prompt — the app must sign in and navigate to the room. +# android:id/button1 is the positive (confirm) button, disambiguating it from the "Login" button +# rendered on the workspace screen behind the alert. +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'android:id/button1' + timeout: 60000 + - tapOn: + id: 'android:id/button1' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Login' + rightOf: + text: 'Cancel' +- extendedWaitUntil: + visible: + id: 'room-view-title-${output.room.name}' + timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 18337835f5e..0e9fdce9400 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -105,11 +105,11 @@ + android:exported="false" /> + android:exported="false" > `${TOKEN_KEY}-${server}`; +export const getLegacyUserTokenKey = (userId: string): string => `${TOKEN_KEY}-${userId}`; +export const getUserTokenKey = (server: string, userId: string): string => `${TOKEN_KEY}-${server}-${userId}`; +export const TOKEN_KEY_SERVER_SCOPED_MIGRATED = 'RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED'; export const CURRENT_SERVER = 'currentServer'; export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY'; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..cf23db8ef0e 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -8,16 +8,26 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; -import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; +import { + CURRENT_SERVER, + E2E_PRIVATE_KEY, + E2E_PUBLIC_KEY, + E2E_RANDOM_PASSWORD_KEY, + getLegacyUserTokenKey, + getServerUserIdKey, + getUserTokenKey +} from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; import { roomsSubscription } from './subscriptions/rooms'; import { _activeUsersSubTimeout } from './getUsersPresence'; function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) { - UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); + UserPreferences.removeItem(getServerUserIdKey(server)); if (userId) { - UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`); + UserPreferences.removeItem(getUserTokenKey(server, userId)); + // A logout before the migration ran leaves a token the native fallbacks would still read. + UserPreferences.removeItem(getLegacyUserTokenKey(userId)); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); UserPreferences.removeItem(`${server}-${E2E_PUBLIC_KEY}`); @@ -29,7 +39,7 @@ export async function removeServerData({ server }: { server: string }): Promise< try { const batch: Model[] = []; const serversDB = database.servers; - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); const usersCollection = serversDB.get('users'); if (userId) { @@ -62,9 +72,9 @@ export async function removeServerDatabase({ server }: { server: string }): Prom export async function removeServer({ server }: { server: string }): Promise { try { - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const userId = UserPreferences.getString(getServerUserIdKey(server)); if (userId) { - const resume = UserPreferences.getString(`${TOKEN_KEY}-${userId}`); + const resume = UserPreferences.getString(getUserTokenKey(server, userId)); try { const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: isSsl(server) }); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.test.ts b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts new file mode 100644 index 00000000000..3d92257b771 --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.test.ts @@ -0,0 +1,167 @@ +import { migrateTokenKeysToServerScoped } from './migrateTokenKeysToServerScoped'; +import UserPreferences from './userPreferences'; +import database from '../database'; +import log from './helpers/log'; +import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getServerUserIdKey, getUserTokenKey } from '../constants/keys'; + +jest.mock('../database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn() + } + } +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockedFetch = jest.fn(); + +// Configure which server records `database.servers.get('servers').query().fetch()` resolves to. +const setServers = (serverIds: string[]) => { + mockedFetch.mockResolvedValue(serverIds.map(id => ({ id }))); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); +}; + +describe('migrateTokenKeysToServerScoped', () => { + beforeEach(() => { + jest.clearAllMocks(); + UserPreferences.clearAll(); + }); + + it('is a no-op when the migration flag is already set', async () => { + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + setServers(['https://open.rocket.chat']); + + await migrateTokenKeysToServerScoped(); + + expect(database.servers.get).not.toHaveBeenCalled(); + }); + + it('migrates the legacy token to the server-scoped slot and drops the legacy slot', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'the-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('the-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('drops the legacy slot without migrating when the userId is shared by multiple servers', async () => { + const serverA = 'https://a.rocket.chat'; + const serverB = 'https://b.rocket.chat'; + const userId = 'shared'; + UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'ambiguous-token'); + setServers([serverA, serverB]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverA, userId))).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverB, userId))).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('does not overwrite an existing server-scoped token', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token'); + UserPreferences.setString(getUserTokenKey(server, userId), 'existing-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('existing-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('drops a legacy slot whose server has no record left in the database', async () => { + const orphanUserId = 'orphan'; + UserPreferences.setString(`${TOKEN_KEY}-${orphanUserId}`, 'orphan-token'); + setServers([]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${orphanUserId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('leaves server-scoped keys untouched while dropping orphaned legacy slots', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token'); + UserPreferences.setString(`${TOKEN_KEY}-orphan`, 'orphan-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-orphan`)).toBeNull(); + }); + + it('preserves keys for a schemeless subpath server', async () => { + // completeUrl leaves `example.com/rocketchat` as typed, so these keys carry no scheme. + const server = 'example.com/rocketchat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('legacy-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + }); + + it('does not sweep a schemeless server whose row is gone', async () => { + const server = 'example.com/rocketchat'; + const userId = 'user1'; + UserPreferences.setString(getServerUserIdKey(server), userId); + UserPreferences.setString(getUserTokenKey(server, userId), 'scoped-token'); + setServers([]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getServerUserIdKey(server))).toBe(userId); + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('scoped-token'); + }); + + it('skips servers that have no stored userId', async () => { + const server = 'https://open.rocket.chat'; + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('logs and swallows errors instead of throwing, leaving the flag unset', async () => { + const error = new Error('db exploded'); + mockedFetch.mockRejectedValue(error); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); + + await expect(migrateTokenKeysToServerScoped()).resolves.toBeUndefined(); + + expect(log).toHaveBeenCalledWith(error); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBeNull(); + }); +}); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts new file mode 100644 index 00000000000..f547d6838f6 --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -0,0 +1,71 @@ +import { + TOKEN_KEY, + TOKEN_KEY_SERVER_SCOPED_MIGRATED, + getLegacyUserTokenKey, + getServerUserIdKey, + getUserTokenKey +} from '../constants/keys'; +import UserPreferences from './userPreferences'; +import database from '../database'; +import log from './helpers/log'; + +// A bare alphanumeric suffix is a userId: server ids always carry a dot, a scheme or a path separator. +const isLegacyUserTokenKey = (key: string): boolean => /^[A-Za-z0-9]+$/.test(key.replace(`${TOKEN_KEY}-`, '')); + +export const migrateTokenKeysToServerScoped = async (): Promise => { + try { + if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { + return; + } + const serversDB = database.servers; + const servers = await serversDB.get('servers').query().fetch(); + + const serversByUserId = new Map(); + for (let i = 0; i < servers.length; i += 1) { + const server = servers[i].id; + const userId = UserPreferences.getString(getServerUserIdKey(server)); + if (!userId) { + continue; + } + const sharing = serversByUserId.get(userId); + if (sharing) { + sharing.push(server); + } else { + serversByUserId.set(userId, [server]); + } + } + + serversByUserId.forEach((sharing, userId) => { + const legacyKey = getLegacyUserTokenKey(userId); + // A userId claimed by more than one server is ambiguous: drop the legacy slot instead of + // migrating it, so the session re-authenticates. + if (sharing.length > 1) { + UserPreferences.removeItem(legacyKey); + return; + } + const newKey = getUserTokenKey(sharing[0], userId); + const token = UserPreferences.getString(legacyKey); + if (token && !UserPreferences.getString(newKey)) { + UserPreferences.setString(newKey, token); + } + UserPreferences.removeItem(legacyKey); + }); + + // Legacy slots whose server has no row left in the database are unreachable above, and the + // migrated flag stops the native fallbacks from reading them. Drop them instead of stranding them. + const liveKeys = new Set(); + serversByUserId.forEach((sharing, userId) => { + sharing.forEach(server => { + liveKeys.add(getServerUserIdKey(server)); + liveKeys.add(getUserTokenKey(server, userId)); + }); + }); + UserPreferences.getAllKeys() + .filter(key => key.startsWith(`${TOKEN_KEY}-`) && !liveKeys.has(key) && isLegacyUserTokenKey(key)) + .forEach(key => UserPreferences.removeItem(key)); + + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + } catch (e) { + log(e); + } +}; diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e6ceccde958..215b2526233 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -82,6 +82,10 @@ jest.mock('i18n-js', () => ({ default: { t: (k: string) => k } })); +jest.mock('../../lib/methods/helpers/info', () => ({ + showConfirmationAlert: jest.fn(({ onPress }: { onPress: () => void }) => onPress()) +})); + // Mock helpers to avoid auxStore (getUidDirectMessage / getRoomTitle call reduxStore.getState()) jest.mock('../../lib/methods/helpers', () => ({ getUidDirectMessage: jest.fn(() => null), @@ -99,10 +103,13 @@ import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLin import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; +import { connectSuccess } from '../../actions/connect'; +import { APP, LOGIN, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; +import { showConfirmationAlert } from '../../lib/methods/helpers/info'; import { getServerById } from '../../lib/database/services/Server'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; @@ -123,6 +130,10 @@ async function flushSagaMicrotasks(): Promise { type PreloadedState = Parameters[1]; +/** Messages pushed through showToast, which emits on the Toast LISTENER channel. */ +const toastedMessages = (emitSpy: jest.SpyInstance): string[] => + emitSpy.mock.calls.map(([, payload]: any[]) => payload?.message).filter(Boolean); + function setupStore(preloadedState?: PreloadedState) { const sagaMiddleware = createSagaMiddleware(); const store = createStore(reducers, preloadedState, applyMiddleware(sagaMiddleware)); @@ -130,6 +141,19 @@ function setupStore(preloadedState?: PreloadedState) { return store; } +/** Same as setupStore, plus an `actions` array recording everything dispatched. */ +function setupRecordingStore(preloadedState?: PreloadedState) { + const sagaMiddleware = createSagaMiddleware(); + const actions: any[] = []; + const recorder = () => (next: any) => (action: any) => { + actions.push(action); + return next(action); + }; + const store = createStore(reducers, preloadedState, applyMiddleware(recorder, sagaMiddleware)); + sagaMiddleware.run(deepLinkingRoot); + return { store, actions }; +} + // ─── Factories ──────────────────────────────────────────────────────────────── const HOST = 'https://open.rocket.chat'; @@ -217,6 +241,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // Saga is now waiting for METEOR.SUCCESS — loginRequest is gated on the socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Saga is now waiting for LOGIN.SUCCESS expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); @@ -236,6 +264,149 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must + // skip the already-fired METEOR.SUCCESS take instead of hanging. + it('completes the chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + }); + + // loginRequest must not fire until the socket is connected (locks the gate). + it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Server selected but socket not connected yet → still parked at the gate. + expect(loginRequested()).toBe(false); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Socket connected → gate released, loginRequest dispatched. + expect(loginRequested()).toBe(true); + }); + + it('does not touch the deep link server when the login confirmation is declined', async () => { + jest.mocked(showConfirmationAlert).mockClear(); + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + + const { store, actions } = setupRecordingStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Prompt shown, and declining leaves the deep link's server entirely untouched: no + // connection attempt, no server added, no navigation away from where the user was. + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(actions.some(a => a.type === APP.START)).toBe(false); + // Cold start: normal init takes over instead of the deep link's server, and there is no + // mounted Toast to show a message on. + expect(actions.some(a => a.type === APP.INIT)).toBe(true); + expect(toastedMessages(emitSpy)).not.toContain('Deep_link_login_declined'); + emitSpy.mockRestore(); + }); + + it('leaves a running app where it was when the login confirmation is declined', async () => { + jest.mocked(showConfirmationAlert).mockClear(); + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + + const { store, actions } = setupRecordingStore({ app: { root: RootEnum.ROOT_INSIDE } } as PreloadedState); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(actions.some(a => a.type === LOGIN.REQUEST)).toBe(false); + expect(actions.some(a => a.type === SERVER.INIT_ADD)).toBe(false); + expect(actions.some(a => a.type === APP.START)).toBe(false); + expect(actions.some(a => a.type === APP.INIT)).toBe(false); + expect(toastedMessages(emitSpy)).toContain('Deep_link_login_declined'); + emitSpy.mockRestore(); + }); + + // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a + // native Alert — except when the deep link carries `forceLoginPrompt=true`, which opts a + // dedicated e2e flow back into the real prompt (see the deeplink.yaml Maestro test). + describe('RUNNING_E2E_TESTS auto-confirm gate', () => { + const original = process.env.RUNNING_E2E_TESTS; + beforeEach(() => { + process.env.RUNNING_E2E_TESTS = 'true'; + jest.mocked(showConfirmationAlert).mockClear(); + }); + afterEach(() => { + process.env.RUNNING_E2E_TESTS = original; + }); + + it('auto-confirms without showing the prompt when no forceLoginPrompt marker is present', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // No prompt shown, yet login still proceeds — pre-fix silent behavior preserved. + expect(jest.mocked(showConfirmationAlert)).not.toHaveBeenCalled(); + expect(loginRequested()).toBe(true); + }); + + it('shows the real prompt when the deep link carries forceLoginPrompt=true', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken({ forceLoginPrompt: 'true' }))); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + }); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. @@ -253,6 +424,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -285,6 +459,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush. // The reducer processes both dispatches before the saga's select runs, // so the select sees ROOT_INSIDE and skips the take. @@ -314,6 +491,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -349,6 +529,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292db..fcf08abafc7 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -10,7 +10,7 @@ import { inviteLinksRequest, inviteLinksSetToken } from '../actions/inviteLinks' import { loginRequest } from '../actions/login'; import { selectServerRequest, serverInitAdd } from '../actions/server'; import { RootEnum } from '../definitions'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey } from '../lib/constants/keys'; import database from '../lib/database'; import { getServerById } from '../lib/database/services/Server'; import { canOpenRoom } from '../lib/methods/canOpenRoom'; @@ -21,6 +21,7 @@ import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; import log from '../lib/methods/helpers/log'; +import { showConfirmationAlert } from '../lib/methods/helpers/info'; import { showToast } from '../lib/methods/helpers/showToast'; import UserPreferences from '../lib/methods/userPreferences'; import { videoConfJoin } from '../lib/methods/videoConf'; @@ -37,6 +38,21 @@ const roomTypes = { channels: 'l' }; +const confirmDeepLinkLogin = (host, params = {}) => + new Promise(resolve => { + if (process.env.RUNNING_E2E_TESTS === 'true' && params.forceLoginPrompt !== 'true') { + resolve(true); + return; + } + showConfirmationAlert({ + title: I18n.t('Deep_link_login_title'), + message: I18n.t('Deep_link_login_description', { server: host }), + confirmationText: I18n.t('Login'), + onPress: () => resolve(true), + onCancel: () => resolve(false) + }); + }); + const handleInviteLink = function* handleInviteLink({ params, requireLogin = false }) { if (params.path && params.path.startsWith('invite/')) { const token = params.path.replace('invite/', ''); @@ -128,6 +144,15 @@ const fallbackNavigation = function* fallbackNavigation() { yield put(appInit()); }; +const declineDeepLinkLogin = function* declineDeepLinkLogin() { + // Only worth a toast while the app is up; on cold start there is no Toast mounted to show it. + const currentRoot = yield select(state => state.app.root); + if (currentRoot) { + showToast(I18n.t('Deep_link_login_declined')); + } + yield fallbackNavigation(); +}; + let consumedOAuthToken; const handleOAuth = function* handleOAuth({ params }) { @@ -145,7 +170,7 @@ const handleOAuth = function* handleOAuth({ params }) { const handleShareExtension = function* handleOpen({ params }) { const server = UserPreferences.getString(CURRENT_SERVER); - const user = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const user = UserPreferences.getString(getServerUserIdKey(server)); if (!user) { yield put(appInit()); @@ -193,7 +218,7 @@ const handleOpen = function* handleOpen({ params }) { const [server, user] = yield all([ UserPreferences.getString(CURRENT_SERVER), - UserPreferences.getString(`${TOKEN_KEY}-${host}`) + UserPreferences.getString(getServerUserIdKey(host)) ]); const serverRecord = yield getServerById(host); @@ -221,6 +246,15 @@ const handleOpen = function* handleOpen({ params }) { } catch (e) { // do nothing? } + // Consent before touching anything on the deep link's server: a resume token means this + // link can sign the user in, so ask while declining is still a no-op. + if (params.token) { + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield declineDeepLinkLogin(); + return; + } + } // if deep link is from a different server const result = yield getServerInfo(host); if (!result.success) { @@ -244,6 +278,11 @@ const handleOpen = function* handleOpen({ params }) { if (params.token) { if (!hostAlreadyConnected) { yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS can land after the socket is already connected. + const connected = yield select(state => state.meteor.connected); + if (!connected) { + yield take(types.METEOR.SUCCESS); + } } yield put(loginRequest({ resume: params.token }, true)); yield take(types.LOGIN.SUCCESS); @@ -305,7 +344,7 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { const [server, user] = yield all([ UserPreferences.getString(CURRENT_SERVER), - UserPreferences.getString(`${TOKEN_KEY}-${host}`) + UserPreferences.getString(getServerUserIdKey(host)) ]); const serverRecord = yield getServerById(host); @@ -326,6 +365,15 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { yield handleNavigateCallRoom({ params }); return; } + // Consent before touching anything on the deep link's server: a resume token means this + // link can sign the user in, so ask while declining is still a no-op. + if (params.token) { + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield declineDeepLinkLogin(); + return; + } + } // if deep link is from a different server const result = yield getServerInfo(host); if (!result.success) { diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..10cc5896166 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -2,8 +2,9 @@ import { call, put, select, takeLatest } from 'redux-saga/effects'; import RNBootSplash from 'react-native-bootsplash'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; @@ -23,8 +24,10 @@ export const initLocalSettings = function* initLocalSettings() { const restore = function* restore() { try { + yield call(migrateTokenKeysToServerScoped); + const server = UserPreferences.getString(CURRENT_SERVER); - let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + let userId = UserPreferences.getString(getServerUserIdKey(server)); if (!server) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); @@ -37,7 +40,7 @@ const restore = function* restore() { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + userId = UserPreferences.getString(getServerUserIdKey(newServer)); if (userId) { return yield put(selectServerRequest(newServer, newServer.version)); } diff --git a/app/sagas/login.js b/app/sagas/login.js index 6d5f1f8e7d9..162503d8dc3 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -22,7 +22,7 @@ import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry' import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; import { RootEnum } from '../definitions'; import sdk from '../lib/services/sdk'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys'; import { getCustomEmojis } from '../lib/methods/getCustomEmojis'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules'; @@ -343,8 +343,8 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { } }); - UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id); - UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token); + UserPreferences.setString(getServerUserIdKey(server), user.id); + UserPreferences.setString(getUserTokenKey(server, user.id), user.token); UserPreferences.setString(CURRENT_SERVER, server); EventEmitter.emit('connected'); const currentRoot = yield select(state => state.app.root); @@ -388,7 +388,7 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + const token = UserPreferences.getString(getServerUserIdKey(newServer)); if (token) { yield put(selectServerRequest(newServer, newServer.version)); return; @@ -455,7 +455,7 @@ const handleDeleteAccount = function* handleDeleteAccount() { if (servers.length > 0) { for (let i = 0; i < servers.length; i += 1) { const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); + const token = UserPreferences.getString(getServerUserIdKey(newServer)); if (token) { yield put(selectServerRequest(newServer, newServer.version)); return; diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..a126e27153e 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -29,7 +29,8 @@ import UserPreferences from '../lib/methods/userPreferences'; import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { type IServerInfo, RootEnum, type TServerModel } from '../definitions'; -import { CERTIFICATE_KEY, CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CERTIFICATE_KEY, CURRENT_SERVER, getServerUserIdKey, getUserTokenKey } from '../lib/constants/keys'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { checkSupportedVersions } from '../lib/methods/checkSupportedVersions'; import { getLoginSettings, setSettings } from '../lib/methods/getSettings'; import { getServerInfo } from '../lib/methods/getServerInfo'; @@ -150,7 +151,8 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(inquiryReset()); yield put(encryptionStop()); yield put(clearActiveUsers()); - const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + yield* call(migrateTokenKeysToServerScoped); + const userId = UserPreferences.getString(getServerUserIdKey(server)); let user = null; if (userId) { // search credentials on database @@ -171,7 +173,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch requirePasswordChange: userRecord.requirePasswordChange }; } else { - const token = UserPreferences.getString(`${TOKEN_KEY}-${userId}`); + const token = UserPreferences.getString(getUserTokenKey(server, userId)); if (token) { user = { token }; } diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 29616a6ad8c..57845f72262 100644 --- a/app/views/RoomsListView/components/ServersList.tsx +++ b/app/views/RoomsListView/components/ServersList.tsx @@ -11,7 +11,7 @@ import * as List from '../../../containers/List'; import ServerItem from '../../../containers/ServerItem'; import { RootEnum, type TServerModel } from '../../../definitions'; import I18n from '../../../i18n'; -import { TOKEN_KEY } from '../../../lib/constants/keys'; +import { getServerUserIdKey } from '../../../lib/constants/keys'; import database from '../../../lib/database'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { useMasterDetail } from '../../../lib/hooks/useMasterDetail'; @@ -77,7 +77,7 @@ const ServersList = () => { close(); if (server !== serverParam) { logEvent(events.RL_CHANGE_SERVER); - const userId = UserPreferences.getString(`${TOKEN_KEY}-${serverParam}`); + const userId = UserPreferences.getString(getServerUserIdKey(serverParam)); if (isMasterDetail) { goRoom({ item: {}, isMasterDetail }); } diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index f825b4d2cfc..8c0156fa406 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -18,11 +18,18 @@ extension MMKVBridge { return MMKVBridge(id: "default", cryptKey: cryptKey, rootPath: mmkvPath) } - func userToken(for userId: String) -> String? { - guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else { + // Keep in sync with getUserTokenKey() (JS) and Ejson.token() (Android); falls back to the + // legacy userId-only slot until the JS migration runs. + func userToken(for userId: String, server: String) -> String? { + if let userToken = string(forKey: "reactnativemeteor_usertoken-\(server)-\(userId)") { + return userToken + } + // The legacy slot is ambiguous across servers sharing a userId, so it is only readable + // before migrateTokenKeysToServerScoped (JS) runs. + if bool(forKey: "RC_TOKEN_KEY_SERVER_SCOPED_MIGRATED") { return nil } - return userToken + return string(forKey: "reactnativemeteor_usertoken-\(userId)") } func userId(for server: String) -> String? { diff --git a/ios/Shared/RocketChat/MMKVBridge.h b/ios/Shared/RocketChat/MMKVBridge.h index 1c4cf510cd4..a4426c77462 100644 --- a/ios/Shared/RocketChat/MMKVBridge.h +++ b/ios/Shared/RocketChat/MMKVBridge.h @@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable NSString *)stringForKey:(NSString *)key; - (BOOL)setString:(NSString *)value forKey:(NSString *)key; +- (BOOL)boolForKey:(NSString *)key; - (nullable NSData *)dataForKey:(NSString *)key; - (BOOL)setData:(NSData *)value forKey:(NSString *)key; - (void)removeValueForKey:(NSString *)key; diff --git a/ios/Shared/RocketChat/MMKVBridge.mm b/ios/Shared/RocketChat/MMKVBridge.mm index db48f48476f..59e28334c5f 100644 --- a/ios/Shared/RocketChat/MMKVBridge.mm +++ b/ios/Shared/RocketChat/MMKVBridge.mm @@ -48,6 +48,11 @@ - (BOOL)setString:(NSString *)value forKey:(NSString *)key { return [_mmkvInstance setString:value forKey:key]; } +- (BOOL)boolForKey:(NSString *)key { + if (!_mmkvInstance) return NO; + return [_mmkvInstance getBoolForKey:key defaultValue:NO]; +} + - (nullable NSData *)dataForKey:(NSString *)key { if (!_mmkvInstance) return nil; return [_mmkvInstance getDataForKey:key]; diff --git a/ios/Shared/RocketChat/Storage.swift b/ios/Shared/RocketChat/Storage.swift index 63ce28eeef3..db7680169db 100644 --- a/ios/Shared/RocketChat/Storage.swift +++ b/ios/Shared/RocketChat/Storage.swift @@ -12,7 +12,7 @@ final class Storage { // Read credentials from MMKV (shared via app group) // Credentials are stored during login in React Native guard let userId = mmkv.userId(for: server), - let userToken = mmkv.userToken(for: userId) else { + let userToken = mmkv.userToken(for: userId, server: server) else { return nil } return Credentials(userId: userId, userToken: userToken) diff --git a/ios/Watch/WatchConnection.swift b/ios/Watch/WatchConnection.swift index 4e7069fff6e..a2fa0f7863c 100644 --- a/ios/Watch/WatchConnection.swift +++ b/ios/Watch/WatchConnection.swift @@ -41,7 +41,7 @@ final class WatchConnection: NSObject { } let servers = serversQuery.compactMap { item -> WatchMessage.Server? in - guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId) else { + guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId, server: item.identifier) else { return nil }