Skip to content

Commit

Permalink
feature/9286-SingleSignOn (#9412)
Browse files Browse the repository at this point in the history
Co-authored-by: Therese <[email protected]>
  • Loading branch information
theodur and TKDickson authored Oct 8, 2024
1 parent a4170ec commit 6ece31b
Show file tree
Hide file tree
Showing 20 changed files with 235 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions VAMobile/documentation/docs/Engineering/FrontEnd/SingleSignOn.md
Original file line number Diff line number Diff line change
@@ -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](<https://github.com/department-of-veterans-affairs/va.gov-team/blob/master/products/identity/Products/Sign-In%20Service/Engineering%20Docs/Authentication%20Types/Client%20Auth%20(User)/auth_flows/device_sso_token_exchange.md>) documentation.
1 change: 0 additions & 1 deletion VAMobile/env/constant.env
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
8 changes: 6 additions & 2 deletions VAMobile/env/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions VAMobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -2097,7 +2102,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
boost: 4cb898d0bf20404aab1850c656dcea009429d6c1
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953
FBLazyVector: 7b438dceb9f904bd85ca3c31d64cce32a035472b
Firebase: 10c8cb12fb7ad2ae0c09ffc86cd9c1ab392a0031
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
Expand All @@ -2113,7 +2118,7 @@ SPEC CHECKSUMS:
FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
GoogleAppMeasurement: bb3c564c3efb933136af0e94899e0a46167466a8
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2205,7 +2211,7 @@ SPEC CHECKSUMS:
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
Yoga: 4ef80d96a5534f0e01b3055f17d1e19a9fc61b63
Yoga: 1354c027ab07c7736f99a3bef16172d6f1b12b47

PODFILE CHECKSUM: 528e5ac3a06c35c8645d8271610e36fdcca33735

Expand Down
1 change: 1 addition & 0 deletions VAMobile/ios/RNAuthSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 0 additions & 4 deletions VAMobile/ios/VAMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions VAMobile/jest/testSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions VAMobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions VAMobile/src/constants/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 (
<Box
Expand All @@ -37,15 +39,31 @@ export function NoAppointments({ subText, subTextA11yLabel, showVAGovLink = true
accessibilityLabel={subTextA11yLabel}>
{subText}
</TextView>
{showVAGovLink && (
<LinkWithAnalytics
type="url"
url={LINK_URL_SCHEDULE_APPOINTMENTS}
text={t('noAppointments.visitVA')}
a11yLabel={a11yLabelVA(t('noAppointments.visitVA'))}
a11yHint={t('mobileBodyLink.a11yHint')}
/>
)}
{showVAGovLink &&
(featureEnabled('sso') ? (
<LinkWithAnalytics
type="custom"
onPress={() =>
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')}
/>
) : (
<LinkWithAnalytics
type="url"
url={LINK_URL_SCHEDULE_APPOINTMENTS}
text={t('noAppointments.visitVA')}
a11yLabel={a11yLabelVA(t('noAppointments.visitVA'))}
a11yHint={t('mobileBodyLink.a11yHint')}
/>
))}
</Box>
)
}
Expand Down
31 changes: 28 additions & 3 deletions VAMobile/src/screens/WebviewScreen/WebviewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
}

Expand All @@ -87,16 +91,24 @@ type WebviewScreenProps = StackScreenProps<WebviewStackParams, 'Webview'>
* 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<WebView>

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()
}

Expand All @@ -116,6 +128,12 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) {
})
})

useEffect(() => {
if (fetchingSSOCookies) {
fetchSSOCookies().finally(() => setFetchingSSOCookies(false))
}
}, [fetchingSSOCookies])

const backPressed = (): void => {
webviewRef?.current.goBack()
}
Expand All @@ -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';
Expand All @@ -156,7 +175,9 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) {
bottom: 0,
}

return (
return fetchingSSOCookies ? (
<WebviewLoading loadingMessage={loadingMessage} />
) : (
<Box {...mainViewBoxProps} testID="Webview-page">
<StatusBar
translucent
Expand All @@ -168,11 +189,15 @@ function WebviewScreen({ navigation, route }: WebviewScreenProps) {
renderLoading={(): ReactElement => <WebviewLoading loadingMessage={loadingMessage} />}
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)
Expand Down
1 change: 1 addition & 0 deletions VAMobile/src/store/api/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 6ece31b

Please sign in to comment.