diff --git a/package-lock.json b/package-lock.json index 2d4cb4899941..4c1b77a853b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "expo-image-manipulator": "^13.1.5", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", + "expo-web-browser": "14.2.0", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -22988,6 +22989,16 @@ "expo": "*" } }, + "node_modules/expo-web-browser": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.2.0.tgz", + "integrity": "sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.8", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.8.tgz", diff --git a/package.json b/package.json index ebc1c9b836a2..adab6b18a39b 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "expo-image-manipulator": "^13.1.5", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", + "expo-web-browser": "14.2.0", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0daa5ee140fb..f523f83c11a6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -952,6 +952,7 @@ const CONST = { GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', DEEPLINK_BASE_URL: 'new-expensify://', + SAML_REDIRECT_URL: 'expensify://open', PDF_VIEWER_URL: '/pdf/web/viewer.html', CLOUDFRONT_DOMAIN_REGEX: /^https:\/\/\w+\.cloudfront\.net/i, EXPENSIFY_ICON_URL: `${CLOUDFRONT_URL}/images/favicon-2019.png`, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 9b5e0618ee0e..b3446e8cc314 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1,4 +1,5 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; +import {openAuthSessionAsync} from 'expo-web-browser'; import throttle from 'lodash/throttle'; import type {ChannelAuthorizationData} from 'pusher-js/types/src/core/auth/options'; import type {ChannelAuthorizationCallback} from 'pusher-js/with-encryption'; @@ -66,6 +67,7 @@ import type Locale from '@src/types/onyx/Locale'; import type Response from '@src/types/onyx/Response'; import type Session from '@src/types/onyx/Session'; import type {AutoAuthState} from '@src/types/onyx/Session'; +import pkg from '../../../../package.json'; import clearCache from './clearCache'; import updateSessionAuthTokens from './updateSessionAuthTokens'; @@ -163,6 +165,7 @@ function getShortLivedLoginParams(isSupportAuthTokenUsed = false, isSAML = false key: ONYXKEYS.SESSION, value: { signedInWithShortLivedAuthToken: true, + signedInWithSAML: isSAML, isAuthenticatingWithShortLivedToken: true, isSupportAuthTokenUsed, }, @@ -183,6 +186,7 @@ function getShortLivedLoginParams(isSupportAuthTokenUsed = false, isSAML = false key: ONYXKEYS.SESSION, value: { signedInWithShortLivedAuthToken: null, + signedInWithSAML: isSAML, isSupportAuthTokenUsed: null, isAuthenticatingWithShortLivedToken: false, }, @@ -237,10 +241,21 @@ function signOut(): Promise { skipReauthentication: true, }; + if (session.signedInWithSAML) { + return callSAMLSignOut(params); + } // eslint-disable-next-line rulesdir/no-api-side-effects-method return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {}); } +function callSAMLSignOut(params: LogOutParams): Promise { + const queryString = `appversion=${pkg.version}&referer=ecash&authToken=${session.authToken}`; + return openAuthSessionAsync(`${CONST.EXPENSIFY_URL}/authentication/saml/logout?${queryString}`).then(() => { + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {}); + }); +} + /** * Checks if the account is an anonymous account. */ diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index 77baf3565713..e7ec9aaa17f0 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -1,6 +1,6 @@ +import type {WebBrowserAuthSessionResult} from 'expo-web-browser'; +import {openAuthSessionAsync} from 'expo-web-browser'; import React, {useCallback, useEffect, useRef, useState} from 'react'; -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'; @@ -8,12 +8,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; 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 {clearSignInData, setAccountError, signInWithShortLivedAuthToken} from '@userActions/Session'; import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -22,8 +22,75 @@ function SAMLSignInPage() { const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS, {canBeMissing: false}); const [showNavigation, shouldShowNavigation] = useState(true); const [SAMLUrl, setSAMLUrl] = useState(''); - const webViewRef = useRef(null); const {translate} = useLocalize(); + const hasOpenedAuthSession = useRef(false); + + /** + * Handles in-app navigation once we get a response back from Expensify + */ + const handleNavigationStateChange = useCallback( + (url: string) => { + // If we've gotten a callback then remove the option to navigate back to the sign-in page + if (url.includes('loginCallback')) { + shouldShowNavigation(false); + } + + const searchParams = new URLSearchParams(new URL(url).search); + const jsonParam = searchParams.get('json'); + + if (!jsonParam) { + Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL'); + return; + } + + let shortLivedAuthToken: string | null = null; + let error: string | null = null; + + try { + const decodedData = JSON.parse(jsonParam) as Record; + shortLivedAuthToken = decodedData.shortLivedAuthToken ?? null; + error = decodedData.error ?? null; + Log.info('SAMLSignInPage - Parsed data from JSON parameter'); + } catch { + // We need to come generate translation for message indicating parsing error + Log.hmmm(`SAMLSignInPage - Failed to parse JSON parameter`); + error = 'Failed to parse JSON'; + } + + if (!account?.isLoading && credentials?.login && !!shortLivedAuthToken) { + Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); + signInWithShortLivedAuthToken(shortLivedAuthToken, true); + return; + } + + if (error) { + clearSignInData(); + setAccountError(error); + + Navigation.isNavigationReady().then(() => { + // We must call goBack() to remove the /transition route from history + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); + }); + } + }, + [credentials?.login, shouldShowNavigation, account?.isLoading], + ); + + useEffect(() => { + // Don't open auth session more than once. If user cancels it we should navigate back to ROUTES.HOME + if (!SAMLUrl || hasOpenedAuthSession.current) { + return; + } + hasOpenedAuthSession.current = true; + openAuthSessionAsync(SAMLUrl, CONST.SAML_REDIRECT_URL).then((response: WebBrowserAuthSessionResult) => { + if (response.type !== 'success') { + Navigation.goBack(); + return; + } + handleNavigationStateChange(response.url); + }); + }, [SAMLUrl, handleNavigationStateChange]); 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 @@ -41,6 +108,7 @@ function SAMLSignInPage() { body.append('email', credentials.login); body.append('referer', CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER); body.append('platform', getPlatform()); + body.append('useBrowser', 'true'); postSAMLLogin(body) .then((response) => { if (!response || !response.url) { @@ -54,38 +122,6 @@ function SAMLSignInPage() { }); }, [credentials?.login, SAMLUrl, translate]); - /** - * Handles in-app navigation once we get a response back from Expensify - */ - const handleNavigationStateChange = useCallback( - ({url}: WebViewNativeEvent) => { - // If we've gotten a callback then remove the option to navigate back to the sign-in page - if (url.includes('loginCallback')) { - shouldShowNavigation(false); - } - - const searchParams = new URLSearchParams(new URL(url).search); - const shortLivedAuthToken = searchParams.get('shortLivedAuthToken'); - if (!account?.isLoading && credentials?.login && !!shortLivedAuthToken) { - Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, true); - } - - // If the login attempt is unsuccessful, set the error message for the account and redirect to sign in page - if (searchParams.has('error')) { - clearSignInData(); - setAccountError(searchParams.get('error') ?? ''); - - Navigation.isNavigationReady().then(() => { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(); - Navigation.navigate(ROUTES.HOME); - }); - } - }, - [credentials?.login, shouldShowNavigation, account?.isLoading], - ); - return ( )} - {!SAMLUrl ? ( - - ) : ( - } - onNavigationStateChange={handleNavigationStateChange} - /> - )} + ); diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts index 5b662764f221..9988590e7912 100644 --- a/src/types/onyx/Session.ts +++ b/src/types/onyx/Session.ts @@ -37,6 +37,9 @@ type Session = { /** User signed in with short lived token */ signedInWithShortLivedAuthToken?: boolean; + /** User signed in with SAML */ + signedInWithSAML?: boolean; + /** Indicates whether the user is re-authenticating with shortLivedToken */ isAuthenticatingWithShortLivedToken?: boolean;