From be9e4a290ca502c0e96b26eca25e322950b4290b Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 15 Feb 2026 16:50:36 +0200 Subject: [PATCH 1/3] feat: open Custom OAuth in external browser to support WebAuthn/passkeys Android WebView does not support WebAuthn, preventing users from authenticating with security keys/passkeys through OAuth providers like Keycloak. This changes Custom OAuth to use the device's native browser (via Linking.openURL) instead of the in-app WebView, matching the existing behavior for Google OAuth. The login style is changed from 'popup' to 'redirect' so the Rocket.Chat server generates a rocketchat://auth redirect URL that brings the user back to the app after authentication. Closes #5681 Co-Authored-By: Claude Opus 4.6 --- app/containers/LoginServices/serviceLogin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/containers/LoginServices/serviceLogin.ts b/app/containers/LoginServices/serviceLogin.ts index bf681fb8fe8..9e3ae1ba14d 100644 --- a/app/containers/LoginServices/serviceLogin.ts +++ b/app/containers/LoginServices/serviceLogin.ts @@ -98,7 +98,8 @@ export const onPressCustomOAuth = ({ loginService, server }: { loginService: IIt logEvent(events.ENTER_WITH_CUSTOM_OAUTH); const { serverURL, authorizePath, clientId, scope, service } = loginService; const redirectUri = `${server}/_oauth/${service}`; - const state = getOAuthState(); + // Use 'redirect' login style to open in external browser (supports WebAuthn/passkeys) + const state = getOAuthState('redirect'); const separator = authorizePath.indexOf('?') !== -1 ? '&' : '?'; const params = `${separator}client_id=${clientId}&redirect_uri=${encodeURIComponent( redirectUri @@ -106,7 +107,8 @@ export const onPressCustomOAuth = ({ loginService, server }: { loginService: IIt const domain = `${serverURL}`; const absolutePath = `${authorizePath}${params}`; const url = absolutePath.includes(domain) ? absolutePath : domain + absolutePath; - openOAuth({ url }); + // Open in external browser instead of in-app WebView to support WebAuthn/passkeys + Linking.openURL(url); }; export const onPressSaml = ({ loginService, server }: { loginService: IItemService; server: string }) => { From 01b5fd9f6a742280c56c13864f61cf7743059bfa Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 15 Feb 2026 16:50:43 +0200 Subject: [PATCH 2/3] feat: handle OAuth redirect from external browser in deep linking When OAuth authentication completes in the external browser, the Rocket.Chat server redirects to rocketchat://auth with credential tokens. This adds handling for that redirect URL in parseDeepLinking, setting type to 'oauth' so the deep linking saga can complete the login flow. Also removes the 5-second delay on the deep link listener so OAuth redirects are processed immediately, and adds an AppState listener to catch pending OAuth deep links when the app returns to foreground (needed on iOS where Safari redirects may arrive while app is backgrounded). Co-Authored-By: Claude Opus 4.6 --- app/index.tsx | 65 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 88f8347445f..c567f756959 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Dimensions, type EmitterSubscription, Linking } from 'react-native'; +import { AppState, Dimensions, type EmitterSubscription, Linking, type AppStateStatus } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { enableScreens } from 'react-native-screens'; @@ -61,6 +61,25 @@ interface IState { const parseDeepLinking = (url: string) => { if (url) { + // Handle OAuth redirect from external browser (rocketchat://auth?...) + // The Rocket.Chat server redirects to 'rocketchat://auth' after OAuth login + // when using the external browser flow (required for WebAuthn/passkeys) + if (url.startsWith('rocketchat://auth')) { + const authUrl = url.replace(/rocketchat:\/\//, ''); + const authMatch = authUrl.match(/^auth\?(.+)/); + if (authMatch) { + const query = authMatch[1]; + const parsedQuery = parseQuery(query); + if (parsedQuery?.credentialToken) { + return { + ...parsedQuery, + type: 'oauth' + }; + } + } + } + + // Handle standard deep links (rocketchat:// and https://go.rocket.chat/) url = url.replace(/rocketchat:\/\/|https:\/\/go.rocket.chat\//, ''); const regex = /^(room|auth|invite|shareextension)\?/; const match = url.match(regex); @@ -83,9 +102,10 @@ const parseDeepLinking = (url: string) => { }; export default class Root extends React.Component<{}, IState> { - private listenerTimeout!: any; private dimensionsListener?: EmitterSubscription; private videoConfActionCleanup?: () => void; + private appStateSubscription?: ReturnType; + private lastAppState: AppStateStatus = AppState.currentState; constructor(props: any) { super(props); @@ -108,14 +128,19 @@ export default class Root extends React.Component<{}, IState> { } componentDidMount() { - this.listenerTimeout = setTimeout(() => { - Linking.addEventListener('url', ({ url }) => { - const parsedDeepLinkingURL = parseDeepLinking(url); - if (parsedDeepLinkingURL) { - store.dispatch(deepLinkingOpen(parsedDeepLinkingURL)); - } - }); - }, 5000); + // Set up deep link listener immediately (no delay) so OAuth redirects + // from external browser are handled promptly + Linking.addEventListener('url', ({ url }) => { + const parsedDeepLinkingURL = parseDeepLinking(url); + if (parsedDeepLinkingURL) { + store.dispatch(deepLinkingOpen(parsedDeepLinkingURL)); + } + }); + + // Handle app returning to foreground - check for pending OAuth deep links + // This is needed on iOS where Safari redirects may arrive while app is backgrounded + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); + this.dimensionsListener = Dimensions.addEventListener('change', this.onDimensionsChange); // Set up video conf action listener for background accept/decline @@ -123,13 +148,31 @@ export default class Root extends React.Component<{}, IState> { } componentWillUnmount() { - clearTimeout(this.listenerTimeout); this.dimensionsListener?.remove?.(); + this.appStateSubscription?.remove?.(); this.videoConfActionCleanup?.(); unsubscribeTheme(); } + handleAppStateChange = async (nextAppState: AppStateStatus) => { + // When app comes to foreground from background, check for pending deep links + if (this.lastAppState.match(/inactive|background/) && nextAppState === 'active') { + try { + const url = await Linking.getInitialURL(); + if (url && url.startsWith('rocketchat://auth')) { + const parsedDeepLinkingURL = parseDeepLinking(url); + if (parsedDeepLinkingURL) { + store.dispatch(deepLinkingOpen(parsedDeepLinkingURL)); + } + } + } catch (e) { + // Ignore errors checking for pending deep links + } + } + this.lastAppState = nextAppState; + }; + init = async () => { store.dispatch(appInitLocalSettings()); From 3a79e75d42ae15a75c901d28eddb05e3989f658f Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 15 Feb 2026 16:50:48 +0200 Subject: [PATCH 3/3] feat: ensure server connection before completing external browser OAuth When the app receives an OAuth callback from the external browser, the SDK WebSocket connection may not be ready yet. This updates handleOAuth to verify the server connection, establish it if needed (waiting for METEOR.SUCCESS), and retry the login with backoff on transient network errors. This is necessary because unlike the in-app WebView flow where the SDK stays connected, the external browser flow may cause the app to be backgrounded and lose its WebSocket connection. Co-Authored-By: Claude Opus 4.6 --- app/sagas/deepLinking.js | 63 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 7afcadc7b92..2ad78b7a3d8 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -96,9 +96,68 @@ const fallbackNavigation = function* fallbackNavigation() { }; const handleOAuth = function* handleOAuth({ params }) { - const { credentialToken, credentialSecret } = params; + const { credentialToken, credentialSecret, host } = params; try { - yield loginOAuthOrSso({ oauth: { credentialToken, credentialSecret } }, false); + // When OAuth completes via external browser redirect, the SDK connection + // may not be ready yet. We need to ensure the server is connected before + // attempting to complete the login. + let server = host; + if (server && server.endsWith('/')) { + server = server.slice(0, -1); + } + if (!server) { + server = UserPreferences.getString(CURRENT_SERVER); + } + + if (!server) { + yield put(appInit()); + return; + } + + // Check if SDK is connected to this server and the WebSocket is ready + const sdkHost = sdk.current?.client?.host; + const meteorConnected = yield select(state => state.meteor.connected); + + if (!meteorConnected || !sdkHost || sdkHost !== server) { + const serverRecord = yield getServerById(server); + if (!serverRecord) { + // Server not in database yet, need to add it first + const result = yield getServerInfo(server); + if (!result.success) { + yield put(appInit()); + return; + } + yield put(serverInitAdd(server)); + yield put(selectServerRequest(server, result.version)); + } else { + yield put(selectServerRequest(server, serverRecord.version)); + } + // Wait for the WebSocket connection to be fully ready + yield take(types.METEOR.SUCCESS); + } + + // Retry logic for OAuth login - the external browser flow can have timing + // issues where the SDK is not fully ready even after METEOR.SUCCESS + const maxRetries = 3; + let lastError; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const delayMs = attempt === 1 ? 500 : 1000 * attempt; + yield delay(delayMs); + yield loginOAuthOrSso({ oauth: { credentialToken, credentialSecret } }, false); + return; + } catch (e) { + lastError = e; + const isNetworkError = e?.message === 'Network request failed' || e?.message?.includes('network'); + if (attempt < maxRetries && isNetworkError) { + continue; + } + throw e; + } + } + if (lastError) { + throw lastError; + } } catch (e) { log(e); }