Skip to content
Draft
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'
};
}
}
}

// 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));
}
});

// 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;
};

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));
} 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);
}
Expand Down
Loading