From 6ece31b50adade9ade0557440e173626cc749452 Mon Sep 17 00:00:00 2001 From: Theo Bentum Date: Tue, 8 Oct 2024 15:07:30 -0700 Subject: [PATCH] feature/9286-SingleSignOn (#9412) Co-authored-by: Therese <94404065+TKDickson@users.noreply.github.com> --- .../native_modules/CustomTabsIntentModule.kt | 1 + .../docs/Engineering/FrontEnd/SingleSignOn.md | 43 +++++++++++ VAMobile/env/constant.env | 1 - VAMobile/env/env.sh | 8 ++- VAMobile/ios/Podfile.lock | 12 +++- VAMobile/ios/RNAuthSession.swift | 1 + .../ios/VAMobile.xcodeproj/project.pbxproj | 4 -- VAMobile/jest/testSetup.ts | 8 +++ VAMobile/package.json | 1 + VAMobile/src/constants/analytics.ts | 8 +++ .../NoAppointments/NoAppointments.tsx | 38 +++++++--- .../screens/WebviewScreen/WebviewScreen.tsx | 31 +++++++- VAMobile/src/store/api/types/auth.ts | 1 + .../src/store/slices/authSlice.sis.test.ts | 6 +- VAMobile/src/store/slices/authSlice.ts | 13 +++- VAMobile/src/translations/en/common.json | 1 + VAMobile/src/utils/auth.ts | 72 +++++++++++++++++++ VAMobile/src/utils/remoteConfig.test.ts | 1 + VAMobile/src/utils/remoteConfig.ts | 3 + VAMobile/yarn.lock | 7 ++ 20 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 VAMobile/documentation/docs/Engineering/FrontEnd/SingleSignOn.md create mode 100644 VAMobile/src/utils/auth.ts diff --git a/VAMobile/android/app/src/main/java/gov/va/mobileapp/native_modules/CustomTabsIntentModule.kt b/VAMobile/android/app/src/main/java/gov/va/mobileapp/native_modules/CustomTabsIntentModule.kt index 4f9827e73b9..a61312e8a4b 100644 --- a/VAMobile/android/app/src/main/java/gov/va/mobileapp/native_modules/CustomTabsIntentModule.kt +++ b/VAMobile/android/app/src/main/java/gov/va/mobileapp/native_modules/CustomTabsIntentModule.kt @@ -47,6 +47,7 @@ class CustomTabsIntentModule(private val context: ReactApplicationContext) : appendQueryParameter("code_challenge", codeChallenge) appendQueryParameter("application", "vamobile") appendQueryParameter("oauth", "true") + appendQueryParameter("scope", "device_sso") } } .build() diff --git a/VAMobile/documentation/docs/Engineering/FrontEnd/SingleSignOn.md b/VAMobile/documentation/docs/Engineering/FrontEnd/SingleSignOn.md new file mode 100644 index 00000000000..ecd00656881 --- /dev/null +++ b/VAMobile/documentation/docs/Engineering/FrontEnd/SingleSignOn.md @@ -0,0 +1,43 @@ +# Single Sign-On + +Single sign-on (SSO) allows users to access the VA.gov website within the mobile app without having to manually authenticate in the browser. + +## Architecture + +The SSO process begins with the normal [authentication flow](../BackEnd/Architecture/Auth%20Diagrams.md), with the user signing in via the login screen. When the user taps the `Sign in` button, the mobile app launches the website's login webpage (https://va.gov/sign-in) in the browser. This webpage is opened with query parameters attached to the URL, specifically `code_challenge_method`, `code_challenge`, `application`, and `oauth`. In order to have the ability to start SSO sessions, we pass an additional query parameter to the URL, `scope`, which is set to `device_sso`. This informs the https://api.va.gov/v0/sign_in/authorize API endpoint that's called by the website that in addition to returning an access token and refresh token, it should return a device secret (`device_secret`), which can be used to fetch cookies for starting SSO sessions. + +Once the device secret is received, it is securely stored in Keychain (iOS) or Keystore (Android). + +### Fetching SSO cookies + +To start an SSO session, cookies need to be fetched using the device secret and access token. SSO cookies are fetched from the https://api.va.gov/v0/sign_in/token endpoint with a few parameters, most importantly `subject_token` (access token) and `actor_token` (device secret). This endpoint will return a response with the `Set-Cookie` header, which will contain the SSO cookies `vagov_access_token`, `vagov_refresh_token`, `vagov_anti_csrf_token`, and `vagov_info_token`. Once received, these cookies are stored in the `CookieManager` using `@react-native-cookies/cookies`, and can be used to authenticate the user's session in the WebView. Cookies are fetched when the WebView component is first mounted. (Note: The `useSSO` prop must be passed to the WebView component in order for SSO cookies to be fetched.) + +New cookies are always fetched whenever a new WebView is launched in the app. This is to ensure the SSO cookies used in the WebView are not expired. + +### Authenticating the WebView + +Once the SSO cookies are stored in the `CookieManager`, the `hasSession` field is set to `true` in `localstorage` for the WebView. This allows the VA.gov website to recognize that the user's session is authenticated. + +### Persisting the device secret + +As mentioned above, the device secret is stored in Keychain/Keystore to ensure its persistence for biometric login. When a user logs into the app with biometrics, the app will use the stored device secret to start SSO sessions. The device secret has an expiration of 45 days, similar to the refresh token. + +When the user manually signs out, all sessions spawned with the device secret are revoked via the https://api.va.gov/v0/sign_in/revoke API endpoint. Likewise, whenever the user manually signs in to the mobile app, a new device secret will be retrieved and stored. + +## Usage + +SSO sessions can easily be started in WebViews within the app. Whenever you have a link/button that navigates to the WebView screen, you can pass the `useSSO` prop to the screen to start an authenticated SSO session, e.g. + +``` +navigateTo('Webview', { + url: LINK_TO_OPEN_IN_WEBVIEW, + displayTitle: t('webview.vagov'), + useSSO: true, +}) +``` + +This will open the WebView screen with an SSO session, allowing the user to access features on the website that require authentication. + +## API documentation + +For more information on API usage for SSO, view the [Device SSO Token Exchange]() documentation. diff --git a/VAMobile/env/constant.env b/VAMobile/env/constant.env index c441478098b..620e7e605de 100644 --- a/VAMobile/env/constant.env +++ b/VAMobile/env/constant.env @@ -7,7 +7,6 @@ LINK_URL_IN_APP_RECRUITMENT=https://docs.google.com/forms/d/e/1FAIpQLSfRb0OtW34q LINK_URL_VETERAN_USABILITY_PROJECT=https://veteranusability.us/ LINK_URL_VETERANS_CRISIS_LINE=https://www.veteranscrisisline.net/ LINK_URL_VETERANS_CRISIS_LINE_GET_HELP=https://www.veteranscrisisline.net/get-help/chat -LINK_URL_SCHEDULE_APPOINTMENTS=https://www.va.gov/health-care/schedule-view-va-appointments/ LINK_URL_PRIVACY_POLICY=https://www.va.gov/privacy-policy/ LINK_URL_DECISION_REVIEWS=https://www.va.gov/decision-reviews/ LINK_URL_ABOUT_DISABILITY_RATINGS=https://www.va.gov/disability/about-disability-ratings/ diff --git a/VAMobile/env/env.sh b/VAMobile/env/env.sh index a535026880f..53878600a7e 100755 --- a/VAMobile/env/env.sh +++ b/VAMobile/env/env.sh @@ -15,7 +15,7 @@ echo "" > .env if [[ $environment == 'staging' ]] then echo "Setting up Staging environment" - AUTH_SIS_PREFIX="staging." + WEBSITE_PREFIX="staging." API_PREFIX="staging-api." else echo "Setting up Production environment" @@ -27,7 +27,7 @@ echo "ENVIRONMENT=$environment" >> .env echo "API_ROOT=https://${API_PREFIX}va.gov/mobile" >> .env # set SIS vars -AUTH_SIS_ROOT="https://${AUTH_SIS_PREFIX}va.gov" +AUTH_SIS_ROOT="https://${WEBSITE_PREFIX}va.gov" echo "AUTH_SIS_ENDPOINT=${AUTH_SIS_ROOT}/sign-in" >> .env echo "AUTH_SIS_TOKEN_EXCHANGE_URL=https://${API_PREFIX}va.gov/v0/sign_in/token" >> .env echo "AUTH_SIS_TOKEN_REFRESH_URL=https://${API_PREFIX}va.gov/v0/sign_in/refresh" >> .env @@ -50,6 +50,10 @@ else fi # set demo mode password echo "DEMO_PASSWORD=${DEMO_PASSWORD}" >> .env + +# set website URLs +echo "LINK_URL_SCHEDULE_APPOINTMENTS=https://${WEBSITE_PREFIX}va.gov/health-care/schedule-view-va-appointments" >> .env + # Get all vars that are the same across environments while read p; do echo "$p" >> .env diff --git a/VAMobile/ios/Podfile.lock b/VAMobile/ios/Podfile.lock index 85ebac1bbd7..6fc01d9a335 100644 --- a/VAMobile/ios/Podfile.lock +++ b/VAMobile/ios/Podfile.lock @@ -1396,6 +1396,8 @@ PODS: - Yoga - react-native-blob-util (0.19.11): - React-Core + - react-native-cookies (6.2.1): + - React-Core - react-native-document-picker (8.2.2): - React-Core - react-native-image-picker (7.1.2): @@ -1845,6 +1847,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-blob-util (from `../node_modules/react-native-blob-util`) + - "react-native-cookies (from `../node_modules/@react-native-cookies/cookies`)" - react-native-document-picker (from `../node_modules/react-native-document-picker`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-notifications (from `../node_modules/react-native-notifications`) @@ -1998,6 +2001,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-blob-util: :path: "../node_modules/react-native-blob-util" + react-native-cookies: + :path: "../node_modules/@react-native-cookies/cookies" react-native-document-picker: :path: "../node_modules/react-native-document-picker" react-native-image-picker: @@ -2097,7 +2102,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 4cb898d0bf20404aab1850c656dcea009429d6c1 - DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 + DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 FBLazyVector: 7b438dceb9f904bd85ca3c31d64cce32a035472b Firebase: 10c8cb12fb7ad2ae0c09ffc86cd9c1ab392a0031 FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe @@ -2113,7 +2118,7 @@ SPEC CHECKSUMS: FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 - glog: 69ef571f3de08433d766d614c73a9838a06bf7eb + glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleAppMeasurement: bb3c564c3efb933136af0e94899e0a46167466a8 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 @@ -2155,6 +2160,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 714f2fae68edcabfc332b754e9fbaa8cfc68fdd4 React-microtasksnativemodule: 4943ad8f99be8ccf5a63329fa7d269816609df9e react-native-blob-util: 39a20f2ef11556d958dc4beb0aa07d1ef2690745 + react-native-cookies: f54fcded06bb0cda05c11d86788020b43528a26c react-native-document-picker: cd4d6b36a5207ad7a9e599ebb9eb0c2e84fa0b87 react-native-image-picker: 2fbbafdae7a7c6db9d25df2f2b1db4442d2ca2ad react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4 @@ -2205,7 +2211,7 @@ SPEC CHECKSUMS: SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - Yoga: 4ef80d96a5534f0e01b3055f17d1e19a9fc61b63 + Yoga: 1354c027ab07c7736f99a3bef16172d6f1b12b47 PODFILE CHECKSUM: 528e5ac3a06c35c8645d8271610e36fdcca33735 diff --git a/VAMobile/ios/RNAuthSession.swift b/VAMobile/ios/RNAuthSession.swift index efc30a76718..9c38c303c45 100644 --- a/VAMobile/ios/RNAuthSession.swift +++ b/VAMobile/ios/RNAuthSession.swift @@ -36,6 +36,7 @@ class RNAuthSession: NSObject, RCTBridgeModule, ASWebAuthenticationPresentationC URLQueryItem(name: "code_challenge", value: codeChallenge), URLQueryItem(name: "application", value: "vamobile"), URLQueryItem(name: "oauth", value: "true"), + URLQueryItem(name: "scope", value: "device_sso"), ] guard var comps = URLComponents(string: authUrl) else { diff --git a/VAMobile/ios/VAMobile.xcodeproj/project.pbxproj b/VAMobile/ios/VAMobile.xcodeproj/project.pbxproj index 3c3265b20ed..f2407c8c94f 100644 --- a/VAMobile/ios/VAMobile.xcodeproj/project.pbxproj +++ b/VAMobile/ios/VAMobile.xcodeproj/project.pbxproj @@ -495,7 +495,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle", ); name = "[CP] Copy Pods Resources"; @@ -513,7 +512,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle", ); runOnlyForDeploymentPostprocessing = 0; @@ -554,7 +552,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle", ); name = "[CP] Copy Pods Resources"; @@ -572,7 +569,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/VAMobile/jest/testSetup.ts b/VAMobile/jest/testSetup.ts index 8b601fee7e3..89fa935594f 100644 --- a/VAMobile/jest/testSetup.ts +++ b/VAMobile/jest/testSetup.ts @@ -200,6 +200,14 @@ jest.mock('react-native-file-viewer', () => { } }) +jest.mock('@react-native-cookies/cookies', () => { + return { + clearAll: jest.fn(), + get: jest.fn(), + setFromResponse: jest.fn(), + } +}) + jest.mock('@react-native-firebase/analytics', () => { return jest.fn(() => { return { diff --git a/VAMobile/package.json b/VAMobile/package.json index 2b7f333e07c..97a1dd26522 100644 --- a/VAMobile/package.json +++ b/VAMobile/package.json @@ -42,6 +42,7 @@ "@department-of-veterans-affairs/mobile-tokens": "0.17.1", "@expo/react-native-action-sheet": "^4.1.0", "@react-native-async-storage/async-storage": "^1.24.0", + "@react-native-cookies/cookies": "^6.2.1", "@react-native-firebase/analytics": "^18.9.0", "@react-native-firebase/app": "^18.9.0", "@react-native-firebase/crashlytics": "^18.9.0", diff --git a/VAMobile/src/constants/analytics.ts b/VAMobile/src/constants/analytics.ts index 9d6b76bfd86..2a0bd31cb53 100644 --- a/VAMobile/src/constants/analytics.ts +++ b/VAMobile/src/constants/analytics.ts @@ -1142,6 +1142,14 @@ export const Events = { }, } }, + vama_sso_cookie_received: (received: boolean): Event => { + return { + name: 'vama_sso_cookie_received', + params: { + received, + }, + } + }, vama_toggle: (toggle_name: string, status: boolean, screen_name: string): Event => { return { name: 'vama_toggle', diff --git a/VAMobile/src/screens/HealthScreen/Appointments/NoAppointments/NoAppointments.tsx b/VAMobile/src/screens/HealthScreen/Appointments/NoAppointments/NoAppointments.tsx index 26506a5c738..de5b1c33126 100644 --- a/VAMobile/src/screens/HealthScreen/Appointments/NoAppointments/NoAppointments.tsx +++ b/VAMobile/src/screens/HealthScreen/Appointments/NoAppointments/NoAppointments.tsx @@ -5,7 +5,8 @@ import { Box, LinkWithAnalytics, TextView } from 'components' import { NAMESPACE } from 'constants/namespaces' import { a11yLabelVA } from 'utils/a11yLabel' import getEnv from 'utils/env' -import { useTheme } from 'utils/hooks' +import { useRouteNavigation, useTheme } from 'utils/hooks' +import { featureEnabled } from 'utils/remoteConfig' const { LINK_URL_SCHEDULE_APPOINTMENTS } = getEnv() @@ -18,6 +19,7 @@ type NoAppointmentsProps = { export function NoAppointments({ subText, subTextA11yLabel, showVAGovLink = true }: NoAppointmentsProps) { const { t } = useTranslation(NAMESPACE.COMMON) const theme = useTheme() + const navigateTo = useRouteNavigation() return ( {subText} - {showVAGovLink && ( - - )} + {showVAGovLink && + (featureEnabled('sso') ? ( + + navigateTo('Webview', { + url: LINK_URL_SCHEDULE_APPOINTMENTS, + displayTitle: t('webview.vagov'), + loadingMessage: t('webview.appointments.loading'), + useSSO: true, + }) + } + text={t('noAppointments.visitVA')} + a11yLabel={a11yLabelVA(t('noAppointments.visitVA'))} + a11yHint={t('mobileBodyLink.a11yHint')} + /> + ) : ( + + ))} ) } diff --git a/VAMobile/src/screens/WebviewScreen/WebviewScreen.tsx b/VAMobile/src/screens/WebviewScreen/WebviewScreen.tsx index b8535142de6..3f17d451366 100644 --- a/VAMobile/src/screens/WebviewScreen/WebviewScreen.tsx +++ b/VAMobile/src/screens/WebviewScreen/WebviewScreen.tsx @@ -10,8 +10,10 @@ import { BackButton } from 'components/BackButton' import { BackButtonLabelConstants } from 'constants/backButtonLabels' import { NAMESPACE } from 'constants/namespaces' import { a11yLabelVA } from 'utils/a11yLabel' +import { fetchSSOCookies } from 'utils/auth' import { useTheme } from 'utils/hooks' import { isIOS } from 'utils/platform' +import { featureEnabled } from 'utils/remoteConfig' import WebviewControlButton from './WebviewControlButton' import WebviewControls, { WebviewControlsProps } from './WebviewControls' @@ -78,6 +80,8 @@ export type WebviewStackParams = { displayTitle: string /** Text to appear with a lock icon in the header */ loadingMessage?: string + /** Use SSO to authenticate webview */ + useSSO?: boolean } } @@ -87,16 +91,24 @@ type WebviewScreenProps = StackScreenProps * Screen for displaying web content within the app. Provides basic navigation and controls */ function WebviewScreen({ navigation, route }: WebviewScreenProps) { + const { url, displayTitle, loadingMessage, useSSO } = route.params + const isSSOSession = featureEnabled('sso') && useSSO + const theme = useTheme() const webviewRef = useRef() as MutableRefObject const [canGoBack, setCanGoBack] = useState(false) const [canGoForward, setCanGoForward] = useState(false) const [currentUrl, setCurrentUrl] = useState('') - - const { url, displayTitle, loadingMessage } = route.params + const [fetchingSSOCookies, setFetchingSSOCookies] = useState(isSSOSession) + const [webviewLoadFailed, setWebviewLoadFailed] = useState(false) const onReloadPressed = (): void => { + // Fetch SSO cookies when attempting to reload after initial WebView load failed + if (isSSOSession && webviewLoadFailed) { + setWebviewLoadFailed(false) + setFetchingSSOCookies(true) + } webviewRef?.current.reload() } @@ -116,6 +128,12 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) { }) }) + useEffect(() => { + if (fetchingSSOCookies) { + fetchSSOCookies().finally(() => setFetchingSSOCookies(false)) + } + }, [fetchingSSOCookies]) + const backPressed = (): void => { webviewRef?.current.goBack() } @@ -133,6 +151,7 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) { } const INJECTED_JAVASCRIPT = `(function() { + localStorage.setItem('hasSession', true); document.getElementsByClassName("header")[0].style.display='none'; document.getElementsByClassName("va-nav-breadcrumbs")[0].style.display='none'; document.getElementsByClassName("footer")[0].style.display='none'; @@ -156,7 +175,9 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) { bottom: 0, } - return ( + return fetchingSSOCookies ? ( + + ) : ( } source={{ uri: url }} injectedJavaScript={INJECTED_JAVASCRIPT} + sharedCookiesEnabled={true} ref={webviewRef} // onMessage is required to be present for injected javascript to work on iOS onMessage={(): void => { // no op }} + onError={() => { + setWebviewLoadFailed(true) + }} onNavigationStateChange={(navState): void => { setCanGoBack(navState.canGoBack) setCanGoForward(navState.canGoForward) diff --git a/VAMobile/src/store/api/types/auth.ts b/VAMobile/src/store/api/types/auth.ts index d68f7a55aa9..1da969d49b4 100644 --- a/VAMobile/src/store/api/types/auth.ts +++ b/VAMobile/src/store/api/types/auth.ts @@ -34,6 +34,7 @@ export const AuthParamsLoadingStateTypeConstants: { export type AuthCredentialData = { access_token?: string refresh_token?: string + device_secret?: string accessTokenExpirationDate?: string token_type?: string id_token?: string diff --git a/VAMobile/src/store/slices/authSlice.sis.test.ts b/VAMobile/src/store/slices/authSlice.sis.test.ts index 57fd6cbdf36..e2db0d8d9da 100644 --- a/VAMobile/src/store/slices/authSlice.sis.test.ts +++ b/VAMobile/src/store/slices/authSlice.sis.test.ts @@ -82,13 +82,16 @@ const defaultEnvParams = { const sampleIdToken = 'TEST_TOKEN' const getItemMock = AsyncStorage.getItem as jest.Mock -let mockedAuthResponse: { data: { access_token: string; refresh_token: string; id_token: string } } +let mockedAuthResponse: { + data: { access_token: string; refresh_token: string; device_secret: string; id_token: string } +} context('authAction SIS', () => { let testAccessToken: string let encryptedComponent: string let nonce: string let testRefreshToken: string + let testDeviceSecret: string afterEach(() => { jest.clearAllMocks() }) @@ -101,6 +104,7 @@ context('authAction SIS', () => { data: { access_token: testAccessToken, refresh_token: testRefreshToken, + device_secret: testDeviceSecret, id_token: sampleIdToken, }, } diff --git a/VAMobile/src/store/slices/authSlice.ts b/VAMobile/src/store/slices/authSlice.ts index d558c7968ee..f044cd03933 100644 --- a/VAMobile/src/store/slices/authSlice.ts +++ b/VAMobile/src/store/slices/authSlice.ts @@ -25,6 +25,7 @@ import { LoginServiceTypeConstants, } from 'store/api/types' import { logAnalyticsEvent, logNonFatalErrorToFirebase, setAnalyticsUserProperty } from 'utils/analytics' +import { KEYCHAIN_DEVICE_SECRET_KEY, storeDeviceSecret } from 'utils/auth' import { isErrorObject } from 'utils/common' import getEnv from 'utils/env' import { pkceAuthorizeParams } from 'utils/oauth' @@ -142,6 +143,7 @@ export const completeFirstTimeLogin = (): AppThunk => async (dispatch) => { */ const clearStoredAuthCreds = async (): Promise => { await Keychain.resetInternetCredentials(KEYCHAIN_STORAGE_KEY) + await Keychain.resetInternetCredentials(KEYCHAIN_DEVICE_SECRET_KEY) await AsyncStorage.removeItem(REFRESH_TOKEN_TYPE) inMemoryRefreshToken = undefined } @@ -388,6 +390,11 @@ const processAuthResponse = async (response: Response): Promise async (dispatch, getState) => { const tokenMatchesServiceType = await refreshTokenMatchesLoginService() if (tokenMatchesServiceType) { - const queryString = new URLSearchParams({ refresh_token: refreshToken ?? '' }).toString() + const deviceSecret = await Keychain.getInternetCredentials(KEYCHAIN_DEVICE_SECRET_KEY) + const queryString = new URLSearchParams({ + refresh_token: refreshToken ?? '', + device_secret: deviceSecret ? deviceSecret.password : '', + }).toString() const response = await fetch(AUTH_SIS_REVOKE_URL, { method: 'POST', diff --git a/VAMobile/src/translations/en/common.json b/VAMobile/src/translations/en/common.json index 9b261cb8f26..1532329e032 100644 --- a/VAMobile/src/translations/en/common.json +++ b/VAMobile/src/translations/en/common.json @@ -1515,6 +1515,7 @@ "video": "Video", "waygateEditScreen.title": "Waygate Edit Screen", "waygateManagement.title": "Waygate Management", + "webview.appointments.loading": "Loading appointments page...", "webview.changeLegalName.loading": "Loading how to change your legal name...", "webview.vagov": "va.gov", "webview.valocation.loading": "Loading VA location finder...", diff --git a/VAMobile/src/utils/auth.ts b/VAMobile/src/utils/auth.ts new file mode 100644 index 00000000000..f8d6e792cfd --- /dev/null +++ b/VAMobile/src/utils/auth.ts @@ -0,0 +1,72 @@ +import * as Keychain from 'react-native-keychain' + +import CookieManager from '@react-native-cookies/cookies' + +import { Events } from 'constants/analytics' +import * as api from 'store/api' +import getEnv from 'utils/env' + +import { logAnalyticsEvent, logNonFatalErrorToFirebase } from './analytics' + +const { AUTH_SIS_TOKEN_EXCHANGE_URL } = getEnv() + +export const KEYCHAIN_DEVICE_SECRET_KEY = 'vamobileDeviceSecret' +const SSO_COOKIE_NAMES = ['vagov_access_token', 'vagov_anti_csrf_token', 'vagov_info_token'] + +/** + * Fetches SSO cookies and stores them in the CookieManager + */ +export const fetchSSOCookies = async () => { + try { + let hasSSOCookies = false + await CookieManager.clearAll() + + const deviceSecret = await Keychain.getInternetCredentials(KEYCHAIN_DEVICE_SECRET_KEY) + const response = await fetch(AUTH_SIS_TOKEN_EXCHANGE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: api.getAccessToken() || '', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + actor_token: deviceSecret ? deviceSecret.password : '', + actor_token_type: 'urn:x-oath:params:oauth:token-type:device-secret', + client_id: 'vaweb', + }).toString(), + }) + + const cookieHeaders = response.headers.get('set-cookie') + + if (cookieHeaders) { + await CookieManager.setFromResponse(AUTH_SIS_TOKEN_EXCHANGE_URL, cookieHeaders) + + const cookies = await CookieManager.get(AUTH_SIS_TOKEN_EXCHANGE_URL) + const cookiesArray = Object.values(cookies) + hasSSOCookies = SSO_COOKIE_NAMES.every((cookieName) => cookiesArray.some((cookie) => cookie.name === cookieName)) + } + + logAnalyticsEvent(Events.vama_sso_cookie_received(hasSSOCookies)) + } catch (error) { + logNonFatalErrorToFirebase(error, `Error fetching SSO cookies: ${error}`) + } +} + +/** + * Stores SSO device secret in keychain/keystore + */ +export const storeDeviceSecret = async (deviceSecret: string) => { + try { + const options: Keychain.Options = { + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, + securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE, + } + + await Keychain.resetInternetCredentials(KEYCHAIN_DEVICE_SECRET_KEY) + await Keychain.setInternetCredentials(KEYCHAIN_DEVICE_SECRET_KEY, 'user', deviceSecret, options) + console.debug('Successfully stored SSO device secret') + } catch (error) { + logNonFatalErrorToFirebase(error, `storeDeviceSecret: Failed to store SSO device secret`) + } +} diff --git a/VAMobile/src/utils/remoteConfig.test.ts b/VAMobile/src/utils/remoteConfig.test.ts index 50638eb5dac..f80de88dc3c 100644 --- a/VAMobile/src/utils/remoteConfig.test.ts +++ b/VAMobile/src/utils/remoteConfig.test.ts @@ -31,6 +31,7 @@ const mockOverrides = { preferredNameGenderWaygate: false, prescriptions: true, submitEvidenceExpansion: true, + sso: false, testFeature: false, useOldLinkComponent: false, whatsNewUI: false, diff --git a/VAMobile/src/utils/remoteConfig.ts b/VAMobile/src/utils/remoteConfig.ts index 7884f072486..279f5af0d20 100644 --- a/VAMobile/src/utils/remoteConfig.ts +++ b/VAMobile/src/utils/remoteConfig.ts @@ -29,6 +29,7 @@ export type FeatureToggleType = | 'preferredNameGenderWaygate' | 'prescriptions' | 'submitEvidenceExpansion' + | 'sso' | 'testFeature' | 'useOldLinkComponent' | 'whatsNewUI' @@ -47,6 +48,7 @@ type FeatureToggleValues = { preferredNameGenderWaygate: boolean prescriptions: boolean submitEvidenceExpansion: boolean + sso: boolean testFeature: boolean useOldLinkComponent: boolean whatsNewUI: boolean @@ -66,6 +68,7 @@ export const defaults: FeatureToggleValues = { preferredNameGenderWaygate: true, prescriptions: true, submitEvidenceExpansion: false, + sso: false, testFeature: false, useOldLinkComponent: false, whatsNewUI: true, diff --git a/VAMobile/yarn.lock b/VAMobile/yarn.lock index d07cf2f2312..a2089e06230 100644 --- a/VAMobile/yarn.lock +++ b/VAMobile/yarn.lock @@ -2308,6 +2308,13 @@ prompts "^2.4.2" semver "^7.5.2" +"@react-native-cookies/cookies@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@react-native-cookies/cookies/-/cookies-6.2.1.tgz#54d50b9496400bbdc19e43c155f70f8f918999e3" + integrity sha512-D17wCA0DXJkGJIxkL74Qs9sZ3sA+c+kCoGmXVknW7bVw/W+Vv1m/7mWTNi9DLBZSRddhzYw8SU0aJapIaM/g5w== + dependencies: + invariant "^2.2.4" + "@react-native-firebase/analytics@^18.9.0": version "18.9.0" resolved "https://registry.yarnpkg.com/@react-native-firebase/analytics/-/analytics-18.9.0.tgz#15defe1052b01975e8b9dd487aff4041b5e42c1d"