mattermost-mobile/app/screens/sso/sso_authentication_with_external_browser.tsx
Pablo Vélez 3735aa6ff8
MM-66813 - enhance sso callback metadata (#9422)
* MM-66813 - enhance sso callback metadata

* fix translation order

* Add serverUrl validation, remove non-null assertions
2026-02-16 11:05:31 -05:00

203 lines
7.3 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import qs from 'querystringify';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Linking, Platform, View} from 'react-native';
import urlParse from 'url-parse';
import {Sso} from '@constants';
import {isErrorWithMessage} from '@utils/errors';
import {isBetaApp} from '@utils/general';
import {createSamlChallenge} from '@utils/saml_challenge';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {sanitizeUrl, tryOpenURL} from '@utils/url';
import AuthError from './components/auth_error';
import AuthRedirect from './components/auth_redirect';
import AuthSuccess from './components/auth_success';
interface SSOWithRedirectURLProps {
doSSOLogin: (bearerToken: string, csrfToken: string) => void;
doSSOCodeExchange: (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => void;
loginError: string;
loginUrl: string;
serverUrl: string;
setLoginError: (value: string) => void;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
button: {
marginTop: 25,
},
container: {
flex: 1,
paddingHorizontal: 24,
},
errorText: {
color: changeOpacity(theme.centerChannelColor, 0.72),
textAlign: 'center',
...typography('Body', 200, 'Regular'),
},
infoContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
infoText: {
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 100, 'Regular'),
},
infoTitle: {
color: theme.centerChannelColor,
marginBottom: 4,
...typography('Heading', 700),
},
};
});
const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOWithRedirectURLProps) => {
const [error, setError] = useState<string>('');
const [loginSuccess, setLoginSuccess] = useState(false);
const style = getStyleSheet(theme);
const intl = useIntl();
let customUrlScheme = Sso.REDIRECT_URL_SCHEME;
if (isBetaApp) {
customUrlScheme = Sso.REDIRECT_URL_SCHEME_DEV;
}
const redirectUrl = customUrlScheme + 'callback';
const samlChallenge = useMemo(() => createSamlChallenge(), []);
// Verify that the srv parameter from the callback matches the expected server
const verifyServerOrigin = useCallback((srvParam: string | undefined): boolean => {
if (!srvParam) {
// Old servers don't send srv parameter - allow for backwards compatibility
return true;
}
const normalizedExpected = sanitizeUrl(serverUrl);
const normalizedActual = sanitizeUrl(srvParam);
return normalizedExpected === normalizedActual;
}, [serverUrl]);
const init = useCallback((resetErrors = true) => {
setLoginSuccess(false);
if (resetErrors !== false) {
setError('');
setLoginError('');
}
const parsedUrl = urlParse(loginUrl, true);
const query: Record<string, string> = {
...parsedUrl.query,
redirect_to: redirectUrl,
state: samlChallenge.state,
code_challenge: samlChallenge.codeChallenge,
code_challenge_method: samlChallenge.method,
};
parsedUrl.set('query', qs.stringify(query));
const url = parsedUrl.toString();
const onError = (e: Error) => {
let message;
if (e && Platform.OS === 'android' && isErrorWithMessage(e) && e.message.match(/no activity found to handle intent/i)) {
message = intl.formatMessage({
id: 'mobile.oauth.failed_to_open_link_no_browser',
defaultMessage: 'The link failed to open. Please verify that a browser is installed on the device.',
});
} else {
message = intl.formatMessage({
id: 'mobile.oauth.failed_to_open_link',
defaultMessage: 'The link failed to open. Please try again.',
});
}
setError(
message,
);
};
tryOpenURL(url, onError);
}, [intl, loginUrl, redirectUrl, samlChallenge, setLoginError]);
useEffect(() => {
const startedRef = {current: false};
const onURLChange = ({url}: { url: string }) => {
if (url && url.startsWith(redirectUrl)) {
const parsedUrl = urlParse(url, true);
const srvParam = parsedUrl.query?.srv as string | undefined;
// Verify server origin before accepting credentials
if (!verifyServerOrigin(srvParam)) {
setError(
intl.formatMessage({
id: 'mobile.oauth.server_mismatch',
defaultMessage: 'Login failed: Unable to complete authentication with this server. Please try again.',
}),
);
return;
}
const loginCode = parsedUrl.query?.login_code as string | undefined;
if (loginCode) {
setLoginSuccess(true);
doSSOCodeExchange(loginCode, {codeVerifier: samlChallenge.codeVerifier, state: samlChallenge.state});
return;
}
const bearerToken = parsedUrl.query?.MMAUTHTOKEN;
const csrfToken = parsedUrl.query?.MMCSRF;
if (bearerToken && csrfToken) {
setLoginSuccess(true);
doSSOLogin(bearerToken, csrfToken);
} else {
setError(
intl.formatMessage({
id: 'mobile.oauth.failed_to_login',
defaultMessage: 'Your login attempt failed. Please try again.',
}),
);
}
}
};
const listener = Linking.addEventListener('url', onURLChange);
const timeout = setTimeout(() => {
if (!startedRef.current) {
startedRef.current = true;
init(false);
}
}, 1000);
return () => {
listener.remove();
clearTimeout(timeout);
};
}, [doSSOCodeExchange, doSSOLogin, init, intl, samlChallenge, redirectUrl, verifyServerOrigin]);
let content;
if (loginSuccess) {
content = (<AuthSuccess theme={theme}/>);
} else if (loginError || error) {
content = (
<AuthError
error={loginError || error}
retry={init}
theme={theme}
/>
);
} else {
content = (<AuthRedirect theme={theme}/>);
}
return (
<View
style={style.container}
testID='sso.redirect_url'
>
{content}
</View>
);
};
export default SSOAuthenticationWithExternalBrowser;