diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 4c5780f8b29..d8c805b730c 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, '&e2eConfirmPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +- assertVisible: + text: '.*A link is asking to sign you in.*' +# decline the prompt — the app must NOT sign in or navigate to the room +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'android:id/button2' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Cancel' +- extendedWaitUntil: + visible: + id: 'workspace-view' + timeout: 60000 +- assertNotVisible: + id: 'room-view-title-${output.room.name}' + +# should show a security confirmation prompt on deep link login and sign in when confirmed +- runFlow: + file: '../../helpers/launch-app.yaml' +- stopApp: ${APP_ID} +- runFlow: + file: '../../helpers/open-deeplink.yaml' + env: + link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name, '&e2eConfirmPrompt=true')} +- extendedWaitUntil: + visible: + text: '.*Sign in to this server.*' + timeout: 60000 +# confirm the prompt — the app must sign in and navigate to the room. +# android:id/button1 is the positive (confirm) button, disambiguating it from the "Login" button +# rendered on the workspace screen behind the alert. +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'android:id/button1' + timeout: 60000 + - tapOn: + id: 'android:id/button1' +- runFlow: + when: + platform: ios + commands: + - tapOn: + text: 'Login' + 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" > - a year ago + 2 years ago diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 53e24e6431b..12c58ecf7fa 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -148,6 +148,8 @@ "Custom": "مخصص", "Dark": "داكن", "Dark_level": "مستوى السمة الداكنة", + "Deep_link_login_description": "يوجد رابط يطلب تسجيل دخولك إلى {{server}}. تابع فقط إذا كنت قد فتحت هذا الرابط بنفسك وتثق به.", + "Deep_link_login_title": "تسجيل الدخول إلى هذا الخادم؟", "Default": "افتراضي", "Default_browser": "المتصفح الأساسي", "DELETE": "حذف", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 9daf3675e2f..0af42395d7b 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -213,6 +213,8 @@ "Dark": "অন্ধকার", "Dark_level": "অন্ধকার স্তর", "decline": "অস্বীকার করুন", + "Deep_link_login_description": "একটি লিঙ্ক আপনাকে {{server}}-এ সাইন ইন করাতে চাইছে। আপনি নিজে এই লিঙ্কটি খুলে থাকেন এবং এটিকে বিশ্বাস করেন তবেই এগিয়ে যান।", + "Deep_link_login_title": "এই সার্ভারে সাইন ইন করবেন?", "Default": "ডিফল্ট", "Default_browser": "ডিফল্ট ব্রাউজার", "Defined_user_as_role": "{{user}} একটি {{role}} হিসেবে নির্ধারণ করেছে", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 1d18ac3efbb..aba97a51fd9 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -225,6 +225,8 @@ "Dark": "Temný", "Dark_level": "Temná úroveň", "decline": "Pokles", + "Deep_link_login_description": "Odkaz se vás pokouší přihlásit do {{server}}. Pokračujte pouze, pokud jste tento odkaz otevřeli sami a důvěřujete mu.", + "Deep_link_login_title": "Přihlásit se k tomuto serveru?", "Default": "Výchozí", "Default_browser": "Výchozí prohlížeč", "Defined_user_as_role": "definováno {{user}} jako {{role}}", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index c1925e399ea..f874c61860d 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -207,6 +207,8 @@ "Dark": "Dunkel", "Dark_level": "Dunkelstufe", "decline": "Ablehnen", + "Deep_link_login_description": "Ein Link versucht, Sie bei {{server}} anzumelden. Fahren Sie nur fort, wenn Sie diesen Link selbst geöffnet haben und ihm vertrauen.", + "Deep_link_login_title": "Bei diesem Server anmelden?", "Default": "Standard", "Default_browser": "Standard-Browser", "Defined_user_as_role": "hat {{user}} als {{role}} gesetzt", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..5692ba8792b 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -230,6 +230,8 @@ "Dark": "Dark", "Dark_level": "Dark level", "decline": "Decline", + "Deep_link_login_description": "A link is asking to sign you in to {{server}}. Only continue if you opened this link yourself and trust it.", + "Deep_link_login_title": "Sign in to this server?", "Default": "Default", "Default_browser": "Default browser", "Defined_user_as_role": "defined {{user}} as {{role}}", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index d15ce6d6cb1..4afb4e86850 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -132,6 +132,8 @@ "Custom": "Personalizado", "Dark": "Oscuro", "Dark_level": "Nivel de oscuridad", + "Deep_link_login_description": "Un enlace está intentando iniciar sesión en {{server}}. Continúa solo si abriste este enlace tú mismo y confías en él.", + "Deep_link_login_title": "¿Iniciar sesión en este servidor?", "Default": "Por defecto", "DELETE": "ELIMINAR", "Delete": "Eliminar", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 45e7e8c9264..53867e698be 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -196,6 +196,8 @@ "Custom": "Mukautettu", "Dark": "Tumma", "Dark_level": "Tumman taso", + "Deep_link_login_description": "Linkki yrittää kirjata sinut palvelimeen {{server}}. Jatka vain, jos avasit tämän linkin itse ja luotat siihen.", + "Deep_link_login_title": "Kirjaudutaanko tälle palvelimelle?", "Default": "Oletus", "Default_browser": "Oletusselain", "Defined_user_as_role": "määritti käyttäjän {{user}} rooliin {{role}}", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index fa613486ddc..f488d0a3967 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -173,6 +173,8 @@ "Custom": "Personnalisé", "Dark": "Sombre", "Dark_level": "Niveau d'obscurité", + "Deep_link_login_description": "Un lien tente de vous connecter à {{server}}. Continuez uniquement si vous avez ouvert ce lien vous-même et que vous lui faites confiance.", + "Deep_link_login_title": "Se connecter à ce serveur ?", "Default": "Défaut", "Default_browser": "Navigateur par défaut", "DELETE": "SUPPRIMER", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 97f3343177e..62502372b56 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -213,6 +213,8 @@ "Dark": "डार्क", "Dark_level": "डार्क स्तर", "decline": "तिरस्कार", + "Deep_link_login_description": "एक लिंक आपको {{server}} में साइन इन कराना चाहता है। केवल तभी आगे बढ़ें जब आपने यह लिंक स्वयं खोला हो और इस पर भरोसा करते हों।", + "Deep_link_login_title": "इस सर्वर में साइन इन करें?", "Default": "डिफ़ॉल्ट", "Default_browser": "डिफ़ॉल्ट ब्राउज़र", "Defined_user_as_role": "{{user}} को {{role}} के रूप में परिभाषित किया गया है", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 89f68d4a3b2..02e5a3812ac 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -213,6 +213,8 @@ "Dark": "Sötét", "Dark_level": "Sötét szint", "decline": "Elutasítom", + "Deep_link_login_description": "Egy hivatkozás be szeretné jelentkeztetni a(z) {{server}} kiszolgálóra. Csak akkor folytassa, ha ezt a hivatkozást Ön nyitotta meg, és megbízik benne.", + "Deep_link_login_title": "Bejelentkezik erre a kiszolgálóra?", "Default": "Alapértelmezett", "Default_browser": "Alapértelmezett böngésző", "Defined_user_as_role": "a(z) {{user}} felhasználót mint {{role}} definiálták", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 5bd67b24060..21e6661be1c 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -155,6 +155,8 @@ "Custom": "Personalizzato", "Dark": "Scuro", "Dark_level": "Contrasto", + "Deep_link_login_description": "Un link sta tentando di farti accedere a {{server}}. Continua solo se hai aperto tu questo link e ti fidi.", + "Deep_link_login_title": "Accedere a questo server?", "Default": "Predefinito", "Default_browser": "Browser predefinito", "DELETE": "ELIMINA", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 86da11d01d9..e39519f170f 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -147,6 +147,8 @@ "Custom": "カスタム", "Dark": "ダーク", "Dark_level": "ダークレベル", + "Deep_link_login_description": "リンクが {{server}} へのサインインを求めています。このリンクを自分で開き、信頼できる場合にのみ続行してください。", + "Deep_link_login_title": "このサーバーにサインインしますか?", "Default": "デフォルト", "Default_browser": "デフォルトのブラウザ", "DELETE": "削除", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 0d86ce4995c..a2ee78bf978 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -173,6 +173,8 @@ "Custom": "Aangepast", "Dark": "Donker", "Dark_level": "Donker niveau", + "Deep_link_login_description": "Een link probeert je aan te melden bij {{server}}. Ga alleen verder als je deze link zelf hebt geopend en je deze vertrouwt.", + "Deep_link_login_title": "Aanmelden bij deze server?", "Default": "Standaard", "Default_browser": "Standaard browser", "DELETE": "VERWIJDEREN", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 84f8b93d623..a8c718c2419 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -117,6 +117,8 @@ "Current_Status": "Nåværende status", "Custom": "Tilpasset", "decline": "Avslå", + "Deep_link_login_description": "Ei lenkje prøver å logge deg inn på {{server}}. Hald berre fram dersom du opna denne lenkja sjølv og stolar på henne.", + "Deep_link_login_title": "Logge inn på denne serveren?", "Default": "Misligholde", "Delete": "Slett", "Delete_Account": "Slett konto", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 097da054fa0..84994a28492 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -217,6 +217,8 @@ "Dark": "Mørk", "Dark_level": "Mørkt nivå", "decline": "Avslå", + "Deep_link_login_description": "En lenke prøver å logge deg inn på {{server}}. Fortsett bare hvis du åpnet denne lenken selv og stoler på den.", + "Deep_link_login_title": "Logge inn på denne serveren?", "Default": "Standard", "Default_browser": "Standard nettleser", "Defined_user_as_role": "definert {{user}} som {{role}}", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 9b2e3db9f8f..77c8517b57f 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -226,6 +226,8 @@ "Dark": "Escuro", "Dark_level": "Nível escuro", "decline": "Recusar", + "Deep_link_login_description": "Um link está tentando fazer seu login em {{server}}. Continue apenas se você abriu esse link por conta própria e confia nele.", + "Deep_link_login_title": "Fazer login neste servidor?", "Default": "Padrão", "Default_browser": "Navegador padrão", "Defined_user_as_role": "definiu {{user}} como {{role}}", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 22b18117bb4..0e3ae5756e9 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -143,6 +143,8 @@ "Custom": "Personalizado", "Dark": "Escuro", "Dark_level": "Nível Escuro", + "Deep_link_login_description": "Um link está a tentar iniciar a sua sessão em {{server}}. Continue apenas se abriu este link por si próprio e confia nele.", + "Deep_link_login_title": "Iniciar sessão neste servidor?", "Default": "Predefinição", "Default_browser": "Navegador predefinido", "DELETE": "APAGAR", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 950a186f227..5cb29e37d94 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -190,6 +190,8 @@ "Custom": "Пользовательский", "Dark": "Темный", "Dark_level": "Уровень затемненности", + "Deep_link_login_description": "Ссылка пытается выполнить вход в {{server}}. Продолжайте, только если вы сами открыли эту ссылку и доверяете ей.", + "Deep_link_login_title": "Войти на этот сервер?", "Default": "По умолчанию", "Default_browser": "Браузер по умолчанию", "DELETE": "УДАЛИТЬ", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 0957302775d..d977b39d383 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -178,6 +178,8 @@ "Custom": "Po meri", "Dark": "Temno", "Dark_level": "Temna raven", + "Deep_link_login_description": "Povezava vas poskuša prijaviti v {{server}}. Nadaljujte le, če ste to povezavo odprli sami in ji zaupate.", + "Deep_link_login_title": "Se želite prijaviti v ta strežnik?", "Default": "Privzeto", "Default_browser": "Privzeti brskalnik", "DELETE": "Izbrisati", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 3466cfea7ca..89ea2f47d8c 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -196,6 +196,8 @@ "Custom": "Anpassad", "Dark": "Mörk", "Dark_level": "Mörk nivå", + "Deep_link_login_description": "En länk försöker logga in dig på {{server}}. Fortsätt bara om du själv öppnade den här länken och litar på den.", + "Deep_link_login_title": "Logga in på den här servern?", "Default": "Standard", "Default_browser": "Standardwebbläsare", "Defined_user_as_role": "angav {{user}} som {{role}}", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 83f1a3251fd..89f752326ba 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -213,6 +213,8 @@ "Dark": "கருப்பு", "Dark_level": "கருப்பு அளவு", "decline": "மறுக்கு", + "Deep_link_login_description": "ஒரு இணைப்பு உங்களை {{server}} இல் உள்நுழையச் கேட்கிறது. இந்த இணைப்பை நீங்களே திறந்து அதைப் நம்பினால் மட்டுமே தொடரவும்.", + "Deep_link_login_title": "இந்த சர்வரில் உள்நுழையவா?", "Default": "இயல்புநிலை", "Default_browser": "இயல்புநிலை உலாவி", "Defined_user_as_role": "{{user}} ஐ {{role}} என்று வெளியிடப்பட்டார்", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index dab8b05080e..13db35a4f5c 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -212,6 +212,8 @@ "Dark": "గాఢంగా", "Dark_level": "గాఢంగా స్థాయి", "decline": "నిరాకరించు", + "Deep_link_login_description": "ఒక లింక్ మిమ్మల్ని {{server}} లో సైన్ ఇన్ చేయాలని కోరుతోంది. మీరు ఈ లింక్‌ను స్వయంగా తెరిచి దానిని నమ్మితేనే కొనసాగండి.", + "Deep_link_login_title": "ఈ సర్వర్‌లో సైన్ ఇన్ చేయాలా?", "Default": "స్వచ్ఛందం", "Default_browser": "స్వచ్ఛంద బ్రౌజర్", "Defined_user_as_role": "{{user}} నియమాలను {{role}} గా ప్రవృత్తించారు", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 8c03a15c015..1f494c84a26 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -147,6 +147,8 @@ "Custom": "Özel", "Dark": "Karanlık", "Dark_level": "Karanlık Seviyesi", + "Deep_link_login_description": "Bir bağlantı sizi {{server}} sunucusunda oturum açtırmak istiyor. Yalnızca bu bağlantıyı kendiniz açtıysanız ve güveniyorsanız devam edin.", + "Deep_link_login_title": "Bu sunucuda oturum açılsın mı?", "Default": "Varsayılan", "Default_browser": "Varsayılan tarayıcı", "DELETE": "SİL", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 396b899cefe..15050547652 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -146,6 +146,8 @@ "Custom": "自定义", "Dark": "深色", "Dark_level": "深色程度", + "Deep_link_login_description": "有一个链接正尝试让你登录到 {{server}}。只有在你亲自打开此链接并且信任它的情况下才继续。", + "Deep_link_login_title": "登录到此服务器?", "Default": "默認", "Default_browser": "预设浏览器", "DELETE": "删除", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index a82f36c4aa9..a709d9d8740 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -148,6 +148,8 @@ "Custom": "自訂", "Dark": "深色", "Dark_level": "深色程度", + "Deep_link_login_description": "有一個連結正嘗試讓你登入 {{server}}。只有在你親自開啟此連結並且信任它的情況下才繼續。", + "Deep_link_login_title": "登入此伺服器?", "Default": "預設", "Default_browser": "預設瀏覽器", "DELETE": "刪除", diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index 8c6dc1da7d2..4289979b498 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -23,5 +23,13 @@ export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERE export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY'; export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY'; export const TOKEN_KEY = 'reactnativemeteor_usertoken'; +/** + * MMKV key for the auth token, scoped to (server, userId). Scoping by userId alone was ambiguous: + * two servers can share a userId (a malicious one can force it), so lookups could resolve another + * server's token (token confusion / exfiltration). Keep in sync with `Ejson.token()` on Android + * and the migration in the init saga. + */ +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..efd7d253108 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -8,7 +8,14 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; -import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; +import { + CURRENT_SERVER, + E2E_PRIVATE_KEY, + E2E_PUBLIC_KEY, + E2E_RANDOM_PASSWORD_KEY, + TOKEN_KEY, + getUserTokenKey +} from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; import { roomsSubscription } from './subscriptions/rooms'; @@ -17,6 +24,8 @@ import { _activeUsersSubTimeout } from './getUsersPresence'; function removeServerKeys({ server, userId }: { server: string; userId?: string | null }) { UserPreferences.removeItem(`${TOKEN_KEY}-${server}`); if (userId) { + UserPreferences.removeItem(getUserTokenKey(server, userId)); + // Also remove the legacy non-server-scoped token slot, in case this server predates the migration. UserPreferences.removeItem(`${TOKEN_KEY}-${userId}`); } UserPreferences.removeItem(`${BASIC_AUTH_KEY}-${server}`); @@ -64,7 +73,7 @@ export async function removeServer({ server }: { server: string }): Promise ({ + __esModule: true, + default: { + servers: { + get: jest.fn() + } + } +})); + +jest.mock('./helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockedFetch = jest.fn(); + +// Configure which server records `database.servers.get('servers').query().fetch()` resolves to. +const setServers = (serverIds: string[]) => { + mockedFetch.mockResolvedValue(serverIds.map(id => ({ id }))); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); +}; + +describe('migrateTokenKeysToServerScoped', () => { + beforeEach(() => { + jest.clearAllMocks(); + UserPreferences.clearAll(); + }); + + it('is a no-op when the migration flag is already set', async () => { + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + setServers(['https://open.rocket.chat']); + + await migrateTokenKeysToServerScoped(); + + expect(database.servers.get).not.toHaveBeenCalled(); + }); + + it('migrates the legacy token to the server-scoped slot and drops the legacy slot', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'the-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('the-token'); + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('drops the legacy slot without migrating when the userId is shared by multiple servers', async () => { + const serverA = 'https://a.rocket.chat'; + const serverB = 'https://b.rocket.chat'; + const userId = 'shared'; + UserPreferences.setString(`${TOKEN_KEY}-${serverA}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${serverB}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'ambiguous-token'); + setServers([serverA, serverB]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${userId}`)).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverA, userId))).toBeNull(); + expect(UserPreferences.getString(getUserTokenKey(serverB, userId))).toBeNull(); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('does not overwrite an existing server-scoped token', async () => { + const server = 'https://open.rocket.chat'; + const userId = 'user1'; + UserPreferences.setString(`${TOKEN_KEY}-${server}`, userId); + UserPreferences.setString(`${TOKEN_KEY}-${userId}`, 'legacy-token'); + UserPreferences.setString(getUserTokenKey(server, userId), 'existing-token'); + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getString(getUserTokenKey(server, userId))).toBe('existing-token'); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('skips servers that have no stored userId', async () => { + const server = 'https://open.rocket.chat'; + setServers([server]); + + await migrateTokenKeysToServerScoped(); + + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBe(true); + }); + + it('logs and swallows errors instead of throwing, leaving the flag unset', async () => { + const error = new Error('db exploded'); + mockedFetch.mockRejectedValue(error); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: mockedFetch }) + } as any); + + await expect(migrateTokenKeysToServerScoped()).resolves.toBeUndefined(); + + expect(log).toHaveBeenCalledWith(error); + expect(UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)).toBeNull(); + }); +}); diff --git a/app/lib/methods/migrateTokenKeysToServerScoped.ts b/app/lib/methods/migrateTokenKeysToServerScoped.ts new file mode 100644 index 00000000000..ca21437522a --- /dev/null +++ b/app/lib/methods/migrateTokenKeysToServerScoped.ts @@ -0,0 +1,57 @@ +import { TOKEN_KEY, TOKEN_KEY_SERVER_SCOPED_MIGRATED, getUserTokenKey } from '../constants/keys'; +import UserPreferences from './userPreferences'; +import database from '../database'; +import log from './helpers/log'; + +/** + * One-time migration of auth tokens from the legacy `${TOKEN_KEY}-${userId}` slot to the + * server-scoped `${TOKEN_KEY}-${server}-${userId}` slot (see `getUserTokenKey`). Only userIds + * owned by a single server are migrated; ambiguous ones are dropped, forcing re-authentication. + */ +export const migrateTokenKeysToServerScoped = async (): Promise => { + try { + if (UserPreferences.getBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED)) { + return; + } + const serversDB = database.servers; + const servers = await serversDB.get('servers').query().fetch(); + + // Map each server to its userId and count how many servers reference each userId. + const serverUserIds: { server: string; userId: string }[] = []; + const serverCountByUserId: Record = {}; + for (let i = 0; i < servers.length; i += 1) { + const server = servers[i].id; + const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + if (!userId) { + continue; + } + serverUserIds.push({ server, userId }); + serverCountByUserId[userId] = (serverCountByUserId[userId] || 0) + 1; + } + + // Collected in a Set so an ambiguous userId shared by N servers is removed once. + const legacyKeys = new Set(); + for (let i = 0; i < serverUserIds.length; i += 1) { + const { server, userId } = serverUserIds[i]; + const legacyKey = `${TOKEN_KEY}-${userId}`; + // Ambiguous: don't migrate, just drop the legacy slot so the session re-authenticates. + if (serverCountByUserId[userId] > 1) { + legacyKeys.add(legacyKey); + continue; + } + const newKey = getUserTokenKey(server, userId); + if (!UserPreferences.getString(newKey)) { + const token = UserPreferences.getString(legacyKey); + if (token) { + UserPreferences.setString(newKey, token); + legacyKeys.add(legacyKey); + } + } + } + // Drop the legacy slots (migrated and ambiguous alike) now that the migration is done. + legacyKeys.forEach(key => UserPreferences.removeItem(key)); + UserPreferences.setBool(TOKEN_KEY_SERVER_SCOPED_MIGRATED, true); + } catch (e) { + log(e); + } +}; diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 632d05f369f..d5ae4672d44 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), @@ -100,11 +104,12 @@ import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; import { connectSuccess } from '../../actions/connect'; import { appStart } from '../../actions/app'; -import { LOGIN } from '../../actions/actionsTypes'; +import { APP, LOGIN } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; +import { showConfirmationAlert } from '../../lib/methods/helpers/info'; import { getServerById } from '../../lib/database/services/Server'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; @@ -283,6 +288,41 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + // Ordering race: the confirm prompt stays open (real native Alert) long enough for the + // NewServer connection — kicked off *before* the prompt — to complete. SERVER.SELECT_SUCCESS + // then fires while the user is still deciding, so the take must be guarded or the saga hangs + // on WorkspaceView without ever logging in (the iOS deep-link symptom). + it('completes the chain when SERVER.SELECT_SUCCESS fires while the confirm prompt is open', async () => { + // Capture onPress instead of auto-confirming, mimicking a prompt the user hasn't tapped yet. + let confirm: (() => void) | undefined; + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onPress }: any) => { + confirm = onPress; + }); + + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Prompt is open, not yet confirmed. The connection completes now: both + // SERVER.SELECT_SUCCESS and the socket connect fire before the saga reaches its takes. + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Still parked in the prompt — no premature login. + expect(loginRequested()).toBe(false); + + // User confirms. The guard sees the server is already selected + connected and skips the + // stale takes, so loginRequest fires instead of the saga hanging. + confirm?.(); + await flushSagaMicrotasks(); + expect(loginRequested()).toBe(true); + }); + // loginRequest must not fire until the socket is connected (locks the gate). it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { const { store, actions } = setupRecordingStore(); @@ -306,6 +346,67 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(loginRequested()).toBe(true); }); + it('does not dispatch loginRequest when the deep-link login confirmation is declined', async () => { + jest.mocked(showConfirmationAlert).mockClear(); + jest.mocked(showConfirmationAlert).mockImplementationOnce(({ onCancel }: any) => onCancel?.()); + + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // User declined → confirmation shown, no login, parked outside. + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + expect(loginRequested()).toBe(false); + expect(actions.some(a => a.type === APP.START && (a as any).root === RootEnum.ROOT_OUTSIDE)).toBe(true); + }); + + // Under RUNNING_E2E_TESTS the prompt is auto-confirmed so most flows don't have to dismiss a + // native Alert — except when the deep link carries `e2eConfirmPrompt=true`, which opts a + // dedicated e2e flow back into the real prompt (see the deeplink.yaml Maestro test). + describe('RUNNING_E2E_TESTS auto-confirm gate', () => { + const original = process.env.RUNNING_E2E_TESTS; + beforeEach(() => { + process.env.RUNNING_E2E_TESTS = 'true'; + jest.mocked(showConfirmationAlert).mockClear(); + }); + afterEach(() => { + process.env.RUNNING_E2E_TESTS = original; + }); + + it('auto-confirms without showing the prompt when no e2eConfirmPrompt marker is present', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // No prompt shown, yet login still proceeds — pre-fix silent behavior preserved. + expect(jest.mocked(showConfirmationAlert)).not.toHaveBeenCalled(); + expect(loginRequested()).toBe(true); + }); + + it('shows the real prompt when the deep link carries e2eConfirmPrompt=true', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken({ e2eConfirmPrompt: 'true' }))); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + expect(jest.mocked(showConfirmationAlert)).toHaveBeenCalledTimes(1); + }); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index b6892f18b39..23d0661263d 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -21,6 +21,7 @@ import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; import log from '../lib/methods/helpers/log'; +import { showConfirmationAlert } from '../lib/methods/helpers/info'; import { showToast } from '../lib/methods/helpers/showToast'; import UserPreferences from '../lib/methods/userPreferences'; import { videoConfJoin } from '../lib/methods/videoConf'; @@ -37,6 +38,23 @@ const roomTypes = { channels: 'l' }; +const confirmDeepLinkLogin = (host, params = {}) => + new Promise(resolve => { + // Under E2E tests, auto-confirm so flows don't have to dismiss the native Alert. + // `e2eConfirmPrompt=true` opts back into the real prompt to test it. Test-only; real users always get the prompt. + if (process.env.RUNNING_E2E_TESTS === 'true' && params.e2eConfirmPrompt !== 'true') { + resolve(true); + return; + } + 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/', ''); @@ -242,8 +260,19 @@ const handleOpen = function* handleOpen({ params }) { } if (params.token) { + const confirmed = yield call(confirmDeepLinkLogin, host, params); + if (!confirmed) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } if (!hostAlreadyConnected) { - yield take(types.SERVER.SELECT_SUCCESS); + // The confirm prompt can sit open for seconds while the NewServer connection + // (emitted above) completes, so SERVER.SELECT_SUCCESS may have already fired by + // the time we get here. Guard the take instead of hanging on an event that's past. + const serverSelected = yield select(state => state.server.server === host && state.server.connected); + if (!serverSelected) { + yield take(types.SERVER.SELECT_SUCCESS); + } // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. const connected = yield select(state => state.meteor.connected); if (!connected) { diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..1ea6b7373a4 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, TOKEN_KEY } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; +import { migrateTokenKeysToServerScoped } from '../lib/methods/migrateTokenKeysToServerScoped'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; @@ -23,6 +24,8 @@ 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}`); diff --git a/app/sagas/login.js b/app/sagas/login.js index 10740600a33..d1c673d62ed 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -22,7 +22,7 @@ import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry' import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; import { RootEnum } from '../definitions'; import sdk from '../lib/services/sdk'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER, TOKEN_KEY, getUserTokenKey } from '../lib/constants/keys'; import { getCustomEmojis } from '../lib/methods/getCustomEmojis'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules'; @@ -341,7 +341,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { }); UserPreferences.setString(`${TOKEN_KEY}-${server}`, user.id); - UserPreferences.setString(`${TOKEN_KEY}-${user.id}`, user.token); + UserPreferences.setString(getUserTokenKey(server, user.id), user.token); UserPreferences.setString(CURRENT_SERVER, server); EventEmitter.emit('connected'); const currentRoot = yield select(state => state.app.root); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..06b755d1a58 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, TOKEN_KEY, 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,6 +151,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(inquiryReset()); yield put(encryptionStop()); yield put(clearActiveUsers()); + // Deep-link and share-extension startup can dispatch selectServerRequest() directly, without + // going through appInit(). Run the (idempotent, flag-guarded) token migration here too so the + // server-scoped read below finds legacy sessions instead of falling through to a login screen. + yield* call(migrateTokenKeysToServerScoped); const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); let user = null; if (userId) { @@ -171,7 +176,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch requirePasswordChange: userRecord.requirePasswordChange }; } else { - const token = UserPreferences.getString(`${TOKEN_KEY}-${userId}`); + const token = UserPreferences.getString(getUserTokenKey(server, userId)); if (token) { user = { token }; } diff --git a/ios/Shared/RocketChat/MMKV.swift b/ios/Shared/RocketChat/MMKV.swift index f825b4d2cfc..442199c126b 100644 --- a/ios/Shared/RocketChat/MMKV.swift +++ b/ios/Shared/RocketChat/MMKV.swift @@ -18,11 +18,14 @@ extension MMKVBridge { return MMKVBridge(id: "default", cryptKey: cryptKey, rootPath: mmkvPath) } - func userToken(for userId: String) -> String? { - guard let userToken = string(forKey: "reactnativemeteor_usertoken-\(userId)") else { - return nil + // Server-scoped key: reactnativemeteor_usertoken-{server}-{userId}. Keep in sync with + // getUserTokenKey() (JS) and Ejson.token() (Android); 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 } - return userToken + return string(forKey: "reactnativemeteor_usertoken-\(userId)") } func userId(for server: String) -> String? { diff --git a/ios/Shared/RocketChat/Storage.swift b/ios/Shared/RocketChat/Storage.swift index 63ce28eeef3..db7680169db 100644 --- a/ios/Shared/RocketChat/Storage.swift +++ b/ios/Shared/RocketChat/Storage.swift @@ -12,7 +12,7 @@ final class Storage { // Read credentials from MMKV (shared via app group) // Credentials are stored during login in React Native guard let userId = mmkv.userId(for: server), - let userToken = mmkv.userToken(for: userId) else { + let userToken = mmkv.userToken(for: userId, server: server) else { return nil } return Credentials(userId: userId, userToken: userToken) diff --git a/ios/Watch/WatchConnection.swift b/ios/Watch/WatchConnection.swift index 4e7069fff6e..a2fa0f7863c 100644 --- a/ios/Watch/WatchConnection.swift +++ b/ios/Watch/WatchConnection.swift @@ -41,7 +41,7 @@ final class WatchConnection: NSObject { } let servers = serversQuery.compactMap { item -> WatchMessage.Server? in - guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId) else { + guard let userId = mmkv.userId(for: item.identifier), let userToken = mmkv.userToken(for: userId, server: item.identifier) else { return nil }