From c8626c35f83694a1e4563aee591484b77df5fc89 Mon Sep 17 00:00:00 2001 From: RiccardoMolinari95 <98079356+RiccardoMolinari95@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:09:33 +0100 Subject: [PATCH] fix(IT Wallet): [SIW-1795] Problem with SPID Login Using Custom Tab (#6409) > [!WARNING] > Depends on #6455 ## Short description This PR addresses the issue encountered by some users when logging in via SPID for PID issuance on ITWallet, specifically related to the opening of the `CustomTab`. Unified the authorization flows for SPID, Cie + PIN, and CieID. Previously, SPID and CieID had a unique flow, while Cie + PIN followed two separate flows (`startAuthFlow` and `completeAuthFlow`). Replaced the opening of a `CustomTab` with a `WebView` for SPID authentication. ## List of changes proposed in this pull request - Updated `io-react-native-wallet` to 0.26.0 ( authentication outside from the package) - Created a new screen `ItwSpidIdpLoginScreen` to handle the SPID authentication process within a `WebView`. This screen loads the `authUrl` generated by `startAuthFlow` and sends the `authRedirectURL` back to the state machine when it contains `itWalletIssuanceRedirectUri` - Refactored the XState machine states related to eID issuance for CieID and SPID, aligning them with the existing flow for CIE + PIN - Added `openUrlAndListenForAuthRedirect`, which opens the `authUrl` in the browser for CieID and provides the `authRedirectURL` - Created a new actor `getAuthRedirectUrl` used in the `CieIDBuildAuthRedirectUrl` state of CieID, which utilizes `openUrlAndListenForAuthRedirect` to complete the authentication ## How to test To test the changes, try obtaining the eID using all three authentication methods (SPID, Cie + PIN, and CieID) and ensure that the eID is successfully obtained in each case. After successfully obtaining the eID, proceed to obtain a credential and ensure that the process completes without issues. https://github.com/user-attachments/assets/2e9d6a20-bee1-470f-860e-3e6b96c9ca1f --------- Co-authored-by: Riccardo.Molinari Co-authored-by: Gianluca Spada Co-authored-by: Federico Mastrini --- .env.local | 2 +- .env.production | 2 +- package.json | 2 +- .../itwallet/common/utils/itwIssuanceUtils.ts | 183 +++++---------- .../utils/itwOpenUrlAndListenForRedirect.ts | 137 +++++++++++ .../screens/cie/ItwCieCardReaderScreen.tsx | 7 +- .../screens/spid/ItwSpidIdpLoginScreen.tsx | 136 +++++++++++ .../machine/eid/__tests__/machine.test.ts | 145 +++++++++--- ts/features/itwallet/machine/eid/actions.ts | 6 + ts/features/itwallet/machine/eid/actors.ts | 83 ++++--- ts/features/itwallet/machine/eid/context.ts | 7 +- ts/features/itwallet/machine/eid/events.ts | 8 +- ts/features/itwallet/machine/eid/guards.ts | 31 +-- ts/features/itwallet/machine/eid/machine.ts | 212 +++++++++++++++--- ts/features/itwallet/machine/eid/selectors.ts | 7 +- .../itwallet/navigation/ItwParamsList.ts | 2 + .../itwallet/navigation/ItwStackNavigator.tsx | 15 +- ts/features/itwallet/navigation/routes.ts | 3 + yarn.lock | 8 +- 19 files changed, 713 insertions(+), 283 deletions(-) create mode 100644 ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts create mode 100644 ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx diff --git a/.env.local b/.env.local index 0760361e8d6..4a9c1d485e3 100644 --- a/.env.local +++ b/.env.local @@ -92,7 +92,7 @@ ITW_EAA_PROVIDER_BASE_URL="https://pre.eaa.wallet.ipzs.it" # Url used to verify a credential (trustmark) ITW_EAA_VERIFIER_BASE_URL="https://pre.verify.wallet.ipzs.it" ITW_GOOGLE_CLOUD_PROJECT_NUMBER="260468725946" -ITW_ISSUANCE_REDIRECT_URI="iowallet://cb" +ITW_ISSUANCE_REDIRECT_URI="https://wallet.io.pagopa.it/index.html" ITW_ISSUANCE_REDIRECT_URI_CIE="iowalletcie://cb" # Bypass the check that enforces the identity of the issued eID is the same as the authenticated user ITW_BYPASS_IDENTITY_MATCH=YES diff --git a/.env.production b/.env.production index 5c9eaf48248..f60272bdca9 100644 --- a/.env.production +++ b/.env.production @@ -92,7 +92,7 @@ ITW_EAA_PROVIDER_BASE_URL="https://eaa.wallet.ipzs.it" # Url used to verify a credential (trustmark) ITW_EAA_VERIFIER_BASE_URL="https://verify.wallet.ipzs.it" ITW_GOOGLE_CLOUD_PROJECT_NUMBER="260468725946" -ITW_ISSUANCE_REDIRECT_URI="iowallet://cb" +ITW_ISSUANCE_REDIRECT_URI="https://wallet.io.pagopa.it/index.html" ITW_ISSUANCE_REDIRECT_URI_CIE="iowalletcie://cb" # Bypass the check that enforces the identity of the issued eID is the same as the authenticated user ITW_BYPASS_IDENTITY_MATCH=NO diff --git a/package.json b/package.json index e601888f632..5bfe16eab14 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "@pagopa/io-react-native-integrity": "^0.3.0", "@pagopa/io-react-native-jwt": "^1.3.0", "@pagopa/io-react-native-login-utils": "^1.0.7", - "@pagopa/io-react-native-wallet": "^0.25.0", + "@pagopa/io-react-native-wallet": "^0.26.0", "@pagopa/io-react-native-zendesk": "^0.3.29", "@pagopa/react-native-cie": "^1.3.0", "@pagopa/ts-commons": "^10.15.0", diff --git a/ts/features/itwallet/common/utils/itwIssuanceUtils.ts b/ts/features/itwallet/common/utils/itwIssuanceUtils.ts index 43bc8504d35..491a534f9a7 100644 --- a/ts/features/itwallet/common/utils/itwIssuanceUtils.ts +++ b/ts/features/itwallet/common/utils/itwIssuanceUtils.ts @@ -1,19 +1,16 @@ import { generate } from "@pagopa/io-react-native-crypto"; import { - type AuthorizationContext, createCryptoContextFor, Credential, AuthorizationDetail } from "@pagopa/io-react-native-wallet"; import { type CryptoContext } from "@pagopa/io-react-native-jwt"; -import { openAuthenticationSession } from "@pagopa/io-react-native-login-utils"; import uuid from "react-native-uuid"; -import { URL } from "react-native-url-polyfill"; import { itwPidProviderBaseUrl, itWalletIssuanceRedirectUri, - itWalletIssuanceRedirectUriCie, - itwIdpHintTest + itwIdpHintTest, + itWalletIssuanceRedirectUriCie } from "../../../../config"; import { type IdentificationContext } from "../../machine/eid/context"; import { StoredCredential } from "./itwTypesUtils"; @@ -34,30 +31,43 @@ const CIE_L3_REDIRECT_URI = "https://wallet.io.pagopa.it/index.html"; const CREDENTIAL_TYPE = "PersonIdentificationData"; // Different scheme to avoid conflicts with the scheme handled by io-react-native-login-utils's activity -const getRedirectUri = (identificationMode: IdentificationContext["mode"]) => - identificationMode === "cieId" - ? itWalletIssuanceRedirectUriCie - : itWalletIssuanceRedirectUri; +const getRedirectUri = (identificationMode: IdentificationContext["mode"]) => { + switch (identificationMode) { + case "cieId": + return itWalletIssuanceRedirectUriCie; + case "ciePin": + return CIE_L3_REDIRECT_URI; + default: + return itWalletIssuanceRedirectUri; + } +}; -type StartCieAuthFlowParams = { +type StartAuthFlowParams = { walletAttestation: string; + identification: IdentificationContext; }; /** - * Function to start the authentication flow when using CIE + PIN. It must be invoked before - * reading the card to get the `authUrl` to launch the CIE web view, and other params that are needed later. - * After successfully reading the card, the flow must be completed invoking `completeCieAuthFlow`. + * Function to start the authentication flow. It must be invoked before + * proceeding with the authentication process to get the `authUrl` and other parameters needed later. + * After completing the initial authentication flow and obtaining the redirectAuthUrl from the WebView (CIE + PIN & SPID) or Browser (CIEID), + * the flow must be completed by invoking `completeAuthFlow`. * @param walletAttestation - The wallet attestation. * @returns Authentication params to use when completing the flow. */ -const startCieAuthFlow = async ({ - walletAttestation -}: StartCieAuthFlowParams) => { +const startAuthFlow = async ({ + walletAttestation, + identification +}: StartAuthFlowParams) => { const startFlow: Credential.Issuance.StartFlow = () => ({ issuerUrl: itwPidProviderBaseUrl, credentialType: CREDENTIAL_TYPE }); + const idpHint = getIdpHint(identification); + + const redirectUri = getRedirectUri(identification.mode); + const { issuerUrl, credentialType } = startFlow(); const { issuerConf } = await Credential.Issuance.evaluateIssuerTrust( @@ -72,141 +82,66 @@ const startCieAuthFlow = async ({ credentialType, { walletInstanceAttestation: walletAttestation, - redirectUri: CIE_L3_REDIRECT_URI, + redirectUri, wiaCryptoContext } ); - const authzRequestEndpoint = - issuerConf.oauth_authorization_server.authorization_endpoint; - - const params = new URLSearchParams({ - client_id: clientId, - request_uri: issuerRequestUri, - idphint: getIdpHint({ mode: "ciePin", pin: "" }) // PIN is not needed for the hint - }); + // Obtain the Authorization URL + const { authUrl } = await Credential.Issuance.buildAuthorizationUrl( + issuerRequestUri, + clientId, + issuerConf, + idpHint + ); return { - authUrl: `${authzRequestEndpoint}?${params}`, + authUrl, issuerConf, clientId, codeVerifier, - credentialDefinition + credentialDefinition, + redirectUri }; }; -type CompleteCieAuthFlowParams = { +type CompleteAuthFlowParams = { callbackUrl: string; issuerConf: IssuerConf; clientId: string; codeVerifier: string; walletAttestation: string; + redirectUri: string; }; -export type CompleteCieAuthFlowResult = Awaited< - ReturnType +export type CompleteAuthFlowResult = Awaited< + ReturnType >; /** - * Function to complete the CIE + PIN authentication flow. It must be invoked after `startCieAuthFlow` - * and after reading the card to get the final `callbackUrl`. The rest of the parameters are those obtained from - * `startCieAuthFlow` + the wallet attestation. + * Function to complete the authentication flow. It must be invoked after `startAuthFlow` + * and after obtaining the final `callbackUrl` from the WebView (CIE + PIN & SPID) or Browser (CIEID). + * The rest of the parameters are those obtained from `startAuthFlow` + the wallet attestation. * @param walletAttestation - The wallet attestation. * @param callbackUrl - The callback url from which the code to get the access token is extracted. * @returns Authentication tokens. */ -const completeCieAuthFlow = async ({ +const completeAuthFlow = async ({ callbackUrl, clientId, codeVerifier, issuerConf, - walletAttestation -}: CompleteCieAuthFlowParams) => { - const query = Object.fromEntries(new URL(callbackUrl).searchParams); - const { code } = Credential.Issuance.parseAuthorizationResponse(query); - - await regenerateCryptoKey(DPOP_KEYTAG); - const dPopCryptoContext = createCryptoContextFor(DPOP_KEYTAG); - const wiaCryptoContext = createCryptoContextFor(WIA_KEYTAG); - - const { accessToken } = await Credential.Issuance.authorizeAccess( - issuerConf, - code, - clientId, - CIE_L3_REDIRECT_URI, - codeVerifier, - { - walletInstanceAttestation: walletAttestation, - wiaCryptoContext, - dPopCryptoContext - } - ); - - return { accessToken, dPoPContext: dPopCryptoContext }; -}; - -type FullAuthFlowParams = { - walletAttestation: string; - identification: Exclude; -}; - -/** - * Full authentication flow completely handled by `io-react-native-wallet`. The consumer of the library - * does not need to implement any authentication screen or logic. Only compatible with SPID and CieID. - * @param walletAttestation - The wallet attestation. - * @param identification - Object that contains details on the selected identification mode. - * @returns Authentication tokens and other params needed to get the PID. - */ -const startAndCompleteFullAuthFlow = async ({ walletAttestation, - identification -}: FullAuthFlowParams) => { - const authorizationContext: AuthorizationContext | undefined = - identification.mode === "spid" - ? { authorize: openAuthenticationSession } - : undefined; - - const idpHint = getIdpHint(identification); - - const startFlow: Credential.Issuance.StartFlow = () => ({ - issuerUrl: itwPidProviderBaseUrl, - credentialType: CREDENTIAL_TYPE - }); - - const { issuerUrl, credentialType } = startFlow(); - - const { issuerConf } = await Credential.Issuance.evaluateIssuerTrust( - issuerUrl - ); - - const redirectUri = getRedirectUri(identification.mode); - - const wiaCryptoContext = createCryptoContextFor(WIA_KEYTAG); - - const { issuerRequestUri, clientId, codeVerifier, credentialDefinition } = - await Credential.Issuance.startUserAuthorization( - issuerConf, - credentialType, - { - walletInstanceAttestation: walletAttestation, - redirectUri, - wiaCryptoContext - } - ); - + redirectUri +}: CompleteAuthFlowParams) => { const { code } = await Credential.Issuance.completeUserAuthorizationWithQueryMode( - issuerRequestUri, - clientId, - issuerConf, - idpHint, - redirectUri, - authorizationContext, - identification.abortController?.signal + callbackUrl ); await regenerateCryptoKey(DPOP_KEYTAG); const dPopCryptoContext = createCryptoContextFor(DPOP_KEYTAG); + const wiaCryptoContext = createCryptoContextFor(WIA_KEYTAG); const { accessToken } = await Credential.Issuance.authorizeAccess( issuerConf, @@ -221,13 +156,7 @@ const startAndCompleteFullAuthFlow = async ({ } ); - return { - accessToken, - dPoPContext: dPopCryptoContext, - credentialDefinition, - clientId, - issuerConf - }; + return { accessToken, dPoPContext: dPopCryptoContext }; }; type PidIssuanceParams = { @@ -240,9 +169,7 @@ type PidIssuanceParams = { /** * Function to get the PID, parse it and return it in {@link StoredCredential} format. - * It must be called after either one of the following: - * - `startCieAuthFlow` and `completeCieAuthFlow` - * - `startAndCompleteFullAuthFlow` + * It must be called after `startAuthFlow` and `completeAuthFlow`. * @returns The stored credential. */ const getPid = async ({ @@ -289,12 +216,7 @@ const getPid = async ({ }; }; -export { - startCieAuthFlow, - completeCieAuthFlow, - startAndCompleteFullAuthFlow, - getPid -}; +export { startAuthFlow, completeAuthFlow, getPid }; /** * Consts for the IDP hints in test for SPID and CIE and in production for CIE. @@ -330,7 +252,6 @@ const SPID_IDP_HINTS: { [key: string]: string } = { * In production for SPID the hint is retrieved from the IDP ID via the {@link getSpidProductionIdpHint} function, * for CIE the hint is always the same and it's defined in the {@link CIE_HINT_PROD} constant. * @param idCtx the identification context which contains the mode and the IDP ID if the mode is SPID - * @returns the IDP hint to be provided to the {@link openAuthenticationSession} function */ const getIdpHint = (idCtx: IdentificationContext) => { const isSpidMode = idCtx.mode === "spid"; diff --git a/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts b/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts new file mode 100644 index 00000000000..818744e7e9d --- /dev/null +++ b/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts @@ -0,0 +1,137 @@ +import { Linking } from "react-native"; +import { Credential } from "@pagopa/io-react-native-wallet"; + +export type OpenUrlAndListenForAuthRedirect = ( + redirectUri: string, + authUrl: string, + signal?: AbortSignal +) => Promise<{ + authRedirectUrl: string; +}>; + +/** + * Opens the authentication URL for CIE L2 and listens for the authentication redirect URL. + * This function opens an in-app browser to navigate to the provided authentication URL. + * It listens for the redirect URL containing the authorization response and returns it. + * If the 302 redirect happens and the redirectSchema is caught, the function will return the authorization Redirect Url . + * @param redirectUri The URL to which the end user should be redirected to complete the authentication flow + * @param authUrl The URL to which the end user should be redirected to start the authentication flow + * @param signal An optional {@link AbortSignal} to abort the operation when using the default browser + * @returns An object containing the authorization redirect URL + * @throws {Credential.Issuance.Errors.AuthorizationError} if an error occurs during the authorization process + * @throws {Credential.Issuance.Errors.OperationAbortedError} if the caller aborts the operation via the provided signal + */ +export const openUrlAndListenForAuthRedirect: OpenUrlAndListenForAuthRedirect = + async (redirectUri, authUrl, signal) => { + // eslint-disable-next-line functional/no-let + let authRedirectUrl: string | undefined; + + if (redirectUri && authUrl) { + const urlEventListener = Linking.addEventListener("url", ({ url }) => { + if (url.includes(redirectUri)) { + authRedirectUrl = url; + } + }); + + const operationIsAborted = signal + ? createAbortPromiseFromSignal(signal) + : undefined; + await Linking.openURL(authUrl); + + /* + * Waits for 120 seconds for the authRedirectUrl variable to be set + * by the custom url handler. If the timeout is exceeded, throw an exception + */ + const untilAuthRedirectIsNotUndefined = until( + () => authRedirectUrl !== undefined, + 120 + ); + + /** + * Simultaneously listen for the abort signal (when provided) and the redirect url. + * The first event that occurs will resolve the promise. + * This is useful to properly cleanup when the caller aborts this operation. + */ + const winner = await Promise.race( + [operationIsAborted?.listen(), untilAuthRedirectIsNotUndefined].filter( + isDefined + ) + ).finally(() => { + urlEventListener.remove(); + operationIsAborted?.remove(); + }); + + if (winner === "OPERATION_ABORTED") { + throw new ItwOperationAbortedError("DefaultQueryModeAuthorization"); + } + } + + if (authRedirectUrl === undefined) { + throw new Credential.Issuance.Errors.AuthorizationError( + "Invalid authentication redirect url" + ); + } + return { authRedirectUrl }; + }; + +/** + * Repeatedly checks a condition function until it returns true, + * then resolves the returned promise. If the condition function does not return true + * within the specified timeout, the promise is rejected. + * + * @param conditionFunction - A function that returns a boolean value. + * The promise resolves when this function returns true. + * @param timeout - An optional timeout in seconds. The promise is rejected if the + * condition function does not return true within this time. + * @returns A promise that resolves once the conditionFunction returns true or rejects if timed out. + */ +const until = ( + conditionFunction: () => boolean, + timeoutSeconds?: number +): Promise => + new Promise((resolve, reject) => { + const start = Date.now(); + const poll = () => { + if (conditionFunction()) { + resolve(); + } else if ( + timeoutSeconds !== undefined && + Date.now() - start >= timeoutSeconds * 1000 + ) { + reject(new Error("Timeout exceeded")); + } else { + setTimeout(poll, 400); + } + }; + + poll(); + }); + +/** + * Creates a promise that waits until the provided signal is aborted. + * @returns {Object} An object with `listen` and `remove` methods to handle subscribing and unsubscribing. + */ +const createAbortPromiseFromSignal = (signal: AbortSignal) => { + // eslint-disable-next-line functional/no-let + let listener: () => void; + return { + listen: () => + new Promise<"OPERATION_ABORTED">(resolve => { + if (signal.aborted) { + return resolve("OPERATION_ABORTED"); + } + listener = () => resolve("OPERATION_ABORTED"); + signal.addEventListener("abort", listener); + }), + remove: () => signal.removeEventListener("abort", listener) + }; +}; + +const isDefined = (x: T | undefined | null | ""): x is T => Boolean(x); + +export class ItwOperationAbortedError extends Error { + constructor(message: string) { + super(message); + this.name = "OperationAbortedError"; + } +} diff --git a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx index 9d047a68af8..f2211289aa3 100644 --- a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx +++ b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx @@ -34,7 +34,7 @@ import { ITW_ROUTES } from "../../../navigation/routes"; import { useInteractiveElementDefaultColorName } from "../../../../../utils/hooks/theme"; import { ItwEidIssuanceMachineContext } from "../../../machine/provider"; import { - selectCieAuthUrlOption, + selectAuthUrlOption, selectCiePin, selectIsLoading } from "../../../machine/eid/selectors"; @@ -161,9 +161,8 @@ export const ItwCieCardReaderScreen = () => { const isMachineLoading = ItwEidIssuanceMachineContext.useSelector(selectIsLoading); const ciePin = ItwEidIssuanceMachineContext.useSelector(selectCiePin); - const cieAuthUrl = ItwEidIssuanceMachineContext.useSelector( - selectCieAuthUrlOption - ); + const cieAuthUrl = + ItwEidIssuanceMachineContext.useSelector(selectAuthUrlOption); const [identificationStep, setIdentificationStep] = useState( IdentificationStep.AUTHENTICATION diff --git a/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx b/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx new file mode 100644 index 00000000000..fb42bea6520 --- /dev/null +++ b/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx @@ -0,0 +1,136 @@ +import React, { memo, useCallback, useMemo } from "react"; +import { Linking, StyleSheet, View } from "react-native"; +import { WebView, WebViewNavigation } from "react-native-webview"; +import * as O from "fp-ts/lib/Option"; +import { pipe } from "fp-ts/lib/function"; +import { + selectAuthUrlOption, + selectIsLoading +} from "../../../machine/eid/selectors"; +import { ItwEidIssuanceMachineContext } from "../../../machine/provider"; +import LoadingScreenContent from "../../../../../components/screens/LoadingScreenContent"; +import I18n from "../../../../../i18n"; +import { originSchemasWhiteList } from "../../../../../screens/authentication/originSchemasWhiteList"; +import { itWalletIssuanceRedirectUri } from "../../../../../config"; +import { getIntentFallbackUrl } from "../../../../../utils/login"; +import { + HeaderSecondLevelHookProps, + useHeaderSecondLevel +} from "../../../../../hooks/useHeaderSecondLevel"; + +const styles = StyleSheet.create({ + webViewWrapper: { flex: 1 } +}); + +const LoadingSpinner = ( + +); + +// To ensure the server recognizes the client as a valid mobile device, we use a custom user agent header. +const defaultUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X; Linux; Android 10) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"; + +/** + * This component renders a WebView that loads the URL obtained from the startAuthFlow. + * It handles the navigation state changes to detect when the authentication is completed + * and sends the redirectAuthUrl back to the state machine. + */ +const ItwSpidIdpLoginScreen = () => { + const isMachineLoading = + ItwEidIssuanceMachineContext.useSelector(selectIsLoading); + const spidAuthUrl = + ItwEidIssuanceMachineContext.useSelector(selectAuthUrlOption); + const machineRef = ItwEidIssuanceMachineContext.useActorRef(); + + const onError = useCallback(() => { + machineRef.send({ type: "error", scope: "spid-login" }); + }, [machineRef]); + + const handleShouldStartLoading = useCallback( + (event: WebViewNavigation): boolean => { + const url = event.url; + const idpIntent = getIntentFallbackUrl(url); + + return pipe( + idpIntent, + O.fold( + () => true, + intentUrl => { + void Linking.openURL(intentUrl); + return false; + } + ) + ); + }, + [] + ); + + const handleNavigationStateChange = useCallback( + (event: WebViewNavigation) => { + const authRedirectUrl = event.url; + const isIssuanceRedirect = pipe( + authRedirectUrl, + O.fromNullable, + O.fold( + () => false, + s => s.startsWith(itWalletIssuanceRedirectUri) + ) + ); + + if (isIssuanceRedirect) { + machineRef.send({ + type: "spid-identification-completed", + authRedirectUrl + }); + } + }, + [machineRef] + ); + + // Setup header properties + const headerProps: HeaderSecondLevelHookProps = { + title: I18n.t("features.itWallet.identification.mode.title"), + supportRequest: false + }; + + useHeaderSecondLevel(headerProps); + + const content = useMemo( + () => + O.fold( + () => LoadingSpinner, + (url: string) => ( + + ) + )(spidAuthUrl), + [ + spidAuthUrl, + handleNavigationStateChange, + handleShouldStartLoading, + onError + ] + ); + + if (isMachineLoading) { + return LoadingSpinner; + } + + return {content}; +}; + +export default memo(ItwSpidIdpLoginScreen); diff --git a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts index 2191ad482c4..a94eebac15d 100644 --- a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts +++ b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts @@ -1,16 +1,23 @@ import { waitFor } from "@testing-library/react-native"; import _ from "lodash"; -import { assign, createActor, fromPromise, StateFrom } from "xstate"; +import { + assign, + createActor, + fromPromise, + StateFrom, + waitFor as waitForActor +} from "xstate"; import { idps } from "../../../../../utils/idps"; import { ItwStoredCredentialsMocks } from "../../../common/utils/itwMocksUtils"; import { StoredCredential } from "../../../common/utils/itwTypesUtils"; import { ItwTags } from "../../tags"; import { + GetAuthRedirectUrlActorParam, GetWalletAttestationActorParams, RequestEidActorParams, - StartCieAuthFlowActorParams + StartAuthFlowActorParams } from "../actors"; -import { CieAuthContext, Context, InitialContext } from "../context"; +import { AuthenticationContext, Context, InitialContext } from "../context"; import { ItwEidIssuanceMachine, itwEidIssuanceMachine } from "../machine"; type MachineSnapshot = StateFrom; @@ -24,6 +31,8 @@ describe("itwEidIssuanceMachine", () => { const navigateToIdentificationModeScreen = jest.fn(); const navigateToIdpSelectionScreen = jest.fn(); const navigateToEidPreviewScreen = jest.fn(); + const navigateToSpidLoginScreen = jest.fn(); + const navigateToWalletRevocationScreen = jest.fn(); const navigateToSuccessScreen = jest.fn(); const navigateToFailureScreen = jest.fn(); const navigateToWallet = jest.fn(); @@ -31,7 +40,6 @@ describe("itwEidIssuanceMachine", () => { const navigateToCiePinScreen = jest.fn(); const navigateToCieReadCardScreen = jest.fn(); const navigateToNfcInstructionsScreen = jest.fn(); - const navigateToWalletRevocationScreen = jest.fn(); const storeIntegrityKeyTag = jest.fn(); const storeWalletInstanceAttestation = jest.fn(); const storeEidCredential = jest.fn(); @@ -43,12 +51,11 @@ describe("itwEidIssuanceMachine", () => { const onInit = jest.fn(); const createWalletInstance = jest.fn(); - const revokeWalletInstance = jest.fn(); const getWalletAttestation = jest.fn(); const requestEid = jest.fn(); - const startCieAuthFlow = jest.fn(); + const startAuthFlow = jest.fn(); + const getAuthRedirectUrl = jest.fn(); - const isNativeAuthSessionClosed = jest.fn(); const issuedEidMatchesAuthenticatedUser = jest.fn(); const isSessionExpired = jest.fn(); const isOperationAborted = jest.fn(); @@ -56,6 +63,7 @@ describe("itwEidIssuanceMachine", () => { const resetWalletInstance = jest.fn(); const trackWalletInstanceCreation = jest.fn(); const trackWalletInstanceRevocation = jest.fn(); + const revokeWalletInstance = jest.fn(); const mockedMachine = itwEidIssuanceMachine.provide({ actions: { @@ -64,6 +72,8 @@ describe("itwEidIssuanceMachine", () => { navigateToIdentificationModeScreen, navigateToIdpSelectionScreen, navigateToEidPreviewScreen, + navigateToSpidLoginScreen, + navigateToWalletRevocationScreen, navigateToSuccessScreen, navigateToFailureScreen, navigateToWallet, @@ -71,7 +81,6 @@ describe("itwEidIssuanceMachine", () => { navigateToCiePinScreen, navigateToCieReadCardScreen, navigateToNfcInstructionsScreen, - navigateToWalletRevocationScreen, storeIntegrityKeyTag, storeWalletInstanceAttestation, storeEidCredential, @@ -87,7 +96,7 @@ describe("itwEidIssuanceMachine", () => { }, actors: { createWalletInstance: fromPromise(createWalletInstance), - revokeWalletInstance: fromPromise(revokeWalletInstance), + revokeWalletInstance: fromPromise(revokeWalletInstance), getWalletAttestation: fromPromise< string, GetWalletAttestationActorParams @@ -95,13 +104,15 @@ describe("itwEidIssuanceMachine", () => { requestEid: fromPromise( requestEid ), - startCieAuthFlow: fromPromise< - CieAuthContext, - StartCieAuthFlowActorParams - >(startCieAuthFlow) + getAuthRedirectUrl: fromPromise( + getAuthRedirectUrl + ), + startAuthFlow: fromPromise< + AuthenticationContext, + StartAuthFlowActorParams + >(startAuthFlow) }, guards: { - isNativeAuthSessionClosed, issuedEidMatchesAuthenticatedUser, isSessionExpired, isOperationAborted, @@ -197,7 +208,9 @@ describe("itwEidIssuanceMachine", () => { actor.send({ type: "select-identification-mode", mode: "spid" }); expect(actor.getSnapshot().value).toStrictEqual({ - UserIdentification: "Spid" + UserIdentification: { + Spid: "IdpSelection" + } }); expect(actor.getSnapshot().tags).toStrictEqual(new Set()); expect(navigateToIdpSelectionScreen).toHaveBeenCalledTimes(1); @@ -206,16 +219,22 @@ describe("itwEidIssuanceMachine", () => { * Choose first IDP in list for SPID identification */ + startAuthFlow.mockImplementation(() => Promise.resolve({})); + requestEid.mockImplementation(() => Promise.resolve(ItwStoredCredentialsMocks.eid) ); + issuedEidMatchesAuthenticatedUser.mockImplementation(() => true); actor.send({ type: "select-spid-idp", idp: idps[0] }); expect(actor.getSnapshot().value).toStrictEqual({ - Issuance: "RequestingEid" + UserIdentification: { + Spid: "StartingSpidAuthFlow" + } }); + expect(actor.getSnapshot().context).toStrictEqual({ ...InitialContext, integrityKeyTag: T_INTEGRITY_KEY, @@ -225,7 +244,32 @@ describe("itwEidIssuanceMachine", () => { idpId: idps[0].id } }); + + expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); + + await waitFor(() => expect(startAuthFlow).toHaveBeenCalledTimes(1)); + + expect(actor.getSnapshot().value).toStrictEqual({ + UserIdentification: { + Spid: "SpidLoginIdentificationCompleted" + } + }); + + actor.send({ + type: "spid-identification-completed", + authRedirectUrl: "http://test.it" + }); + + expect(actor.getSnapshot().value).toStrictEqual({ + Issuance: "RequestingEid" + }); expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); + expect(actor.getSnapshot().context).toMatchObject({ + authenticationContext: { + callbackUrl: "http://test.it" + } + }); + expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); // EID obtained @@ -234,6 +278,14 @@ describe("itwEidIssuanceMachine", () => { Issuance: "DisplayingPreview" }) ); + + actor.send({ type: "add-to-wallet" }); + + expect(actor.getSnapshot().value).toStrictEqual("Success"); + expect(storeEidCredential).toHaveBeenCalledTimes(1); + expect(setWalletInstanceToValid).toHaveBeenCalledTimes(1); + expect(navigateToSuccessScreen).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().context).toStrictEqual({ ...InitialContext, integrityKeyTag: T_INTEGRITY_KEY, @@ -242,21 +294,11 @@ describe("itwEidIssuanceMachine", () => { mode: "spid", idpId: idps[0].id }, + authenticationContext: expect.objectContaining({ + callbackUrl: "http://test.it" + }), eid: ItwStoredCredentialsMocks.eid }); - expect(actor.getSnapshot().tags).toStrictEqual(new Set()); - expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); - - /** - * Add to wallet - */ - - actor.send({ type: "add-to-wallet" }); - - expect(actor.getSnapshot().value).toStrictEqual("Success"); - expect(storeEidCredential).toHaveBeenCalledTimes(1); - expect(setWalletInstanceToValid).toHaveBeenCalledTimes(1); - expect(navigateToSuccessScreen).toHaveBeenCalledTimes(1); /** * Go to wallet @@ -267,9 +309,13 @@ describe("itwEidIssuanceMachine", () => { expect(navigateToWallet).toHaveBeenCalledTimes(1); }); - it("Should obtain an eID (CieID)", () => { + it("Should obtain an eID (CieID)", async () => { /** Initial part is the same as the previous test, we can start from the identification */ + startAuthFlow.mockImplementation(() => Promise.resolve({})); + getAuthRedirectUrl.mockImplementation(() => Promise.resolve({})); + requestEid.mockImplementation(() => Promise.reject({})); + const initialSnapshot: MachineSnapshot = createActor( itwEidIssuanceMachine ).getSnapshot(); @@ -294,9 +340,12 @@ describe("itwEidIssuanceMachine", () => { actor.send({ type: "select-identification-mode", mode: "cieId" }); expect(actor.getSnapshot().value).toStrictEqual({ - Issuance: "RequestingEid" + UserIdentification: { + CieID: "StartingCieIDAuthFlow" + } }); - expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); + + expect(actor.getSnapshot().tags).toStrictEqual(new Set()); expect(actor.getSnapshot().context).toStrictEqual({ ...InitialContext, integrityKeyTag: T_INTEGRITY_KEY, @@ -308,6 +357,26 @@ describe("itwEidIssuanceMachine", () => { }); expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); + const cieIDBuildAuthRedirectUrlState = await waitForActor(actor, snapshot => + snapshot.matches({ + UserIdentification: { CieID: "CieIDBuildAuthRedirectUrl" } + }) + ); + expect(cieIDBuildAuthRedirectUrlState.value).toStrictEqual({ + UserIdentification: { CieID: "CieIDBuildAuthRedirectUrl" } + }); + + expect(getAuthRedirectUrl).toHaveBeenCalledTimes(1); + + const requestingEidState = await waitForActor(actor, snapshot => + snapshot.matches({ Issuance: "RequestingEid" }) + ); + expect(requestingEidState.value).toStrictEqual({ + Issuance: "RequestingEid" + }); + + expect(requestEid).toHaveBeenCalledTimes(1); + /** Last part is the same as the previous test */ }); @@ -355,7 +424,7 @@ describe("itwEidIssuanceMachine", () => { * Enter pin */ - startCieAuthFlow.mockImplementation(() => Promise.resolve({})); + startAuthFlow.mockImplementation(() => Promise.resolve({})); actor.send({ type: "cie-pin-entered", @@ -378,7 +447,7 @@ describe("itwEidIssuanceMachine", () => { } }); expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); - await waitFor(() => expect(startCieAuthFlow).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(startAuthFlow).toHaveBeenCalledTimes(1)); // Auth flow started @@ -403,7 +472,7 @@ describe("itwEidIssuanceMachine", () => { }); expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); expect(actor.getSnapshot().context).toMatchObject({ - cieAuthContext: { + authenticationContext: { callbackUrl: "http://test.it" } }); @@ -791,9 +860,11 @@ describe("itwEidIssuanceMachine", () => { actor.send({ type: "select-identification-mode", mode: "cieId" }); expect(actor.getSnapshot().value).toStrictEqual({ - Issuance: "RequestingEid" + UserIdentification: { + CieID: "StartingCieIDAuthFlow" + } }); - expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); + expect(actor.getSnapshot().tags).toStrictEqual(new Set()); expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); await waitFor(() => expect(requestEid).toHaveBeenCalledTimes(1)); diff --git a/ts/features/itwallet/machine/eid/actions.ts b/ts/features/itwallet/machine/eid/actions.ts index f5ba7068fbb..77e92063130 100644 --- a/ts/features/itwallet/machine/eid/actions.ts +++ b/ts/features/itwallet/machine/eid/actions.ts @@ -56,6 +56,12 @@ export const createEidIssuanceActionsImplementation = ( }); }, + navigateToSpidLoginScreen: () => { + navigation.navigate(ITW_ROUTES.MAIN, { + screen: ITW_ROUTES.IDENTIFICATION.SPID.LOGIN + }); + }, + navigateToEidPreviewScreen: () => { navigation.navigate(ITW_ROUTES.MAIN, { screen: ITW_ROUTES.ISSUANCE.EID_PREVIEW diff --git a/ts/features/itwallet/machine/eid/actors.ts b/ts/features/itwallet/machine/eid/actors.ts index 86bdde0d07b..86bacf0c607 100644 --- a/ts/features/itwallet/machine/eid/actors.ts +++ b/ts/features/itwallet/machine/eid/actors.ts @@ -18,20 +18,22 @@ import { } from "../../issuance/store/selectors"; import { itwLifecycleStoresReset } from "../../lifecycle/store/actions"; import { pollForStoreValue } from "../../common/utils/itwStoreUtils"; -import type { CieAuthContext, IdentificationContext } from "./context"; +import { openUrlAndListenForAuthRedirect } from "../../common/utils/itwOpenUrlAndListenForRedirect"; +import type { AuthenticationContext, IdentificationContext } from "./context"; export type RequestEidActorParams = { identification: IdentificationContext | undefined; walletInstanceAttestation: string | undefined; - cieAuthContext: CieAuthContext | undefined; + authenticationContext: AuthenticationContext | undefined; }; -export type StartCieAuthFlowActorParams = { +export type StartAuthFlowActorParams = { walletInstanceAttestation: string | undefined; + identification: IdentificationContext | undefined; }; -export type CompleteCieAuthFlowActorParams = { - cieAuthContext: CieAuthContext | undefined; +export type CompleteAuthFlowActorParams = { + authenticationContext: AuthenticationContext | undefined; walletInstanceAttestation: string | undefined; }; @@ -39,6 +41,12 @@ export type GetWalletAttestationActorParams = { integrityKeyTag: string | undefined; }; +export type GetAuthRedirectUrlActorParam = { + redirectUri: string | undefined; + authUrl: string | undefined; + identification: IdentificationContext | undefined; +}; + export const createEidIssuanceActorsImplementation = ( store: ReturnType ) => ({ @@ -96,54 +104,65 @@ export const createEidIssuanceActorsImplementation = ( "walletInstanceAttestation is undefined" ); - // When using CIE + PIN the authorization flow was already started, we just need to complete it - if (input.identification.mode === "ciePin") { - assert( - input.cieAuthContext, - "cieAuthContext must exist when the identification mode is ciePin" - ); - - const authParams = await issuanceUtils.completeCieAuthFlow({ - ...input.cieAuthContext, - walletAttestation: input.walletInstanceAttestation - }); - trackItwRequest("ciePin"); - return issuanceUtils.getPid({ - ...authParams, - ...input.cieAuthContext - }); - } - - // SPID & CieID flow - const authParams = await issuanceUtils.startAndCompleteFullAuthFlow({ - identification: input.identification, + // At this point, the authorization flow has already started and just needs to be completed + assert( + input.authenticationContext, + "authenticationContext must exist when the identification mode is ciePin" + ); + + const authParams = await issuanceUtils.completeAuthFlow({ + ...input.authenticationContext, walletAttestation: input.walletInstanceAttestation }); trackItwRequest(input.identification.mode); - return issuanceUtils.getPid(authParams); + return issuanceUtils.getPid({ + ...authParams, + ...input.authenticationContext + }); } ), - startCieAuthFlow: fromPromise( + startAuthFlow: fromPromise( async ({ input }) => { assert( input.walletInstanceAttestation, "walletInstanceAttestation is undefined" ); + assert(input.identification, "identification is undefined"); - const cieAuthContext = await issuanceUtils.startCieAuthFlow({ - walletAttestation: input.walletInstanceAttestation + const authenticationContext = await issuanceUtils.startAuthFlow({ + walletAttestation: input.walletInstanceAttestation, + identification: input.identification }); return { - ...cieAuthContext, - callbackUrl: "" // This is not important in this phase, it will be set after completing the CIE auth flow + ...authenticationContext, + callbackUrl: "" // This is not important in this phase, it will be set after completing the auth flow }; } ), + getAuthRedirectUrl: fromPromise( + async ({ input }) => { + assert( + input.redirectUri, + "redirectUri must be defined to get authRedirectUrl" + ); + assert(input.authUrl, "authUrl must be defined to get authRedirectUrl"); + assert(input.identification, "identification is undefined"); + + const { authRedirectUrl } = await openUrlAndListenForAuthRedirect( + input.redirectUri, + input.authUrl, + input.identification.abortController?.signal + ); + + return authRedirectUrl; + } + ), + revokeWalletInstance: fromPromise(async () => { const state = store.getState(); const sessionToken = sessionTokenSelector(state); diff --git a/ts/features/itwallet/machine/eid/context.ts b/ts/features/itwallet/machine/eid/context.ts index 0ef5e1f4e3c..dc024af5d3a 100644 --- a/ts/features/itwallet/machine/eid/context.ts +++ b/ts/features/itwallet/machine/eid/context.ts @@ -18,20 +18,21 @@ export type IdentificationContext = * We need to resume the authentication flow after reading the card, * so here we save the auth params obtained in the first step. */ -export type CieAuthContext = { +export type AuthenticationContext = { authUrl: string; clientId: string; codeVerifier: string; issuerConf: Parameters[0]; credentialDefinition: AuthorizationDetail; callbackUrl: string; + redirectUri: string; }; export type Context = { walletInstanceAttestation: string | undefined; integrityKeyTag: string | undefined; identification: IdentificationContext | undefined; - cieAuthContext: CieAuthContext | undefined; + authenticationContext: AuthenticationContext | undefined; eid: StoredCredential | undefined; failure: IssuanceFailure | undefined; }; @@ -40,7 +41,7 @@ export const InitialContext: Context = { walletInstanceAttestation: undefined, integrityKeyTag: undefined, identification: undefined, - cieAuthContext: undefined, + authenticationContext: undefined, eid: undefined, failure: undefined }; diff --git a/ts/features/itwallet/machine/eid/events.ts b/ts/features/itwallet/machine/eid/events.ts index 973ca373558..d0c89d75caf 100644 --- a/ts/features/itwallet/machine/eid/events.ts +++ b/ts/features/itwallet/machine/eid/events.ts @@ -52,6 +52,11 @@ export type CieIdentificationCompleted = { url: string; }; +export type SpidIdentificationCompleted = { + type: "spid-identification-completed"; + authRedirectUrl: string; +}; + export type Retry = { type: "retry"; }; @@ -79,7 +84,7 @@ export type RevokeWalletInstance = { export type Error = { type: "error"; // Add a custom error code to the error event to distinguish between different errors. Add a new error code for each different error if needed. - scope: "ipzs-privacy"; + scope: "ipzs-privacy" | "spid-login"; }; export type EidIssuanceEvents = @@ -91,6 +96,7 @@ export type EidIssuanceEvents = | SelectSpidIdp | CiePinEntered | CieIdentificationCompleted + | SpidIdentificationCompleted | AddToWallet | GoToWallet | AddNewCredential diff --git a/ts/features/itwallet/machine/eid/guards.ts b/ts/features/itwallet/machine/eid/guards.ts index 9bd011f8f89..371790d6e6c 100644 --- a/ts/features/itwallet/machine/eid/guards.ts +++ b/ts/features/itwallet/machine/eid/guards.ts @@ -1,21 +1,14 @@ -import { Credential } from "@pagopa/io-react-native-wallet"; -import * as E from "fp-ts/lib/Either"; import { pipe } from "fp-ts/lib/function"; -import * as J from "fp-ts/lib/Json"; import * as O from "fp-ts/lib/Option"; -import * as t from "io-ts"; import { useIOStore } from "../../../../store/hooks"; import { profileFiscalCodeSelector } from "../../../../store/reducers/profile"; import { ItwSessionExpiredError } from "../../api/client"; import { isWalletInstanceAttestationValid } from "../../common/utils/itwAttestationUtils"; import { getFiscalCodeFromCredential } from "../../common/utils/itwClaimsUtils"; +import { ItwOperationAbortedError } from "../../common/utils/itwOpenUrlAndListenForRedirect"; import { Context } from "./context"; import { EidIssuanceEvents } from "./events"; -const NativeAuthSessionClosed = t.type({ - error: t.literal("NativeAuthSessionClosed") -}); - type GuardsImplementationOptions = Partial<{ bypassIdentityMatch: boolean; }>; @@ -24,25 +17,6 @@ export const createEidIssuanceGuardsImplementation = ( store: ReturnType, options?: GuardsImplementationOptions ) => ({ - /** - * Guard to check whether a native authentication session - * opened with io-react-native-login-utils was closed by the user. - */ - isNativeAuthSessionClosed: ({ event }: { event: EidIssuanceEvents }) => { - if ( - "error" in event && - event.error instanceof Credential.Issuance.Errors.AuthorizationError - ) { - return pipe( - event.error.message, - J.parse, - E.map(NativeAuthSessionClosed.is), - E.getOrElse(() => false) - ); - } - return false; - }, - /** * Guard to check whether the user for whom the eID was issued * is the same that is currently authenticated in app. @@ -65,8 +39,7 @@ export const createEidIssuanceGuardsImplementation = ( "error" in event && event.error instanceof ItwSessionExpiredError, isOperationAborted: ({ event }: { event: EidIssuanceEvents }) => - "error" in event && - event.error instanceof Credential.Issuance.Errors.OperationAbortedError, + "error" in event && event.error instanceof ItwOperationAbortedError, hasValidWalletInstanceAttestation: ({ context }: { context: Context }) => pipe( diff --git a/ts/features/itwallet/machine/eid/machine.ts b/ts/features/itwallet/machine/eid/machine.ts index 2d999354b7e..8fac3279954 100644 --- a/ts/features/itwallet/machine/eid/machine.ts +++ b/ts/features/itwallet/machine/eid/machine.ts @@ -5,9 +5,10 @@ import { ItwTags } from "../tags"; import { GetWalletAttestationActorParams, type RequestEidActorParams, - StartCieAuthFlowActorParams + StartAuthFlowActorParams, + GetAuthRedirectUrlActorParam } from "./actors"; -import { CieAuthContext, Context, InitialContext } from "./context"; +import { AuthenticationContext, Context, InitialContext } from "./context"; import { EidIssuanceEvents } from "./events"; import { IssuanceFailureType, mapEventToFailure } from "./failure"; @@ -25,6 +26,7 @@ export const itwEidIssuanceMachine = setup({ navigateToIpzsPrivacyScreen: notImplemented, navigateToIdentificationModeScreen: notImplemented, navigateToIdpSelectionScreen: notImplemented, + navigateToSpidLoginScreen: notImplemented, navigateToEidPreviewScreen: notImplemented, navigateToSuccessScreen: notImplemented, navigateToFailureScreen: notImplemented, @@ -57,12 +59,14 @@ export const itwEidIssuanceMachine = setup({ requestEid: fromPromise( notImplemented ), - startCieAuthFlow: fromPromise( + startAuthFlow: fromPromise( + notImplemented + ), + getAuthRedirectUrl: fromPromise( notImplemented ) }, guards: { - isNativeAuthSessionClosed: notImplemented, issuedEidMatchesAuthenticatedUser: notImplemented, isSessionExpired: notImplemented, isOperationAborted: notImplemented, @@ -235,24 +239,178 @@ export const itwEidIssuanceMachine = setup({ abortController: new AbortController() } })), - target: "#itwEidIssuanceMachine.UserIdentification.Completed" + target: "CieID" } ], back: "#itwEidIssuanceMachine.IpzsPrivacyAcceptance" } }, + CieID: { + description: + "This state handles the entire CieID authentication flow", + initial: "StartingCieIDAuthFlow", + states: { + StartingCieIDAuthFlow: { + entry: [ + assign(() => ({ authenticationContext: undefined })), + { type: "navigateToEidPreviewScreen" } + ], + invoke: { + src: "startAuthFlow", + input: ({ context }) => ({ + walletInstanceAttestation: context.walletInstanceAttestation, + identification: context.identification + }), + onDone: { + actions: assign(({ event }) => ({ + authenticationContext: event.output + })), + target: "CieIDBuildAuthRedirectUrl" + }, + onError: [ + { + actions: "setFailure", + target: "#itwEidIssuanceMachine.Failure" + } + ] + }, + on: { + abort: { + target: + "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + }, + back: { + target: + "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + } + } + }, + CieIDBuildAuthRedirectUrl: { + invoke: { + src: "getAuthRedirectUrl", + input: ({ context }) => ({ + redirectUri: context.authenticationContext?.redirectUri, + authUrl: context.authenticationContext?.authUrl, + identification: context.identification + }), + onDone: { + actions: assign(({ context, event }) => { + assert( + context.authenticationContext, + "authenticationContext must be defined when completing auth flow" + ); + return { + authenticationContext: { + ...context.authenticationContext, + callbackUrl: event.output + } + }; + }), + target: "Completed" + }, + onError: [ + { + guard: or(["isOperationAborted"]), + target: "#itwEidIssuanceMachine.UserIdentification" + }, + { + actions: "setFailure", + target: "#itwEidIssuanceMachine.Failure" + } + ] + }, + on: { + abort: { actions: "abortIdentification" }, + back: { + target: + "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + } + } + }, + Completed: { + type: "final" + } + }, + onDone: { + target: "#itwEidIssuanceMachine.UserIdentification.Completed" + } + }, Spid: { - entry: "navigateToIdpSelectionScreen", - on: { - "select-spid-idp": { - target: "Completed", - actions: assign(({ event }) => ({ - identification: { mode: "spid", idpId: event.idp.id } - })) + description: "This state handles the entire SPID identification flow", + initial: "IdpSelection", + states: { + IdpSelection: { + entry: [ + assign(() => ({ authenticationContext: undefined })), + { type: "navigateToIdpSelectionScreen" } + ], + on: { + "select-spid-idp": { + target: "StartingSpidAuthFlow", + actions: assign(({ event }) => ({ + identification: { mode: "spid", idpId: event.idp.id } + })) + }, + back: { + target: + "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + } + } }, - back: { - target: "ModeSelection" + StartingSpidAuthFlow: { + entry: "navigateToSpidLoginScreen", + tags: [ItwTags.Loading], + invoke: { + src: "startAuthFlow", + input: ({ context }) => ({ + walletInstanceAttestation: context.walletInstanceAttestation, + identification: context.identification + }), + onDone: { + actions: assign(({ event }) => ({ + authenticationContext: event.output + })), + target: "SpidLoginIdentificationCompleted" + }, + onError: { + actions: "setFailure", + target: "#itwEidIssuanceMachine.Failure" + } + }, + on: { + back: { + target: "IdpSelection" + } + } + }, + SpidLoginIdentificationCompleted: { + on: { + "spid-identification-completed": { + target: "Completed", + actions: assign(({ context, event }) => { + assert( + context.authenticationContext, + "authenticationContext must be defined when completing auth flow" + ); + return { + authenticationContext: { + ...context.authenticationContext, + callbackUrl: event.authRedirectUrl + } + }; + }) + }, + back: { + target: "IdpSelection" + } + } + }, + Completed: { + type: "final" } + }, + onDone: { + target: "#itwEidIssuanceMachine.UserIdentification.Completed" } }, CiePin: { @@ -262,7 +420,7 @@ export const itwEidIssuanceMachine = setup({ states: { InsertingCardPin: { entry: [ - assign(() => ({ cieAuthContext: undefined })), // Reset the CIE context, otherwise retries will use stale data + assign(() => ({ authenticationContext: undefined })), // Reset the authentication context, otherwise retries will use stale data { type: "navigateToCiePinScreen" } ], on: { @@ -304,13 +462,14 @@ export const itwEidIssuanceMachine = setup({ entry: "navigateToCieReadCardScreen", tags: [ItwTags.Loading], invoke: { - src: "startCieAuthFlow", + src: "startAuthFlow", input: ({ context }) => ({ - walletInstanceAttestation: context.walletInstanceAttestation + walletInstanceAttestation: context.walletInstanceAttestation, + identification: context.identification }), onDone: { actions: assign(({ event }) => ({ - cieAuthContext: event.output + authenticationContext: event.output })), target: "ReadingCieCard" }, @@ -331,12 +490,12 @@ export const itwEidIssuanceMachine = setup({ target: "Completed", actions: assign(({ context, event }) => { assert( - context.cieAuthContext, - "cieAuthContext must be defined when completing CIE+pin flow" + context.authenticationContext, + "authenticationContext must be defined when completing auth flow" ); return { - cieAuthContext: { - ...context.cieAuthContext, + authenticationContext: { + ...context.authenticationContext, callbackUrl: event.url } }; @@ -371,15 +530,12 @@ export const itwEidIssuanceMachine = setup({ initial: "RequestingEid", states: { RequestingEid: { - on: { - abort: { actions: "abortIdentification" } - }, tags: [ItwTags.Loading], invoke: { src: "requestEid", input: ({ context }) => ({ identification: context.identification, - cieAuthContext: context.cieAuthContext, + authenticationContext: context.authenticationContext, walletInstanceAttestation: context.walletInstanceAttestation }), onDone: { @@ -387,10 +543,6 @@ export const itwEidIssuanceMachine = setup({ target: "CheckingIdentityMatch" }, onError: [ - { - guard: or(["isNativeAuthSessionClosed", "isOperationAborted"]), - target: "#itwEidIssuanceMachine.UserIdentification" - }, { actions: "setFailure", target: "#itwEidIssuanceMachine.Failure" diff --git a/ts/features/itwallet/machine/eid/selectors.ts b/ts/features/itwallet/machine/eid/selectors.ts index 0c9f84965dc..7df2368813b 100644 --- a/ts/features/itwallet/machine/eid/selectors.ts +++ b/ts/features/itwallet/machine/eid/selectors.ts @@ -25,9 +25,9 @@ export const selectCiePin = (snapshot: MachineSnapshot) => O.getOrElse(() => "") ); -export const selectCieAuthUrlOption = (snapshot: MachineSnapshot) => +export const selectAuthUrlOption = (snapshot: MachineSnapshot) => pipe( - snapshot.context.cieAuthContext, + snapshot.context.authenticationContext, O.fromNullable, O.map(x => x.authUrl) ); @@ -36,5 +36,4 @@ export const selectIsLoading = (snapshot: MachineSnapshot) => snapshot.hasTag(ItwTags.Loading); export const selectIsCieIdEidRequest = (snapshot: MachineSnapshot) => - snapshot.context.identification?.mode === "cieId" && - snapshot.matches({ Issuance: "RequestingEid" }); + snapshot.context.identification?.mode === "cieId"; diff --git a/ts/features/itwallet/navigation/ItwParamsList.ts b/ts/features/itwallet/navigation/ItwParamsList.ts index 94781d3ec27..2059e5bc5e0 100644 --- a/ts/features/itwallet/navigation/ItwParamsList.ts +++ b/ts/features/itwallet/navigation/ItwParamsList.ts @@ -14,7 +14,9 @@ export type ItwParamsList = { [ITW_ROUTES.DISCOVERY.ALREADY_ACTIVE_SCREEN]: undefined; // IDENTIFICATION [ITW_ROUTES.IDENTIFICATION.MODE_SELECTION]: undefined; + // IDENTIFICATION SPID [ITW_ROUTES.IDENTIFICATION.IDP_SELECTION]: undefined; + [ITW_ROUTES.IDENTIFICATION.SPID.LOGIN]: undefined; // IDENTIFICATION CIE + PIN [ITW_ROUTES.IDENTIFICATION.CIE.PIN_SCREEN]: undefined; [ITW_ROUTES.IDENTIFICATION.CIE.CARD_READER_SCREEN]: undefined; diff --git a/ts/features/itwallet/navigation/ItwStackNavigator.tsx b/ts/features/itwallet/navigation/ItwStackNavigator.tsx index 2066697a7e8..910194bb201 100644 --- a/ts/features/itwallet/navigation/ItwStackNavigator.tsx +++ b/ts/features/itwallet/navigation/ItwStackNavigator.tsx @@ -1,13 +1,11 @@ import { - createStackNavigator, - TransitionPresets + TransitionPresets, + createStackNavigator } from "@react-navigation/stack"; import * as React from "react"; import { Platform } from "react-native"; import { isGestureEnabled } from "../../../utils/navigation"; -import { ItwAlreadyActiveScreen } from "../discovery/screens/ItwAlreadyActiveScreen"; import { ItwDiscoveryInfoScreen } from "../discovery/screens/ItwDiscoveryInfoScreen"; -import ItwIpzsPrivacyScreen from "../discovery/screens/ItwIpzsPrivacyScreen"; import { ItwActivateNfcScreen } from "../identification/screens/cie/ItwActivateNfcScreen"; import { ItwCieCardReaderScreen } from "../identification/screens/cie/ItwCieCardReaderScreen"; import { ItwCieExpiredOrInvalidScreen } from "../identification/screens/cie/ItwCieExpiredOrInvalidScreen"; @@ -17,7 +15,6 @@ import { ItwCieWrongCardScreen } from "../identification/screens/cie/ItwCieWrong import { ItwCieWrongCiePinScreen } from "../identification/screens/cie/ItwCieWrongCiePinScreen"; import { ItwIdentificationIdpSelectionScreen } from "../identification/screens/ItwIdentificationIdpSelectionScreen"; import { ItwIdentificationModeSelectionScreen } from "../identification/screens/ItwIdentificationModeSelectionScreen"; -import { ItwIssuanceCredentialAsyncContinuationScreen } from "../issuance/screens/ItwIssuanceCredentialAsyncContinuationScreen"; import { ItwIssuanceCredentialFailureScreen } from "../issuance/screens/ItwIssuanceCredentialFailureScreen"; import { ItwIssuanceCredentialPreviewScreen } from "../issuance/screens/ItwIssuanceCredentialPreviewScreen"; import { ItwIssuanceCredentialTrustIssuerScreen } from "../issuance/screens/ItwIssuanceCredentialTrustIssuerScreen"; @@ -36,8 +33,12 @@ import ItwPlayground from "../playgrounds/screens/ItwPlayground"; import { ItwPresentationCredentialAttachmentScreen } from "../presentation/screens/ItwPresentationCredentialAttachmentScreen"; import { ItwPresentationCredentialCardModal } from "../presentation/screens/ItwPresentationCredentialCardModal"; import { ItwPresentationCredentialDetailScreen } from "../presentation/screens/ItwPresentationCredentialDetailScreen"; +import { ItwIssuanceCredentialAsyncContinuationScreen } from "../issuance/screens/ItwIssuanceCredentialAsyncContinuationScreen"; +import ItwIpzsPrivacyScreen from "../discovery/screens/ItwIpzsPrivacyScreen"; +import ItwSpidIdpLoginScreen from "../identification/screens/spid/ItwSpidIdpLoginScreen"; import { ItwPresentationCredentialFiscalCodeModal } from "../presentation/screens/ItwPresentationCredentialFiscalCodeModal"; import { ItwCredentialTrustmarkScreen } from "../trustmark/screens/ItwCredentialTrustmarkScreen"; +import { ItwAlreadyActiveScreen } from "../discovery/screens/ItwAlreadyActiveScreen"; import { ItwParamsList } from "./ItwParamsList"; import { ITW_ROUTES } from "./routes"; @@ -97,6 +98,10 @@ const InnerNavigator = () => { name={ITW_ROUTES.IDENTIFICATION.IDP_SELECTION} component={ItwIdentificationIdpSelectionScreen} /> + {/* IDENTIFICATION CIE + PIN */}