Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/containers/LoginServices/serviceLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,17 @@ 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
)}&response_type=code&state=${state}&scope=${encodeURIComponent(scope)}`;
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 }) => {
Expand Down
65 changes: 54 additions & 11 deletions app/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
};
}
}
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a URL matches rocketchat://auth but lacks a credentialToken parameter, it falls through to the standard deep link parsing at line 83, where it will match the auth pattern in the regex. This could lead to unexpected behavior where an invalid OAuth redirect is treated as a standard auth deep link. Consider returning null explicitly after line 79 if the URL starts with rocketchat://auth but doesn't have a valid credentialToken, or add additional validation to prevent this fallthrough scenario.

Suggested change
}
}
// If the URL is an OAuth redirect but lacks a valid credentialToken,
// treat it as invalid and do not fall through to the standard deep link parsing.
return null;

Copilot uses AI. Check for mistakes.
}

// 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);
Expand All @@ -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<typeof AppState.addEventListener>;
private lastAppState: AppStateStatus = AppState.currentState;

constructor(props: any) {
super(props);
Expand All @@ -108,28 +128,51 @@ 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));
}
});
Comment on lines +133 to +138
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event listener returned by Linking.addEventListener is not stored or cleaned up. This creates a memory leak because the listener will persist even after the component unmounts. Store the listener in a class property (similar to dimensionsListener and appStateSubscription) and remove it in componentWillUnmount.

Copilot uses AI. Check for mistakes.

// 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
this.videoConfActionCleanup = setupVideoConfActionListener();
}

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;
};
Comment on lines +158 to +174
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linking.getInitialURL() returns the URL that launched the app on cold start, not the URL from a redirect that occurred while the app was backgrounded. When the app is brought to the foreground from a redirect, the URL event listener (line 133) should already receive the deep link. This logic will always return either null or the original launch URL, making it ineffective for capturing OAuth redirects from Safari. Consider removing this AppState listener entirely, as the URL event listener should handle all incoming deep links including OAuth redirects.

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +174
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Linking.getInitialURL() returns the cold-start URL, not pending background deep links — causes duplicate OAuth dispatches.

getInitialURL() returns the URL that launched the app (cold start). It does not return deep links received while the app is already running in the background — those arrive via the 'url' event listener (line 133). This means:

  1. If the app was cold-started by rocketchat://auth?..., getInitialURL() will return that same URL every foreground transition, dispatching deepLinkingOpen repeatedly.
  2. If the app was started normally, getInitialURL() returns null, so this handler does nothing — the actual OAuth redirect is already handled by the 'url' event listener.

Either way, this handler is either harmful (case 1) or a no-op (case 2).

On iOS, when a custom-scheme URL brings a backgrounded app to foreground, it fires the 'url' event, which is already handled at line 133. Consider removing this AppState listener entirely, or if there's a specific iOS edge case you've observed, track whether the OAuth link has already been processed (e.g., with a "consumed" flag).

Proposed fix — simplest approach: remove the AppState handler
-	private appStateSubscription?: ReturnType<typeof AppState.addEventListener>;
-	private lastAppState: AppStateStatus = AppState.currentState;

 	componentDidMount() {
 		// 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);
 	componentWillUnmount() {
 		this.dimensionsListener?.remove?.();
-		this.appStateSubscription?.remove?.();
 		this.videoConfActionCleanup?.();
-	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;
-	};
🤖 Prompt for AI Agents
In `@app/index.tsx` around lines 158 - 174, The AppState handler
handleAppStateChange incorrectly uses Linking.getInitialURL (which returns the
cold-start URL) and causes duplicate deep link dispatches via deepLinkingOpen;
remove the handleAppStateChange logic (or at minimum the getInitialURL call and
its dispatch) and rely on the existing 'url' event listener that already handles
background-to-foreground deep links, or implement a consumed flag to ensure
OAuth links processed by the 'url' listener are not re-dispatched — update
references to this.lastAppState accordingly and remove any unused handler
registration/unregistration for handleAppStateChange.


init = async () => {
store.dispatch(appInitLocalSettings());

Expand Down
63 changes: 61 additions & 2 deletions app/sagas/deepLinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment on lines +128 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the serverInitAdd function definition and its usages
echo "=== Finding serverInitAdd definition and usages ==="
rg -n 'serverInitAdd' --type=js --type=ts -B2 -A5

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 5172


🏁 Script executed:

#!/bin/bash
# Check the specific file mentioned - app/actions/server.ts around line 103-108
echo "=== Checking app/actions/server.ts ==="
fd -t f 'server.ts' -x cat -n {}

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 23935


🏁 Script executed:

#!/bin/bash
# Check the deepLinking.js file context around lines 100-135
echo "=== Checking app/sagas/deepLinking.js context ==="
fd -t f 'deepLinking.js' -x cat -n {} | sed -n '100,135p'

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1555


Pass the current/previous server to serverInitAdd, not the new OAuth server.

serverInitAdd expects the previously-active server (see app/actions/server.ts line 104–108: serverInitAdd(previousServer: string)). At line 130, server (the OAuth host being connected to) is incorrectly passed. When server comes from the host parameter rather than the CURRENT_SERVER fallback, this stores the wrong value in Redux state. Read the current server separately before the connection attempt:

Proposed fix
+			const previousServer = UserPreferences.getString(CURRENT_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(serverInitAdd(previousServer));
 				yield put(selectServerRequest(server, result.version));
🤖 Prompt for AI Agents
In `@app/sagas/deepLinking.js` around lines 128 - 131, Read and store the
current/previous server value before attempting the OAuth connection and pass
that stored previous server into serverInitAdd rather than the new OAuth host;
specifically, ensure you capture the currently-active server (the value used as
the CURRENT_SERVER fallback) into a variable (e.g., previousServer) before the
connection attempt and call yield put(serverInitAdd(previousServer)) while still
calling yield put(selectServerRequest(server, result.version)) with the new
OAuth server/host.

} else {
yield put(selectServerRequest(server, serverRecord.version));
}
// Wait for the WebSocket connection to be fully ready
yield take(types.METEOR.SUCCESS);
}
Comment on lines +135 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No timeout on yield take(METEOR.SUCCESS) — saga can hang indefinitely.

If the WebSocket connection never succeeds (server unreachable, DNS failure, etc.), this take will block forever, leaving the user stuck with no feedback. Consider using a race with a timeout:

Proposed fix
-			// Wait for the WebSocket connection to be fully ready
-			yield take(types.METEOR.SUCCESS);
+			// Wait for the WebSocket connection to be fully ready (with timeout)
+			const { timeout } = yield race({
+				success: take(types.METEOR.SUCCESS),
+				timeout: delay(15000)
+			});
+			if (timeout) {
+				log(new Error('Timeout waiting for Meteor connection during OAuth'));
+				yield put(appInit());
+				return;
+			}

You'll need to import race from redux-saga/effects (line 1).

🤖 Prompt for AI Agents
In `@app/sagas/deepLinking.js` around lines 135 - 137, The yield
take(types.METEOR.SUCCESS) in the deepLinking saga can block forever; change it
to a race between take(types.METEOR.SUCCESS) and a timeout so the saga can
continue or surface an error if the connection never succeeds. Import race (and
delay or use a configurable TIMEOUT constant) from redux-saga/effects, wrap the
existing take(types.METEOR.SUCCESS) in race, handle the timeout branch by
logging/dispatching a failure or fallback, and keep the success branch behavior
unchanged (refer to the METEOR.SUCCESS wait in deepLinking).


// 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;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry delays are inconsistent: attempt 1 delays 500ms, attempt 2 delays 2000ms (1000 * 2), and attempt 3 delays 3000ms (1000 * 3). This creates an unexpected pattern where the first retry has a much shorter delay than subsequent ones. Consider using a consistent exponential backoff pattern, such as 500 * attempt or 500 * Math.pow(2, attempt - 1), to provide more predictable behavior.

Suggested change
const delayMs = attempt === 1 ? 500 : 1000 * attempt;
const delayMs = 500 * Math.pow(2, attempt - 1);

Copilot uses AI. Check for mistakes.
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;
}
Comment on lines +149 to +156
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Case-sensitive network error check may miss retries for valid errors.

e?.message?.includes('network') is case-sensitive and won't match messages like "Network timeout" or "Network error". The first check (=== 'Network request failed') covers the most common case, but the fallback .includes should use a case-insensitive comparison.

Proposed fix
-				const isNetworkError = e?.message === 'Network request failed' || e?.message?.includes('network');
+				const isNetworkError = e?.message === 'Network request failed' || e?.message?.toLowerCase?.()?.includes?.('network');
🤖 Prompt for AI Agents
In `@app/sagas/deepLinking.js` around lines 149 - 156, The network-error detection
in the catch block sets isNetworkError using a case-sensitive includes check;
change it to perform a case-insensitive check by normalizing the error message
(e.g., toLowerCase()) before calling includes so messages like "Network timeout"
or "Network Error" are detected; update the isNetworkError expression that
references e?.message, leaving the surrounding retry logic (attempt, maxRetries,
continue/throw) and lastError assignment unchanged.

}
if (lastError) {
throw lastError;
}
Comment on lines +158 to +160
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is unreachable. The for loop at line 143 will either return successfully (line 148), throw an error for non-network errors or the final retry (line 155), or continue to the next iteration. Since the loop always either returns or throws by the time it completes all iterations (attempt 3 throws at line 155), execution can never reach lines 158-160. Remove this dead code.

Copilot uses AI. Check for mistakes.
} catch (e) {
log(e);
}
Expand Down
Loading