Skip to content
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

Use POST for SAML login #52658

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f5ed242
use fetch instead of simply window.location
NikkiWines Nov 16, 2024
67148f6
update to use withOnyx, updated props
NikkiWines Nov 16, 2024
8125593
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Nov 20, 2024
abea27c
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Nov 21, 2024
6e71e1a
minor style
NikkiWines Nov 21, 2024
193e333
introduce post for SAML native
NikkiWines Nov 21, 2024
5354911
style
NikkiWines Nov 21, 2024
488e69e
dry up saml url logic
NikkiWines Nov 22, 2024
2326cd0
style
NikkiWines Nov 22, 2024
1caea73
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Nov 27, 2024
e81cd71
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Nov 28, 2024
9d63639
update SAML native logic to handle errors
NikkiWines Nov 28, 2024
455fb47
fix error handling for signing in with short lived authtoken
NikkiWines Nov 28, 2024
3f678b6
minor style
NikkiWines Nov 28, 2024
4d57937
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Nov 28, 2024
72d735d
Add clarification
NikkiWines Nov 29, 2024
fc12c62
return early with clean session if no login
NikkiWines Nov 29, 2024
d60395c
prettier
NikkiWines Nov 29, 2024
103f0b6
Merge branch 'nikki-saml-use-post' of github.com:Expensify/App into n…
NikkiWines Nov 29, 2024
cc16359
add copy
NikkiWines Nov 30, 2024
01dd6ff
add handleError function
NikkiWines Nov 30, 2024
9312a29
style
NikkiWines Nov 30, 2024
8f1cb9f
linting changes
NikkiWines Dec 2, 2024
2d614f7
error handling
NikkiWines Dec 2, 2024
d04d6db
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Dec 11, 2024
75aa238
remove unneeded url clean
NikkiWines Dec 11, 2024
ccc87c4
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Dec 19, 2024
c366857
dry handle error functionality and rename function
NikkiWines Dec 19, 2024
50f47d1
remove noop
NikkiWines Dec 20, 2024
bd30dd4
fix imports
NikkiWines Dec 20, 2024
4ce67c8
add back navigation import
NikkiWines Dec 20, 2024
9dc8399
fix import and show loading screen
NikkiWines Dec 21, 2024
73e8164
fix double negation
NikkiWines Dec 21, 2024
fa294e1
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Jan 6, 2025
fb2acc8
Merge branch 'main' of github.com:Expensify/App into nikki-saml-use-post
NikkiWines Jan 10, 2025
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
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ const translations = {
invalidRateError: 'Please enter a valid rate.',
lowRateError: 'Rate must be greater than 0.',
email: 'Please enter a valid email address.',
login: 'An error occurred while logging in. Please try again.',
},
comma: 'comma',
semicolon: 'semicolon',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ const translations = {
invalidRateError: 'Por favor, introduce una tarifa válida.',
lowRateError: 'La tarifa debe ser mayor que 0.',
email: 'Por favor, introduzca una dirección de correo electrónico válida.',
login: 'Se produjo un error al iniciar sesión. Por favor intente nuevamente.',
},
comma: 'la coma',
semicolon: 'el punto y coma',
Expand Down
28 changes: 27 additions & 1 deletion src/libs/LoginUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {PUBLIC_DOMAINS, Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import * as Session from './actions/Session';
import Navigation from './Navigation/Navigation';
import {parsePhoneNumber} from './PhoneNumber';

let countryCodeByIP: number;
Expand Down Expand Up @@ -75,4 +79,26 @@ function areEmailsFromSamePrivateDomain(email1: string, email2: string): boolean
return Str.extractEmailDomain(email1).toLowerCase() === Str.extractEmailDomain(email2).toLowerCase();
}

export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain};
function postSAMLLogin(body: FormData): Promise<Response | void> {
return fetch(CONFIG.EXPENSIFY.SAML_URL, {
method: CONST.NETWORK.METHOD.POST,
body,
credentials: 'omit',
Copy link
Contributor

Choose a reason for hiding this comment

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

Just curious - why are we setting credentials:omit here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Following the convention used here - I can test without it but figured it was safest to go with logic we already knew worked.

}).then((response) => {
if (!response.ok) {
throw new Error('An error occurred while logging in. Please try again');
}
return response.json() as Promise<Response>;
});
}

function handleSAMLLoginError(errorMessage: string, cleanSignInData: boolean) {
if (cleanSignInData) {
Session.clearSignInData();
}

Session.setAccountError(errorMessage);
Navigation.goBack(ROUTES.HOME);
}

export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain, postSAMLLogin, handleSAMLLoginError};
2 changes: 2 additions & 0 deletions src/pages/LogInWithShortLivedAuthTokenPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP
// For HybridApp we have separate logic to handle transitions.
if (!NativeModules.HybridAppModule && exitTo) {
Navigation.isNavigationReady().then(() => {
// We must call goBack() to remove the /transition route from history
Navigation.goBack();
Navigation.navigate(exitTo as Route);
});
}
Expand Down
66 changes: 54 additions & 12 deletions src/pages/signin/SAMLSignInPage/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, {useCallback, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import WebView from 'react-native-webview';
import type {WebViewNativeEvent} from 'react-native-webview/lib/WebViewTypes';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
import getPlatform from '@libs/getPlatform';
import getUAForWebView from '@libs/getUAForWebView';
import Log from '@libs/Log';
import {handleSAMLLoginError, postSAMLLogin} from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
Expand All @@ -17,15 +20,45 @@ import ROUTES from '@src/ROUTES';
function SAMLSignInPage() {
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS);
const samlLoginURL = `${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials?.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}&platform=${getPlatform()}`;
const [showNavigation, shouldShowNavigation] = useState(true);
const [SAMLUrl, setSAMLUrl] = useState('');
const webViewRef = useRef<WebView>(null);
const {translate} = useLocalize();
Copy link
Member

Choose a reason for hiding this comment

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

I didn't get the use of this ref.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't sure about it either but I saw the same usage for multiple other WebViews (see: ConnectToQuickbookOnlineFlow, ConnectToXeroFlow, WalletStatementModal) and figured it was wise to follow that existing convention


useEffect(() => {
// If we don't have a valid login to pass here, direct the user back to a clean sign in state to try again
if (!credentials?.login) {
handleSAMLLoginError(translate('common.error.email'), true);
return;
}

// If we've already gotten a url back to log into the user's Identity Provider (IdP), then don't re-fetch it
if (SAMLUrl) {
return;
}

const body = new FormData();
body.append('email', credentials.login);
body.append('referer', CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER);
body.append('platform', getPlatform());
postSAMLLogin(body)
.then((response) => {
if (!response || !response.url) {
handleSAMLLoginError(translate('common.error.login'), false);
return;
}
setSAMLUrl(response.url);
})
.catch((error: Error) => {
handleSAMLLoginError(error.message ?? translate('common.error.login'), false);
});
}, [credentials?.login, SAMLUrl, translate]);

/**
* Handles in-app navigation once we get a response back from Expensify
*/
const handleNavigationStateChange = useCallback(
({url}: WebViewNativeEvent) => {
Log.info('SAMLSignInPage - Handling SAML navigation change');
// If we've gotten a callback then remove the option to navigate back to the sign-in page
if (url.includes('loginCallback')) {
shouldShowNavigation(false);
Expand All @@ -42,7 +75,12 @@ function SAMLSignInPage() {
if (searchParams.has('error')) {
Session.clearSignInData();
Session.setAccountError(searchParams.get('error') ?? '');
Navigation.navigate(ROUTES.HOME);

Navigation.isNavigationReady().then(() => {
// We must call goBack() to remove the /transition route from history
Navigation.goBack();
Copy link
Contributor

Choose a reason for hiding this comment

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

Just curious - where is this transition route set? Is it only from OldDot -> NewDot?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah OldDot -> NewDot - here

Navigation.navigate(ROUTES.HOME);
});
}
},
[credentials?.login, shouldShowNavigation, account?.isLoading],
Expand All @@ -66,14 +104,18 @@ function SAMLSignInPage() {
/>
)}
<FullPageOfflineBlockingView>
<WebView
originWhitelist={['https://*']}
source={{uri: samlLoginURL}}
incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352
startInLoadingState
renderLoading={() => <SAMLLoadingIndicator />}
onNavigationStateChange={handleNavigationStateChange}
/>
{!!SAMLUrl && (
<WebView
Copy link
Member

Choose a reason for hiding this comment

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

Should we show the loader until we have the url?

ref={webViewRef}
originWhitelist={['https://*']}
source={{uri: SAMLUrl}}
userAgent={getUAForWebView()}
incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352
startInLoadingState
renderLoading={() => <SAMLLoadingIndicator />}
onNavigationStateChange={handleNavigationStateChange}
/>
)}
</FullPageOfflineBlockingView>
</ScreenWrapper>
);
Expand Down
39 changes: 30 additions & 9 deletions src/pages/signin/SAMLSignInPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
import React, {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator';
import useLocalize from '@hooks/useLocalize';
import {handleSAMLLoginError, postSAMLLogin} from '@libs/LoginUtils';
Copy link
Member

Choose a reason for hiding this comment

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

better to import this as import * as LoginUtils format.

import CONFIG from '@src/CONFIG';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SAMLSignInPageOnyxProps, SAMLSignInPageProps} from './types';

function SAMLSignInPage({credentials}: SAMLSignInPageProps) {
function SAMLSignInPage() {
const {translate} = useLocalize();
const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS);

useEffect(() => {
window.location.replace(`${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials?.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}`);
}, [credentials?.login]);
// If we don't have a valid login to pass here, direct the user back to a clean sign in state to try again
if (!credentials?.login) {
handleSAMLLoginError(translate('common.error.email'), true);
return;
}

const body = new FormData();
body.append('email', credentials.login);
body.append('referer', CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER);

postSAMLLogin(body)
.then((response) => {
if (!response || !response.url) {
handleSAMLLoginError(translate('common.error.login'), false);
return;
}
window.location.replace(response.url);
})
.catch((error: Error) => {
handleSAMLLoginError(error.message ?? translate('common.error.login'), false);
});
}, [credentials?.login, translate]);

return <SAMLLoadingIndicator />;
}

SAMLSignInPage.displayName = 'SAMLSignInPage';

export default withOnyx<SAMLSignInPageProps, SAMLSignInPageOnyxProps>({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
})(SAMLSignInPage);
export default SAMLSignInPage;
Loading