-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: open Custom OAuth in external browser to support WebAuthn/passkeys #6987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
be9e4a2
01b5fd9
3a79e75
90357fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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<typeof AppState.addEventListener>; | ||
| private lastAppState: AppStateStatus = AppState.currentState; | ||
|
|
||
| constructor(props: any) { | ||
| super(props); | ||
|
|
@@ -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
|
||
|
|
||
| // 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
|
||
|
|
||
| init = async () => { | ||
| store.dispatch(appInitLocalSettings()); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -A5Repository: 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
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 |
||||||
| } 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No timeout on If the WebSocket connection never succeeds (server unreachable, DNS failure, etc.), this 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 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| // 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; | ||||||
|
||||||
| const delayMs = attempt === 1 ? 500 : 1000 * attempt; | |
| const delayMs = 500 * Math.pow(2, attempt - 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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://authbut lacks acredentialTokenparameter, it falls through to the standard deep link parsing at line 83, where it will match theauthpattern in the regex. This could lead to unexpected behavior where an invalid OAuth redirect is treated as a standard auth deep link. Consider returningnullexplicitly after line 79 if the URL starts withrocketchat://authbut doesn't have a valid credentialToken, or add additional validation to prevent this fallthrough scenario.