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 }) => { 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()); 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); }