Skip to content

Commit 3549de6

Browse files
feat: open browser login providers in a system auth session for WebAuthn support (#7419)
Co-authored-by: Lucas Machado <lucas@demola.net>
1 parent a8c4899 commit 3549de6

7 files changed

Lines changed: 184 additions & 51 deletions

File tree

app/containers/LoginServices/interfaces.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { type TIconsName } from '../CustomIcon';
66
type TAuthType = 'oauth' | 'oauth_custom' | 'saml' | 'cas' | 'apple';
77

88
type TServiceName = 'facebook' | 'github' | 'gitlab' | 'google' | 'linkedin' | 'meteor-developer' | 'twitter' | 'wordpress';
9-
export interface IOpenOAuth {
9+
export interface IOpenSSOWebView {
1010
url: string;
11-
ssoToken?: string;
12-
authType?: TAuthType;
11+
ssoToken: string;
12+
authType: 'saml' | 'cas';
1313
}
1414

1515
export interface IItemService {

app/containers/LoginServices/serviceLogin.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import * as AppleAuthentication from 'expo-apple-authentication';
2-
import { Linking } from 'react-native';
2+
import * as WebBrowser from 'expo-web-browser';
33
import { Base64 } from 'js-base64';
44

55
import Navigation from '../../lib/navigation/appNavigation';
6-
import { type IItemService, type IOpenOAuth, type IServiceLogin } from './interfaces';
6+
import { type IItemService, type IOpenSSOWebView, type IServiceLogin } from './interfaces';
77
import { random } from '../../lib/methods/helpers';
88
import { loginOAuthOrSso } from '../../lib/services/connect';
9-
import { events, logEvent } from '../../lib/methods/helpers/log';
9+
import log, { events, logEvent } from '../../lib/methods/helpers/log';
10+
import { store } from '../../lib/store/auxStore';
11+
import { deepLinkingOpen } from '../../actions/deepLinking';
12+
import parseDeepLinking from '../../lib/methods/helpers/parseDeepLinking';
1013

1114
type TLoginStyle = 'popup' | 'redirect';
1215

@@ -16,9 +19,9 @@ export const onPressFacebook = ({ service, server }: IServiceLogin) => {
1619
const endpoint = 'https://m.facebook.com/v2.9/dialog/oauth';
1720
const redirect_uri = `${server}/_oauth/facebook?close`;
1821
const scope = 'email';
19-
const state = getOAuthState();
22+
const state = getOAuthState('redirect');
2023
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}&display=touch`;
21-
openOAuth({ url: `${endpoint}${params}` });
24+
openOAuthSession(`${endpoint}${params}`);
2225
};
2326

2427
export const onPressGithub = ({ service, server }: IServiceLogin) => {
@@ -27,9 +30,9 @@ export const onPressGithub = ({ service, server }: IServiceLogin) => {
2730
const endpoint = `https://github.com/login?client_id=${clientId}&return_to=${encodeURIComponent('/login/oauth/authorize')}`;
2831
const redirect_uri = `${server}/_oauth/github?close`;
2932
const scope = 'user:email';
30-
const state = getOAuthState();
33+
const state = getOAuthState('redirect');
3134
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}`;
32-
openOAuth({ url: `${endpoint}${encodeURIComponent(params)}` });
35+
openOAuthSession(`${endpoint}${encodeURIComponent(params)}`);
3336
};
3437

3538
export const onPressGitlab = ({ service, server, urlOption }: IServiceLogin) => {
@@ -39,9 +42,9 @@ export const onPressGitlab = ({ service, server, urlOption }: IServiceLogin) =>
3942
const endpoint = `${baseURL}/oauth/authorize`;
4043
const redirect_uri = `${server}/_oauth/gitlab?close`;
4144
const scope = 'read_user';
42-
const state = getOAuthState();
45+
const state = getOAuthState('redirect');
4346
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}&response_type=code`;
44-
openOAuth({ url: `${endpoint}${params}` });
47+
openOAuthSession(`${endpoint}${params}`);
4548
};
4649

4750
export const onPressGoogle = ({ service, server }: IServiceLogin) => {
@@ -52,7 +55,7 @@ export const onPressGoogle = ({ service, server }: IServiceLogin) => {
5255
const scope = encodeURIComponent('profile email');
5356
const state = getOAuthState('redirect');
5457
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}&response_type=code`;
55-
Linking.openURL(`${endpoint}${params}`);
58+
openOAuthSession(`${endpoint}${params}`);
5659
};
5760

5861
export const onPressLinkedin = ({ service, server }: IServiceLogin) => {
@@ -61,26 +64,26 @@ export const onPressLinkedin = ({ service, server }: IServiceLogin) => {
6164
const endpoint = 'https://www.linkedin.com/oauth/v2/authorization';
6265
const redirect_uri = `${server}/_oauth/linkedin?close`;
6366
const scope = 'r_liteprofile,r_emailaddress';
64-
const state = getOAuthState();
67+
const state = getOAuthState('redirect');
6568
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}&response_type=code`;
66-
openOAuth({ url: `${endpoint}${params}` });
69+
openOAuthSession(`${endpoint}${params}`);
6770
};
6871

6972
export const onPressMeteor = ({ service, server }: IServiceLogin) => {
7073
logEvent(events.ENTER_WITH_METEOR);
7174
const { clientId } = service;
7275
const endpoint = 'https://www.meteor.com/oauth2/authorize';
7376
const redirect_uri = `${server}/_oauth/meteor-developer`;
74-
const state = getOAuthState();
77+
const state = getOAuthState('redirect');
7578
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&state=${state}&response_type=code`;
76-
openOAuth({ url: `${endpoint}${params}` });
79+
openOAuthSession(`${endpoint}${params}`);
7780
};
7881

7982
export const onPressTwitter = ({ server }: IServiceLogin) => {
8083
logEvent(events.ENTER_WITH_TWITTER);
81-
const state = getOAuthState();
84+
const state = getOAuthState('redirect');
8285
const url = `${server}/_oauth/twitter/?requestTokenAndRedirect=true&state=${state}`;
83-
openOAuth({ url });
86+
openOAuthSession(url);
8487
};
8588

8689
export const onPressWordpress = ({ service, server }: IServiceLogin) => {
@@ -89,24 +92,24 @@ export const onPressWordpress = ({ service, server }: IServiceLogin) => {
8992
const endpoint = `${serverURL}/oauth/authorize`;
9093
const redirect_uri = `${server}/_oauth/wordpress?close`;
9194
const scope = 'openid';
92-
const state = getOAuthState();
95+
const state = getOAuthState('redirect');
9396
const params = `?client_id=${clientId}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}&response_type=code`;
94-
openOAuth({ url: `${endpoint}${params}` });
97+
openOAuthSession(`${endpoint}${params}`);
9598
};
9699

97100
export const onPressCustomOAuth = ({ loginService, server }: { loginService: IItemService; server: string }) => {
98101
logEvent(events.ENTER_WITH_CUSTOM_OAUTH);
99102
const { serverURL, authorizePath, clientId, scope, service } = loginService;
100103
const redirectUri = `${server}/_oauth/${service}`;
101-
const state = getOAuthState();
104+
const state = getOAuthState('redirect');
102105
const separator = authorizePath.indexOf('?') !== -1 ? '&' : '?';
103106
const params = `${separator}client_id=${clientId}&redirect_uri=${encodeURIComponent(
104107
redirectUri
105108
)}&response_type=code&state=${state}&scope=${encodeURIComponent(scope)}`;
106109
const domain = `${serverURL}`;
107110
const absolutePath = `${authorizePath}${params}`;
108111
const url = absolutePath.includes(domain) ? absolutePath : domain + absolutePath;
109-
openOAuth({ url });
112+
openOAuthSession(url);
110113
};
111114

112115
export const onPressSaml = ({ loginService, server }: { loginService: IItemService; server: string }) => {
@@ -115,14 +118,14 @@ export const onPressSaml = ({ loginService, server }: { loginService: IItemServi
115118
const { provider } = clientConfig;
116119
const ssoToken = random(17);
117120
const url = `${server}/_saml/authorize/${provider}/${ssoToken}`;
118-
openOAuth({ url, ssoToken, authType: 'saml' });
121+
openSSOWebView({ url, ssoToken, authType: 'saml' });
119122
};
120123

121124
export const onPressCas = ({ casLoginUrl, server }: { casLoginUrl: string; server: string }) => {
122125
logEvent(events.ENTER_WITH_CAS);
123126
const ssoToken = random(17);
124127
const url = `${casLoginUrl}?service=${server}/_cas/${ssoToken}`;
125-
openOAuth({ url, ssoToken, authType: 'cas' });
128+
openSSOWebView({ url, ssoToken, authType: 'cas' });
126129
};
127130

128131
export const onPressAppleLogin = async () => {
@@ -140,6 +143,22 @@ export const onPressAppleLogin = async () => {
140143
}
141144
};
142145

146+
const OAUTH_REDIRECT_URL = 'rocketchat://auth';
147+
148+
const openOAuthSession = async (url: string) => {
149+
try {
150+
const result = await WebBrowser.openAuthSessionAsync(url, OAUTH_REDIRECT_URL);
151+
if (result.type === 'success' && 'url' in result && result.url) {
152+
const parsed = parseDeepLinking(result.url);
153+
if (parsed) {
154+
store.dispatch(deepLinkingOpen(parsed));
155+
}
156+
}
157+
} catch (e) {
158+
log(e);
159+
}
160+
};
161+
143162
const getOAuthState = (loginStyle: TLoginStyle = 'popup') => {
144163
const credentialToken = random(43);
145164
let obj: {
@@ -157,6 +176,6 @@ const getOAuthState = (loginStyle: TLoginStyle = 'popup') => {
157176
return Base64.encodeURI(JSON.stringify(obj));
158177
};
159178

160-
const openOAuth = ({ url, ssoToken, authType = 'oauth' }: IOpenOAuth) => {
179+
const openSSOWebView = ({ url, ssoToken, authType }: IOpenSSOWebView) => {
161180
Navigation.navigate('AuthenticationWebView', { url, authType, ssoToken });
162181
};

app/index.tsx

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { type IThemePreference } from './definitions/ITheme';
2121
import { themes } from './lib/constants/colors';
2222
import { getAllowAnalyticsEvents, getAllowCrashReport } from './lib/methods/crashReport';
2323
import { toggleAnalyticsEventsReport, toggleCrashErrorsReport } from './lib/methods/helpers/log';
24-
import parseQuery from './lib/methods/helpers/parseQuery';
24+
import parseDeepLinking from './lib/methods/helpers/parseDeepLinking';
2525
import {
2626
getTheme,
2727
initialTheme,
@@ -51,29 +51,6 @@ interface IState {
5151
themePreferences: IThemePreference;
5252
}
5353

54-
const parseDeepLinking = (url: string) => {
55-
if (url) {
56-
url = url.replace(/rocketchat:\/\/|https:\/\/go.rocket.chat\//, '');
57-
const regex = /^(room|auth|invite|shareextension)\?/;
58-
const match = url.match(regex);
59-
if (match) {
60-
const matchedPattern = match[1];
61-
const query = url.replace(regex, '').trim();
62-
63-
if (query) {
64-
const parsedQuery = parseQuery(query);
65-
return {
66-
...parsedQuery,
67-
type: matchedPattern === 'shareextension' ? matchedPattern : parsedQuery?.type
68-
};
69-
}
70-
}
71-
}
72-
73-
// Return null if the URL doesn't match or is not valid
74-
return null;
75-
};
76-
7754
export default class Root extends Component<{}, IState> {
7855
private listenerTimeout!: any;
7956
private videoConfActionCleanup?: () => void;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import parseDeepLinking from '../parseDeepLinking';
2+
3+
describe('parseDeepLinking', () => {
4+
it('parses an OAuth redirect URL', () => {
5+
const result = parseDeepLinking(
6+
'rocketchat://auth?host=https://open.rocket.chat&type=oauth&credentialToken=abc&credentialSecret=def'
7+
);
8+
expect(result).toMatchObject({
9+
type: 'oauth',
10+
host: 'https://open.rocket.chat',
11+
credentialToken: 'abc',
12+
credentialSecret: 'def'
13+
});
14+
});
15+
16+
it('parses a room URL', () => {
17+
const result = parseDeepLinking('rocketchat://room?host=open.rocket.chat&rid=GENERAL&path=channel/general');
18+
expect(result).toMatchObject({ host: 'open.rocket.chat', rid: 'GENERAL' });
19+
});
20+
21+
it('parses an invite URL', () => {
22+
const result = parseDeepLinking('rocketchat://invite?host=open.rocket.chat&path=invite/token123');
23+
expect(result).toMatchObject({ host: 'open.rocket.chat', path: 'invite/token123' });
24+
});
25+
26+
it('parses a shareextension URL and sets type to shareextension', () => {
27+
const result = parseDeepLinking('rocketchat://shareextension?text=hello');
28+
expect(result).toMatchObject({ type: 'shareextension' });
29+
});
30+
31+
it('returns null for a non-matching URL', () => {
32+
expect(parseDeepLinking('https://example.com/some/path')).toBeNull();
33+
});
34+
35+
it('returns null for an empty string', () => {
36+
expect(parseDeepLinking('')).toBeNull();
37+
});
38+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import parseQuery from './parseQuery';
2+
3+
const parseDeepLinking = (url: string) => {
4+
if (url) {
5+
url = url.replace(/rocketchat:\/\/|https:\/\/go.rocket.chat\//, '');
6+
const regex = /^(room|auth|invite|shareextension)\?/;
7+
const match = url.match(regex);
8+
if (match) {
9+
const matchedPattern = match[1];
10+
const query = url.replace(regex, '').trim();
11+
12+
if (query) {
13+
const parsedQuery = parseQuery(query);
14+
return {
15+
...parsedQuery,
16+
type: matchedPattern === 'shareextension' ? matchedPattern : parsedQuery?.type
17+
};
18+
}
19+
}
20+
}
21+
22+
return null;
23+
};
24+
25+
export default parseDeepLinking;

app/sagas/__tests__/deepLinking.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { canOpenRoom } from '../../lib/methods/canOpenRoom';
110110
import { getServerInfo } from '../../lib/methods/getServerInfo';
111111
import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom';
112112
import { waitForNavigationReady } from '../../lib/navigation/appNavigation';
113+
import { loginOAuthOrSso } from '../../lib/services/connect';
113114
import sdk from '../../lib/services/sdk';
114115
import EventEmitter from '../../lib/methods/helpers/events';
115116
import database from '../../lib/database';
@@ -659,3 +660,70 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro
659660
expect(loginRequested()).toBe(true);
660661
});
661662
});
663+
664+
// ─── handleOAuth — single-use credentialToken dedup guard ────────────────────
665+
666+
describe('deepLinking saga — handleOAuth dedup guard', () => {
667+
// handleOAuth tracks the consumed credentialToken in module scope and it is never reset between
668+
// tests, so every oauth case here must use a globally-unique token or the guard silently suppresses it.
669+
beforeEach(() => {
670+
jest.mocked(loginOAuthOrSso).mockReset();
671+
jest.mocked(loginOAuthOrSso).mockResolvedValue(undefined as any);
672+
});
673+
674+
it('calls loginOAuthOrSso with the oauth credentials on a fresh token', async () => {
675+
const store = setupStore();
676+
677+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-fresh-A', credentialSecret: 'secret-A' } as any));
678+
await flushSagaMicrotasks();
679+
await flushSagaMicrotasks();
680+
681+
expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1);
682+
expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledWith({
683+
oauth: { credentialToken: 'token-fresh-A', credentialSecret: 'secret-A' }
684+
});
685+
});
686+
687+
it('does not call loginOAuthOrSso when the credentialSecret is missing', async () => {
688+
const store = setupStore();
689+
690+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-no-secret-D' } as any));
691+
await flushSagaMicrotasks();
692+
await flushSagaMicrotasks();
693+
694+
expect(jest.mocked(loginOAuthOrSso)).not.toHaveBeenCalled();
695+
});
696+
697+
it('does not call loginOAuthOrSso a second time for the same credentialToken', async () => {
698+
const store = setupStore();
699+
700+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-dup-B', credentialSecret: 'secret-B' } as any));
701+
await flushSagaMicrotasks();
702+
await flushSagaMicrotasks();
703+
704+
// Second dispatch with the identical token — guard must suppress it.
705+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-dup-B', credentialSecret: 'secret-B' } as any));
706+
await flushSagaMicrotasks();
707+
await flushSagaMicrotasks();
708+
709+
expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1);
710+
});
711+
712+
it('calls loginOAuthOrSso again for a different credentialToken after a previous one was consumed', async () => {
713+
const store = setupStore();
714+
715+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-first-C', credentialSecret: 'secret-C' } as any));
716+
await flushSagaMicrotasks();
717+
await flushSagaMicrotasks();
718+
719+
// A distinct token must not be blocked by the guard.
720+
store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-second-C', credentialSecret: 'secret-C2' } as any));
721+
await flushSagaMicrotasks();
722+
await flushSagaMicrotasks();
723+
724+
expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(2);
725+
expect(jest.mocked(loginOAuthOrSso)).toHaveBeenNthCalledWith(2, {
726+
oauth: { credentialToken: 'token-second-C', credentialSecret: 'secret-C2' }
727+
});
728+
});
729+
});

app/sagas/deepLinking.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,16 @@ const fallbackNavigation = function* fallbackNavigation() {
128128
yield put(appInit());
129129
};
130130

131+
let consumedOAuthToken;
132+
131133
const handleOAuth = function* handleOAuth({ params }) {
132134
const { credentialToken, credentialSecret } = params;
135+
if (!credentialToken || !credentialSecret || credentialToken === consumedOAuthToken) {
136+
return;
137+
}
138+
consumedOAuthToken = credentialToken;
133139
try {
134-
yield loginOAuthOrSso({ oauth: { credentialToken, credentialSecret } }, false);
140+
yield loginOAuthOrSso({ oauth: { credentialToken, credentialSecret } });
135141
} catch (e) {
136142
log(e);
137143
}

0 commit comments

Comments
 (0)