From 3ac296935f0b278f2d27e6c9941672d86849965e Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Mon, 16 Dec 2024 12:05:37 +0100 Subject: [PATCH] feat(IT Wallet): [SIW-1630] Identification through direct integration with the CieID app (#6545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Short description This PR introduces direct integration with the CieID app during the Wallet activation phase. When the CieID app is installed on a user's device, it will be opened directly to authenticate the user. This flow is particularly optimized for Android, thanks to `@pagopa/io-react-native-cieid`. When the app is not installed, identification will be carried on through a regular web view. ## List of changes proposed in this pull request - Created `ItwCieIdLoginScreen` to handle the CieID app: - Use `openCieIdApp` on Android - Use `Linking` on iOS to directly open CieID - Fallback to a regular web view when CieID is not installed - Removed `ITW_ISSUANCE_REDIRECT_URI_CIE` and always used the same redirect URI - Create a function to serialize `Error` in failure reasons for better debugging - Moved a few CIE related constants to `utils/cie.ts` for better reuse ## How to test > [!NOTE] > Needs testing on iOS. Test all the identification methods to ensure no regressions were introduced. For CieID, test the following: - Identification with CieID installed → it should directly open the app - Identification without CieID → it should ask for CieID credentials in a web view --- .env.local | 1 - .env.production | 1 - ts/config.ts | 2 - .../cieLogin/components/CieIdLoginWebView.tsx | 10 +- .../itwallet/common/utils/itwIssuanceUtils.ts | 23 +-- .../utils/itwOpenUrlAndListenForRedirect.ts | 137 ---------------- .../itwallet/common/utils/itwStoreUtils.ts | 13 ++ .../identification/hooks/useCieIdApp.ts | 142 ++++++++++++++++ .../__tests__/ItwCieIdLoginScreen.test.tsx | 114 +++++++++++++ .../screens/cie/ItwCieCardReaderScreen.tsx | 18 ++- .../screens/cieId/ItwCieIdLoginScreen.tsx | 153 ++++++++++++++++++ .../screens/spid/ItwSpidIdpLoginScreen.tsx | 2 +- .../components/ItwIssuanceLoadingScreen.tsx | 78 --------- .../ItwIssuanceCredentialFailureScreen.tsx | 3 +- .../screens/ItwIssuanceEidFailureScreen.tsx | 3 +- .../screens/ItwIssuanceEidPreviewScreen.tsx | 6 +- .../machine/eid/__tests__/machine.test.ts | 77 +++++---- ts/features/itwallet/machine/eid/actions.ts | 6 + ts/features/itwallet/machine/eid/actors.ts | 26 --- ts/features/itwallet/machine/eid/events.ts | 19 +-- ts/features/itwallet/machine/eid/guards.ts | 4 - ts/features/itwallet/machine/eid/machine.ts | 125 +++++--------- .../itwallet/navigation/ItwParamsList.ts | 1 + .../itwallet/navigation/ItwStackNavigator.tsx | 5 + ts/features/itwallet/navigation/routes.ts | 3 + ts/utils/cie.ts | 5 + 26 files changed, 561 insertions(+), 416 deletions(-) delete mode 100644 ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts create mode 100644 ts/features/itwallet/identification/hooks/useCieIdApp.ts create mode 100644 ts/features/itwallet/identification/screens/__tests__/ItwCieIdLoginScreen.test.tsx create mode 100644 ts/features/itwallet/identification/screens/cieId/ItwCieIdLoginScreen.tsx delete mode 100644 ts/features/itwallet/issuance/components/ItwIssuanceLoadingScreen.tsx diff --git a/.env.local b/.env.local index 6695a3aa390..15b6ff0d3a8 100644 --- a/.env.local +++ b/.env.local @@ -92,7 +92,6 @@ ITW_EAA_PROVIDER_BASE_URL="https://pre.eaa.wallet.ipzs.it" ITW_EAA_VERIFIER_BASE_URL="https://pre.verify.wallet.ipzs.it" ITW_GOOGLE_CLOUD_PROJECT_NUMBER="260468725946" 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 # Use the test environment for the IDP hint for both CIE and SPID diff --git a/.env.production b/.env.production index 6b30017f516..0d09f9f56db 100644 --- a/.env.production +++ b/.env.production @@ -92,7 +92,6 @@ ITW_EAA_PROVIDER_BASE_URL="https://eaa.wallet.ipzs.it" ITW_EAA_VERIFIER_BASE_URL="https://verify.wallet.ipzs.it" ITW_GOOGLE_CLOUD_PROJECT_NUMBER="260468725946" 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 # Use the test environment for the IDP hint for both CIE and SPID diff --git a/ts/config.ts b/ts/config.ts index 8223ea078fc..fa847bbb3ad 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -241,8 +241,6 @@ export const itwWalletProviderBaseUrl = Config.ITW_WALLET_PROVIDER_BASE_URL; export const itwGoogleCloudProjectNumber = Config.ITW_GOOGLE_CLOUD_PROJECT_NUMBER; export const itWalletIssuanceRedirectUri = Config.ITW_ISSUANCE_REDIRECT_URI; -export const itWalletIssuanceRedirectUriCie = - Config.ITW_ISSUANCE_REDIRECT_URI_CIE; export const itwPidProviderBaseUrl = Config.ITW_PID_PROVIDER_BASE_URL; export const itwEaaProviderBaseUrl = Config.ITW_EAA_PROVIDER_BASE_URL; export const itwEaaVerifierBaseUrl = Config.ITW_EAA_VERIFIER_BASE_URL; diff --git a/ts/features/cieLogin/components/CieIdLoginWebView.tsx b/ts/features/cieLogin/components/CieIdLoginWebView.tsx index aafc805e728..3ae4bda9f7b 100644 --- a/ts/features/cieLogin/components/CieIdLoginWebView.tsx +++ b/ts/features/cieLogin/components/CieIdLoginWebView.tsx @@ -37,6 +37,12 @@ import { HeaderSecondLevelHookProps, useHeaderSecondLevel } from "../../../hooks/useHeaderSecondLevel"; +import { + CIE_ID_ERROR, + CIE_ID_ERROR_MESSAGE, + IO_LOGIN_CIE_SOURCE_APP, + IO_LOGIN_CIE_URL_SCHEME +} from "../../../utils/cie"; export type WebViewLoginNavigationProps = { spidLevel: SpidLevel; @@ -55,10 +61,6 @@ const originSchemasWhiteList = [ "iologin://*", ...(isDevEnv ? ["http://*"] : []) ]; -const IO_LOGIN_CIE_SOURCE_APP = "iologincie"; -const IO_LOGIN_CIE_URL_SCHEME = `${IO_LOGIN_CIE_SOURCE_APP}:`; -const CIE_ID_ERROR = "cieiderror"; -const CIE_ID_ERROR_MESSAGE = "cieid_error_message="; const WHITELISTED_DOMAINS = [ "https://idserver.servizicie.interno.gov.it", diff --git a/ts/features/itwallet/common/utils/itwIssuanceUtils.ts b/ts/features/itwallet/common/utils/itwIssuanceUtils.ts index 491a534f9a7..38aef16d135 100644 --- a/ts/features/itwallet/common/utils/itwIssuanceUtils.ts +++ b/ts/features/itwallet/common/utils/itwIssuanceUtils.ts @@ -9,8 +9,7 @@ import uuid from "react-native-uuid"; import { itwPidProviderBaseUrl, itWalletIssuanceRedirectUri, - itwIdpHintTest, - itWalletIssuanceRedirectUriCie + itwIdpHintTest } from "../../../../config"; import { type IdentificationContext } from "../../machine/eid/context"; import { StoredCredential } from "./itwTypesUtils"; @@ -26,22 +25,8 @@ type AccessToken = Awaited< type IssuerConf = Parameters[0]; -// This can be any URL, as long as it has http or https as its protocol, otherwise it cannot be managed by the webview. -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"]) => { - switch (identificationMode) { - case "cieId": - return itWalletIssuanceRedirectUriCie; - case "ciePin": - return CIE_L3_REDIRECT_URI; - default: - return itWalletIssuanceRedirectUri; - } -}; - type StartAuthFlowParams = { walletAttestation: string; identification: IdentificationContext; @@ -66,8 +51,6 @@ const startAuthFlow = async ({ const idpHint = getIdpHint(identification); - const redirectUri = getRedirectUri(identification.mode); - const { issuerUrl, credentialType } = startFlow(); const { issuerConf } = await Credential.Issuance.evaluateIssuerTrust( @@ -82,7 +65,7 @@ const startAuthFlow = async ({ credentialType, { walletInstanceAttestation: walletAttestation, - redirectUri, + redirectUri: itWalletIssuanceRedirectUri, wiaCryptoContext } ); @@ -101,7 +84,7 @@ const startAuthFlow = async ({ clientId, codeVerifier, credentialDefinition, - redirectUri + redirectUri: itWalletIssuanceRedirectUri }; }; diff --git a/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts b/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts deleted file mode 100644 index 818744e7e9d..00000000000 --- a/ts/features/itwallet/common/utils/itwOpenUrlAndListenForRedirect.ts +++ /dev/null @@ -1,137 +0,0 @@ -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/common/utils/itwStoreUtils.ts b/ts/features/itwallet/common/utils/itwStoreUtils.ts index 93aa6769abb..33637b7a6e2 100644 --- a/ts/features/itwallet/common/utils/itwStoreUtils.ts +++ b/ts/features/itwallet/common/utils/itwStoreUtils.ts @@ -1,4 +1,6 @@ import { GlobalState } from "../../../../store/reducers/types"; +import { type CredentialIssuanceFailure } from "../../machine/credential/failure"; +import { type IssuanceFailure } from "../../machine/eid/failure"; interface PollForStoreValueOptions { getState: () => GlobalState; @@ -47,3 +49,14 @@ export const pollForStoreValue = ({ } }, interval); }); + +/** + * Serialize failure reasons that are instances of {@link Error}, to be safely stored and displayed. + */ +export const serializeFailureReason = ( + failure: IssuanceFailure | CredentialIssuanceFailure +) => ({ + ...failure, + reason: + failure.reason instanceof Error ? failure.reason.message : failure.reason +}); diff --git a/ts/features/itwallet/identification/hooks/useCieIdApp.ts b/ts/features/itwallet/identification/hooks/useCieIdApp.ts new file mode 100644 index 00000000000..f49b0eec8d2 --- /dev/null +++ b/ts/features/itwallet/identification/hooks/useCieIdApp.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; +import * as O from "fp-ts/lib/Option"; +import { Linking } from "react-native"; +import * as t from "io-ts"; +import { CieIdErrorResult, openCieIdApp } from "@pagopa/io-react-native-cieid"; +import { pipe } from "fp-ts/lib/function"; +import { + CIE_ID_ERROR, + CIE_ID_ERROR_MESSAGE, + IO_LOGIN_CIE_SOURCE_APP, + IO_LOGIN_CIE_URL_SCHEME +} from "../../../../utils/cie"; +import { isAndroid, isIos } from "../../../../utils/platform"; +import { convertUnknownToError } from "../../../../utils/errors"; +import { ItwEidIssuanceMachineContext } from "../../machine/provider"; + +type CieIdHookResult = { + /** + * The authentication url obtained after a successful identification through CieID. + */ + authUrl: O.Option; + /** + * Whether the CieID app has been opened separately from IO (iOS only). + */ + isAppLaunched: boolean; + /** + * Function that starts the authentication with CieID. + */ + startCieIdAppAuthentication: (url: string) => void; + /** + * Function that handles CieID related errors. + */ + handleAuthenticationFailure: (error: unknown) => void; +}; + +const cieIdAppError = t.type({ + id: t.literal("ERROR"), + code: t.string +}); + +const isCieIdAppError = (e: unknown): e is CieIdErrorResult => + cieIdAppError.is(e); + +const extractCieIdErrorFromUrl = (url: string) => + pipe( + url, + O.fromPredicate(x => x.includes(CIE_ID_ERROR)), + O.map( + x => x.split(CIE_ID_ERROR_MESSAGE)[1] ?? "Unexpected error from CieID" + ), + O.toUndefined + ); + +/** + * Hook that contains CieID related logic and handlers. + * @returns CieIdHookResult: {@link CieIdHookResult} + */ +export const useCieIdApp = (): CieIdHookResult => { + const machineRef = ItwEidIssuanceMachineContext.useActorRef(); + const [authUrl, setAuthUrl] = useState>(O.none); + const [isAppLaunched, setIsAppLaunched] = useState(false); + + const sendErrorToMachine = useCallback( + (error: Error) => { + machineRef.send({ type: "error", scope: "cieid-login", error }); + }, + [machineRef] + ); + + const goBack = useCallback( + () => machineRef.send({ type: "back" }), + [machineRef] + ); + + const handleAuthenticationFailure = useCallback( + (error: unknown) => { + if (isCieIdAppError(error)) { + return error.code === "CIEID_OPERATION_CANCEL" + ? goBack() + : sendErrorToMachine(new Error(error.code)); + } + + sendErrorToMachine(convertUnknownToError(error)); + }, + [sendErrorToMachine, goBack] + ); + + const startCieIdAppAuthentication = useCallback( + (url: string) => { + // Use the new CieID app-to-app flow on Android + if (isAndroid) { + openCieIdApp(url, result => { + if (result.id === "URL") { + setAuthUrl(O.some(result.url)); + } else { + handleAuthenticationFailure(result); + } + }); + } + + // Try to directly open the CieID app on iOS + if (isIos) { + Linking.openURL(`CIEID://${url}&sourceApp=${IO_LOGIN_CIE_SOURCE_APP}`) + .then(() => setIsAppLaunched(true)) + .catch(handleAuthenticationFailure); + } + }, + [handleAuthenticationFailure] + ); + + useEffect(() => { + // Listen for a URL event to continue the flow. This is only needed on iOS, + // as the CieID app is opened with the Linking module. + const urlListenerSubscription = Linking.addEventListener( + "url", + ({ url }) => { + if (!url.startsWith(IO_LOGIN_CIE_URL_SCHEME)) { + return; + } + + const [, continueUrl] = url.split(IO_LOGIN_CIE_URL_SCHEME); + const cieIdError = extractCieIdErrorFromUrl(continueUrl); + + if (cieIdError) { + return sendErrorToMachine(new Error(cieIdError)); + } + + setAuthUrl(O.some(continueUrl)); + setIsAppLaunched(false); + } + ); + + return () => urlListenerSubscription.remove(); + }, [sendErrorToMachine]); + + return { + authUrl, + isAppLaunched, + startCieIdAppAuthentication, + handleAuthenticationFailure + }; +}; diff --git a/ts/features/itwallet/identification/screens/__tests__/ItwCieIdLoginScreen.test.tsx b/ts/features/itwallet/identification/screens/__tests__/ItwCieIdLoginScreen.test.tsx new file mode 100644 index 00000000000..dc97383f972 --- /dev/null +++ b/ts/features/itwallet/identification/screens/__tests__/ItwCieIdLoginScreen.test.tsx @@ -0,0 +1,114 @@ +/* eslint-disable functional/no-let */ +import React from "react"; +import { createStore } from "redux"; +import { fireEvent, waitFor } from "@testing-library/react-native"; +import { createActor } from "xstate"; +import _ from "lodash"; +import { Linking } from "react-native"; +import { isCieIdAvailable, openCieIdApp } from "@pagopa/io-react-native-cieid"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { itwEidIssuanceMachine } from "../../../machine/eid/machine"; +import { ITW_ROUTES } from "../../../navigation/routes"; +import ItwCieIdLoginScreen from "../cieId/ItwCieIdLoginScreen"; +import { ItwEidIssuanceMachineContext } from "../../../machine/provider"; + +jest.mock("@pagopa/io-react-native-cieid", () => ({ + isCieIdAvailable: jest.fn(), + openCieIdApp: jest.fn() +})); + +let mockIsIOS = false; +let mockIsAndroid = true; +jest.mock("../../../../../utils/platform", () => ({ + get isIos() { + return mockIsIOS; + }, + get isAndroid() { + return mockIsAndroid; + } +})); + +describe("ItwCieIdLoginScreen", () => { + afterEach(jest.clearAllMocks); + + it("should continue in the webview when CieID is not installed", () => { + (isCieIdAvailable as jest.Mock).mockImplementation(() => false); + + const { getByTestId } = renderComponent(); + const webView = getByTestId("cieid-webview"); + + fireEvent(webView, "onShouldStartLoadWithRequest", { + url: "https://idserver.servizicie.interno.gov.it/idp/login/livello2" + }); + + expect(openCieIdApp).not.toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + it("should open CieID app when it is installed (Android)", () => { + (isCieIdAvailable as jest.Mock).mockImplementation(() => true); + mockIsAndroid = true; + mockIsIOS = false; + + const { getByTestId } = renderComponent(); + const webView = getByTestId("cieid-webview"); + + fireEvent(webView, "onShouldStartLoadWithRequest", { + url: "https://idserver.servizicie.interno.gov.it/idp/login/livello2" + }); + + expect(openCieIdApp).toHaveBeenCalledTimes(1); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + it("should open CieID app when it is installed (iOS)", async () => { + (isCieIdAvailable as jest.Mock).mockImplementation(() => true); + mockIsAndroid = false; + mockIsIOS = true; + + jest.spyOn(Linking, "openURL").mockReturnValue(Promise.resolve()); + const { getByTestId } = renderComponent(); + const webView = getByTestId("cieid-webview"); + + await waitFor(() => { + fireEvent(webView, "onShouldStartLoadWithRequest", { + url: "https://idserver.servizicie.interno.gov.it/idp/login/livello2" + }); + }); + expect(openCieIdApp).not.toHaveBeenCalled(); + expect(Linking.openURL).toHaveBeenCalledTimes(1); + }); +}); + +const renderComponent = () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const logic = itwEidIssuanceMachine.provide({ + actions: { onInit: jest.fn() } + }); + + const initialSnapshot = createActor(itwEidIssuanceMachine).getSnapshot(); + return renderScreenWithNavigationStoreContext( + () => ( + + + + ), + ITW_ROUTES.IDENTIFICATION.CIE_ID.LOGIN, + {}, + createStore(appReducer, globalState as any) + ); +}; diff --git a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx index e2ddebde7a6..d09bb668178 100644 --- a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx +++ b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx @@ -39,7 +39,10 @@ import { } from "../../../machine/eid/selectors"; import { ItwParamsList } from "../../../navigation/ItwParamsList"; import LoadingScreenContent from "../../../../../components/screens/LoadingScreenContent"; -import { itwIdpHintTest } from "../../../../../config"; +import { + itWalletIssuanceRedirectUri, + itwIdpHintTest +} from "../../../../../config"; import { trackItWalletCieCardReading, trackItWalletCieCardReadingFailure, @@ -48,8 +51,6 @@ import { } from "../../../analytics"; import * as Cie from "../../components/cie"; -// This can be any URL, as long as it has http or https as its protocol, otherwise it cannot be managed by the webview. -const CIE_L3_REDIRECT_URI = "https://wallet.io.pagopa.it/index.html"; // the timeout we sleep until move to consent form screen when authentication goes well const WAIT_TIMEOUT_NAVIGATION = 1700 as Millisecond; const WAIT_TIMEOUT_NAVIGATION_ACCESSIBILITY = 5000 as Millisecond; @@ -168,7 +169,6 @@ export const ItwCieCardReaderScreen = () => { IdentificationStep.AUTHENTICATION ); const [readingState, setReadingState] = useState(); - const [webViewVisible, setWebViewVisible] = useState(true); const blueColorName = useInteractiveElementDefaultColorName(); const isScreenReaderEnabled = useScreenReaderEnabled(); @@ -243,8 +243,10 @@ export const ItwCieCardReaderScreen = () => { }; const handleCieReadSuccess = (url: string) => { - setWebViewVisible(false); // Try to hide the error page because the callback url is fake - machineRef.send({ type: "cie-identification-completed", url }); + machineRef.send({ + type: "user-identification-completed", + authRedirectUrl: url + }); }; if (isMachineLoading) { @@ -308,7 +310,7 @@ export const ItwCieCardReaderScreen = () => { { onEvent={handleCieReadEvent} onSuccess={handleCieReadSuccess} onError={handleCieReadError} - redirectUrl={CIE_L3_REDIRECT_URI} + redirectUrl={itWalletIssuanceRedirectUri} /> ) : null} diff --git a/ts/features/itwallet/identification/screens/cieId/ItwCieIdLoginScreen.tsx b/ts/features/itwallet/identification/screens/cieId/ItwCieIdLoginScreen.tsx new file mode 100644 index 00000000000..ada88d05ba7 --- /dev/null +++ b/ts/features/itwallet/identification/screens/cieId/ItwCieIdLoginScreen.tsx @@ -0,0 +1,153 @@ +import React, { memo, useCallback, useMemo, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { WebView, WebViewNavigation } from "react-native-webview"; +import { isCieIdAvailable } from "@pagopa/io-react-native-cieid"; +import * as O from "fp-ts/lib/Option"; +import { constNull, pipe } from "fp-ts/lib/function"; +import { selectAuthUrlOption } from "../../../machine/eid/selectors"; +import { ItwEidIssuanceMachineContext } from "../../../machine/provider"; +import I18n from "../../../../../i18n"; +import { originSchemasWhiteList } from "../../../../../screens/authentication/originSchemasWhiteList"; +import { itWalletIssuanceRedirectUri } from "../../../../../config"; +import { useHeaderSecondLevel } from "../../../../../hooks/useHeaderSecondLevel"; +import LoadingSpinnerOverlay from "../../../../../components/LoadingSpinnerOverlay"; +import { useCieIdApp } from "../../hooks/useCieIdApp"; + +// 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"; + +const styles = StyleSheet.create({ + webViewWrapper: { flex: 1 } +}); + +const isAuthenticationUrl = (url: string) => { + const authUrlRegex = /\/(livello1|livello2|nextUrl|openApp)(\/|\?|$)/; + return authUrlRegex.test(url); +}; + +/** + * 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 ItwCieIdLoginScreen = () => { + const initialAuthUrl = + ItwEidIssuanceMachineContext.useSelector(selectAuthUrlOption); + const machineRef = ItwEidIssuanceMachineContext.useActorRef(); + const [isWebViewLoading, setWebViewLoading] = useState(true); + + const { + authUrl, + isAppLaunched, + startCieIdAppAuthentication, + handleAuthenticationFailure + } = useCieIdApp(); + + const webViewSource = pipe( + authUrl, + O.alt(() => initialAuthUrl) + ); + + useHeaderSecondLevel({ + title: I18n.t("features.itWallet.identification.mode.title"), + supportRequest: false + }); + + const goBack = useCallback( + () => machineRef.send({ type: "back" }), + [machineRef] + ); + + const onLoadEnd = useCallback(() => { + // When CieId app-to-app flow is enabled, stop loading only after we got + // the authUrl from CieId app, so the user doesn't see the login screen. + if (isCieIdAvailable() ? !!authUrl : true) { + setWebViewLoading(false); + } + }, [authUrl]); + + const handleShouldStartLoading = useCallback( + (event: WebViewNavigation): boolean => { + const url = event.url; + + // When CieID is available, use a flow that launches the app + if (isAuthenticationUrl(url) && isCieIdAvailable()) { + startCieIdAppAuthentication(url); + return false; + } + + // When CieID is not available, fallback to the regular webview + return true; + }, + [startCieIdAppAuthentication] + ); + + 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: "user-identification-completed", + authRedirectUrl + }); + } + }, + [machineRef] + ); + + const content = useMemo( + () => + pipe( + webViewSource, + O.fold(constNull, (url: string) => ( + + )) + ), + [ + webViewSource, + handleNavigationStateChange, + handleShouldStartLoading, + handleAuthenticationFailure, + onLoadEnd + ] + ); + + return ( + + {content} + + ); +}; + +export default memo(ItwCieIdLoginScreen); diff --git a/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx b/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx index bb443e021fe..6a4113d2611 100644 --- a/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx +++ b/ts/features/itwallet/identification/screens/spid/ItwSpidIdpLoginScreen.tsx @@ -80,7 +80,7 @@ const ItwSpidIdpLoginScreen = () => { if (isIssuanceRedirect) { machineRef.send({ - type: "spid-identification-completed", + type: "user-identification-completed", authRedirectUrl }); } diff --git a/ts/features/itwallet/issuance/components/ItwIssuanceLoadingScreen.tsx b/ts/features/itwallet/issuance/components/ItwIssuanceLoadingScreen.tsx deleted file mode 100644 index 382dd624c77..00000000000 --- a/ts/features/itwallet/issuance/components/ItwIssuanceLoadingScreen.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Linking, View } from "react-native"; -import { - Body, - ButtonLink, - ContentWrapper, - VSpacer -} from "@pagopa/io-app-design-system"; -import LoadingScreenContent from "../../../../components/screens/LoadingScreenContent"; -import I18n from "../../../../i18n"; -import { selectIsCieIdEidRequest } from "../../machine/eid/selectors"; -import { ItwEidIssuanceMachineContext } from "../../machine/provider"; - -const CieIdConnectionContent = () => { - const machineRef = ItwEidIssuanceMachineContext.useActorRef(); - return ( - - - - - {I18n.t("features.itWallet.identification.loading.cieId.subtitle")} - - - - machineRef.send({ type: "abort" })} - /> - - - - ); -}; - -/** - * This loading screen component displays a cancel button during CieID identification - * to allow the user to abort the flow after the external browser was opened. - */ -export const ItwIssuanceLoadingScreen = () => { - const [callbackUrlReceived, setCallbackUrlReceived] = useState(false); - - const isCieIdEidRequest = ItwEidIssuanceMachineContext.useSelector( - selectIsCieIdEidRequest - ); - - useEffect(() => { - if (!isCieIdEidRequest) { - return; - } - - const listener = Linking.addEventListener("url", ({ url }) => - setCallbackUrlReceived(!!url) - ); - return () => { - listener.remove(); - }; - }, [isCieIdEidRequest]); - - const isWaitingForCieIdIdentification = - isCieIdEidRequest && !callbackUrlReceived; - - return ( - - {isWaitingForCieIdIdentification && } - - ); -}; diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx index 91d3815b199..695268a2ec1 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx @@ -29,6 +29,7 @@ import { useCredentialEventsTracking } from "../hooks/useCredentialEventsTrackin import { useIOSelector } from "../../../../store/hooks"; import { itwDeferredIssuanceScreenContentSelector } from "../../../../store/reducers/backendStatus/remoteConfig"; import { getFullLocale } from "../../../../utils/locale"; +import { serializeFailureReason } from "../../common/utils/itwStoreUtils"; export const ItwIssuanceCredentialFailureScreen = () => { const failureOption = @@ -88,7 +89,7 @@ const ContentView = ({ failure }: ContentViewProps) => { }; useDebugInfo({ - failure + failure: serializeFailureReason(failure) }); const getOperationResultScreenContentProps = diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceEidFailureScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceEidFailureScreen.tsx index b3d7222903d..b69830b87bb 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceEidFailureScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceEidFailureScreen.tsx @@ -22,6 +22,7 @@ import { useItwDisableGestureNavigation } from "../../common/hooks/useItwDisable import { KoState, trackWalletCreationFailed } from "../../analytics"; import { openWebUrl } from "../../../../utils/url"; import { useEidEventsTracking } from "../hooks/useEidEventsTracking"; +import { serializeFailureReason } from "../../common/utils/itwStoreUtils"; const FAQ_URL = "https://io.italia.it/documenti-su-io/faq/#n1_12"; @@ -47,7 +48,7 @@ const ContentView = ({ failure }: ContentViewProps) => { const toast = useIOToast(); useDebugInfo({ - failure + failure: serializeFailureReason(failure) }); const closeIssuance = (errorConfig: KoState) => { diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceEidPreviewScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceEidPreviewScreen.tsx index 68d39cb53e6..571dc391f13 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceEidPreviewScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceEidPreviewScreen.tsx @@ -36,8 +36,8 @@ import { trackSaveCredentialToWallet } from "../../analytics"; import { ItwCredentialPreviewClaimsList } from "../components/ItwCredentialPreviewClaimsList"; -import { ItwIssuanceLoadingScreen } from "../components/ItwIssuanceLoadingScreen"; import IOMarkdown from "../../../../components/IOMarkdown"; +import LoadingScreenContent from "../../../../components/screens/LoadingScreenContent"; export const ItwIssuanceEidPreviewScreen = () => { const eidOption = ItwEidIssuanceMachineContext.useSelector(selectEidOption); @@ -51,7 +51,9 @@ export const ItwIssuanceEidPreviewScreen = () => { // If there is no eID in the context (None), we can safely assume the issuing phase is still ongoing. // A None eID cannot be stored in the context, as any issuance failure causes the machine to transition // to the Failure state. - () => , + () => ( + + ), eid => ) ); diff --git a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts index a7757cce8a9..c7d51a9c7ab 100644 --- a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts +++ b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts @@ -1,18 +1,11 @@ import { waitFor } from "@testing-library/react-native"; import _ from "lodash"; -import { - assign, - createActor, - fromPromise, - StateFrom, - waitFor as waitForActor -} from "xstate"; +import { assign, createActor, fromPromise, StateFrom } 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, StartAuthFlowActorParams @@ -45,6 +38,7 @@ describe("itwEidIssuanceMachine", () => { const navigateToCiePinScreen = jest.fn(); const navigateToCieReadCardScreen = jest.fn(); const navigateToNfcInstructionsScreen = jest.fn(); + const navigateToCieIdLoginScreen = jest.fn(); const storeIntegrityKeyTag = jest.fn(); const storeWalletInstanceAttestation = jest.fn(); const storeEidCredential = jest.fn(); @@ -60,7 +54,6 @@ describe("itwEidIssuanceMachine", () => { const getWalletAttestation = jest.fn(); const requestEid = jest.fn(); const startAuthFlow = jest.fn(); - const getAuthRedirectUrl = jest.fn(); const issuedEidMatchesAuthenticatedUser = jest.fn(); const isSessionExpired = jest.fn(); @@ -87,6 +80,7 @@ describe("itwEidIssuanceMachine", () => { navigateToCiePinScreen, navigateToCieReadCardScreen, navigateToNfcInstructionsScreen, + navigateToCieIdLoginScreen, storeIntegrityKeyTag, storeWalletInstanceAttestation, storeEidCredential, @@ -111,9 +105,6 @@ describe("itwEidIssuanceMachine", () => { requestEid: fromPromise( requestEid ), - getAuthRedirectUrl: fromPromise( - getAuthRedirectUrl - ), startAuthFlow: fromPromise< AuthenticationContext, StartAuthFlowActorParams @@ -250,12 +241,12 @@ describe("itwEidIssuanceMachine", () => { expect(actor.getSnapshot().value).toStrictEqual({ UserIdentification: { - Spid: "SpidLoginIdentificationCompleted" + Spid: "CompletingSpidAuthFlow" } }); actor.send({ - type: "spid-identification-completed", + type: "user-identification-completed", authRedirectUrl: "http://test.it" }); @@ -312,8 +303,7 @@ describe("itwEidIssuanceMachine", () => { /** 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({})); + requestEid.mockImplementation(() => Promise.resolve({})); const initialSnapshot: MachineSnapshot = createActor( itwEidIssuanceMachine @@ -344,7 +334,6 @@ describe("itwEidIssuanceMachine", () => { } }); - expect(actor.getSnapshot().tags).toStrictEqual(new Set()); expect(actor.getSnapshot().context).toStrictEqual({ ...InitialContext, integrityKeyTag: T_INTEGRITY_KEY, @@ -354,26 +343,29 @@ describe("itwEidIssuanceMachine", () => { abortController: new AbortController() } }); - expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); + expect(navigateToCieIdLoginScreen).toHaveBeenCalledTimes(1); - const cieIDBuildAuthRedirectUrlState = await waitForActor(actor, snap => - snap.matches({ - UserIdentification: { CieID: "CieIDBuildAuthRedirectUrl" } - }) - ); - expect(cieIDBuildAuthRedirectUrlState.value).toStrictEqual({ - UserIdentification: { CieID: "CieIDBuildAuthRedirectUrl" } + await waitFor(() => expect(startAuthFlow).toHaveBeenCalledTimes(1)); + + expect(actor.getSnapshot().value).toStrictEqual({ + UserIdentification: { CieID: "CompletingCieIDAuthFlow" } }); - expect(getAuthRedirectUrl).toHaveBeenCalledTimes(1); + actor.send({ + type: "user-identification-completed", + authRedirectUrl: "http://cieid.test.it" + }); - const requestingEidState = await waitForActor(actor, snap => - snap.matches({ Issuance: "RequestingEid" }) - ); - expect(requestingEidState.value).toStrictEqual({ + expect(actor.getSnapshot().value).toStrictEqual({ Issuance: "RequestingEid" }); + expect(actor.getSnapshot().context).toMatchObject({ + authenticationContext: { + callbackUrl: "http://cieid.test.it" + } + }); + expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); expect(requestEid).toHaveBeenCalledTimes(1); /** Last part is the same as the previous test */ @@ -473,8 +465,8 @@ describe("itwEidIssuanceMachine", () => { */ actor.send({ - type: "cie-identification-completed", - url: "http://test.it" + type: "user-identification-completed", + authRedirectUrl: "http://test.it" }); expect(actor.getSnapshot().value).toStrictEqual({ @@ -870,6 +862,7 @@ describe("itwEidIssuanceMachine", () => { }); it("Should fail when requesting eID (user identification or eID request failed)", async () => { + startAuthFlow.mockImplementation(() => Promise.resolve({})); requestEid.mockImplementation(() => Promise.reject({})); const initialSnapshot: MachineSnapshot = createActor( @@ -894,8 +887,24 @@ describe("itwEidIssuanceMachine", () => { CieID: "StartingCieIDAuthFlow" } }); - expect(actor.getSnapshot().tags).toStrictEqual(new Set()); - expect(navigateToEidPreviewScreen).toHaveBeenCalledTimes(1); + expect(navigateToCieIdLoginScreen).toHaveBeenCalledTimes(1); + + // Start the issuance flow + + await waitFor(() => expect(startAuthFlow).toHaveBeenCalledTimes(1)); + + expect(actor.getSnapshot().value).toStrictEqual({ + UserIdentification: { CieID: "CompletingCieIDAuthFlow" } + }); + + actor.send({ + type: "user-identification-completed", + authRedirectUrl: "http://cieid.test.it" + }); + + expect(actor.getSnapshot().value).toStrictEqual({ + Issuance: "RequestingEid" + }); 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 77e92063130..d7581cc2b7a 100644 --- a/ts/features/itwallet/machine/eid/actions.ts +++ b/ts/features/itwallet/machine/eid/actions.ts @@ -62,6 +62,12 @@ export const createEidIssuanceActionsImplementation = ( }); }, + navigateToCieIdLoginScreen: () => { + navigation.navigate(ITW_ROUTES.MAIN, { + screen: ITW_ROUTES.IDENTIFICATION.CIE_ID.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 d79ffccf6b5..e565dc726d3 100644 --- a/ts/features/itwallet/machine/eid/actors.ts +++ b/ts/features/itwallet/machine/eid/actors.ts @@ -12,7 +12,6 @@ import { registerWalletInstance } from "../../common/utils/itwAttestationUtils"; import * as issuanceUtils from "../../common/utils/itwIssuanceUtils"; -import { openUrlAndListenForAuthRedirect } from "../../common/utils/itwOpenUrlAndListenForRedirect"; import { revokeCurrentWalletInstance } from "../../common/utils/itwRevocationUtils"; import { pollForStoreValue } from "../../common/utils/itwStoreUtils"; import { StoredCredential } from "../../common/utils/itwTypesUtils"; @@ -42,12 +41,6 @@ export type GetWalletAttestationActorParams = { integrityKeyTag: string | undefined; }; -export type GetAuthRedirectUrlActorParam = { - redirectUri: string | undefined; - authUrl: string | undefined; - identification: IdentificationContext | undefined; -}; - export const createEidIssuanceActorsImplementation = ( store: ReturnType ) => ({ @@ -153,25 +146,6 @@ export const createEidIssuanceActorsImplementation = ( } ), - 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/events.ts b/ts/features/itwallet/machine/eid/events.ts index 5cc92058a6d..4868c1ac44b 100644 --- a/ts/features/itwallet/machine/eid/events.ts +++ b/ts/features/itwallet/machine/eid/events.ts @@ -46,13 +46,8 @@ export type CiePinEntered = { pin: string; }; -export type CieIdentificationCompleted = { - type: "cie-identification-completed"; - url: string; -}; - -export type SpidIdentificationCompleted = { - type: "spid-identification-completed"; +export type UserIdentificationCompleted = { + type: "user-identification-completed"; authRedirectUrl: string; }; @@ -80,10 +75,11 @@ export type RevokeWalletInstance = { type: "revoke-wallet-instance"; }; -export type Error = { +export type ExternalErrorEvent = { 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" | "spid-login"; + scope: "ipzs-privacy" | "spid-login" | "cieid-login"; + error?: Error; }; export type EidIssuanceEvents = @@ -94,8 +90,7 @@ export type EidIssuanceEvents = | SelectIdentificationMode | SelectSpidIdp | CiePinEntered - | CieIdentificationCompleted - | SpidIdentificationCompleted + | UserIdentificationCompleted | AddToWallet | GoToWallet | AddNewCredential @@ -106,4 +101,4 @@ export type EidIssuanceEvents = | Abort | RevokeWalletInstance | ErrorActorEvent - | Error; + | ExternalErrorEvent; diff --git a/ts/features/itwallet/machine/eid/guards.ts b/ts/features/itwallet/machine/eid/guards.ts index 371790d6e6c..78cb8f805f2 100644 --- a/ts/features/itwallet/machine/eid/guards.ts +++ b/ts/features/itwallet/machine/eid/guards.ts @@ -5,7 +5,6 @@ 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"; @@ -38,9 +37,6 @@ export const createEidIssuanceGuardsImplementation = ( isSessionExpired: ({ event }: { event: EidIssuanceEvents }) => "error" in event && event.error instanceof ItwSessionExpiredError, - isOperationAborted: ({ event }: { event: EidIssuanceEvents }) => - "error" in event && event.error instanceof ItwOperationAbortedError, - hasValidWalletInstanceAttestation: ({ context }: { context: Context }) => pipe( O.fromNullable(context.walletInstanceAttestation), diff --git a/ts/features/itwallet/machine/eid/machine.ts b/ts/features/itwallet/machine/eid/machine.ts index 510ea417481..3da95b4605c 100644 --- a/ts/features/itwallet/machine/eid/machine.ts +++ b/ts/features/itwallet/machine/eid/machine.ts @@ -1,10 +1,9 @@ import _ from "lodash"; -import { assign, fromPromise, not, or, setup } from "xstate"; +import { assertEvent, assign, fromPromise, not, setup } from "xstate"; import { assert } from "../../../../utils/assert"; import { StoredCredential } from "../../common/utils/itwTypesUtils"; import { ItwTags } from "../tags"; import { - GetAuthRedirectUrlActorParam, GetWalletAttestationActorParams, type RequestEidActorParams, StartAuthFlowActorParams @@ -33,6 +32,7 @@ export const itwEidIssuanceMachine = setup({ navigateToIdentificationModeScreen: notImplemented, navigateToIdpSelectionScreen: notImplemented, navigateToSpidLoginScreen: notImplemented, + navigateToCieIdLoginScreen: notImplemented, navigateToEidPreviewScreen: notImplemented, navigateToSuccessScreen: notImplemented, navigateToFailureScreen: notImplemented, @@ -54,7 +54,24 @@ export const itwEidIssuanceMachine = setup({ trackWalletInstanceCreation: notImplemented, trackWalletInstanceRevocation: notImplemented, setFailure: assign(({ event }) => ({ failure: mapEventToFailure(event) })), - onInit: notImplemented + onInit: notImplemented, + /** + * Save the final redirect url in the machine context for later reuse. + * This action is the same for the three identification methods. + */ + completeUserIdentification: assign(({ context, event }) => { + assertEvent(event, "user-identification-completed"); + assert( + context.authenticationContext, + "authenticationContext must be defined when completing auth flow" + ); + return { + authenticationContext: { + ...context.authenticationContext, + callbackUrl: event.authRedirectUrl + } + }; + }) }, actors: { createWalletInstance: fromPromise(notImplemented), @@ -68,9 +85,6 @@ export const itwEidIssuanceMachine = setup({ ), startAuthFlow: fromPromise( notImplemented - ), - getAuthRedirectUrl: fromPromise( - notImplemented ) }, guards: { @@ -271,7 +285,7 @@ export const itwEidIssuanceMachine = setup({ StartingCieIDAuthFlow: { entry: [ assign(() => ({ authenticationContext: undefined })), - { type: "navigateToEidPreviewScreen" } + { type: "navigateToCieIdLoginScreen" } ], invoke: { src: "startAuthFlow", @@ -283,7 +297,7 @@ export const itwEidIssuanceMachine = setup({ actions: assign(({ event }) => ({ authenticationContext: event.output })), - target: "CieIDBuildAuthRedirectUrl" + target: "CompletingCieIDAuthFlow" }, onError: [ { @@ -291,57 +305,17 @@ export const itwEidIssuanceMachine = setup({ 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" - } - ] - }, + CompletingCieIDAuthFlow: { on: { - abort: { actions: "abortIdentification" }, - back: { - target: - "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + "user-identification-completed": { + target: "Completed", + actions: "completeUserIdentification" + }, + error: { + actions: "setFailure", + target: "#itwEidIssuanceMachine.Failure" } } }, @@ -349,6 +323,11 @@ export const itwEidIssuanceMachine = setup({ type: "final" } }, + on: { + back: { + target: "#itwEidIssuanceMachine.UserIdentification.ModeSelection" + } + }, onDone: { target: "#itwEidIssuanceMachine.UserIdentification.Completed" } @@ -388,7 +367,7 @@ export const itwEidIssuanceMachine = setup({ actions: assign(({ event }) => ({ authenticationContext: event.output })), - target: "SpidLoginIdentificationCompleted" + target: "CompletingSpidAuthFlow" }, onError: { actions: "setFailure", @@ -401,22 +380,11 @@ export const itwEidIssuanceMachine = setup({ } } }, - SpidLoginIdentificationCompleted: { + CompletingSpidAuthFlow: { on: { - "spid-identification-completed": { + "user-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 - } - }; - }) + actions: "completeUserIdentification" }, back: { target: "IdpSelection" @@ -511,20 +479,9 @@ export const itwEidIssuanceMachine = setup({ description: "Read the CIE card and get back a url to continue the PID issuing flow. This state also handles errors when reading the card.", on: { - "cie-identification-completed": { + "user-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.url - } - }; - }) + actions: "completeUserIdentification" }, close: { target: "#itwEidIssuanceMachine.UserIdentification" diff --git a/ts/features/itwallet/navigation/ItwParamsList.ts b/ts/features/itwallet/navigation/ItwParamsList.ts index 2059e5bc5e0..3654b6fa7bd 100644 --- a/ts/features/itwallet/navigation/ItwParamsList.ts +++ b/ts/features/itwallet/navigation/ItwParamsList.ts @@ -17,6 +17,7 @@ export type ItwParamsList = { // IDENTIFICATION SPID [ITW_ROUTES.IDENTIFICATION.IDP_SELECTION]: undefined; [ITW_ROUTES.IDENTIFICATION.SPID.LOGIN]: undefined; + [ITW_ROUTES.IDENTIFICATION.CIE_ID.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 488070b6b78..9be76c3d77d 100644 --- a/ts/features/itwallet/navigation/ItwStackNavigator.tsx +++ b/ts/features/itwallet/navigation/ItwStackNavigator.tsx @@ -36,6 +36,7 @@ import { ItwPresentationCredentialDetailScreen } from "../presentation/screens/I import { ItwIssuanceCredentialAsyncContinuationScreen } from "../issuance/screens/ItwIssuanceCredentialAsyncContinuationScreen"; import ItwIpzsPrivacyScreen from "../discovery/screens/ItwIpzsPrivacyScreen"; import ItwSpidIdpLoginScreen from "../identification/screens/spid/ItwSpidIdpLoginScreen"; +import ItwCieIdLoginScreen from "../identification/screens/cieId/ItwCieIdLoginScreen"; import { ItwPresentationCredentialFiscalCodeModal } from "../presentation/screens/ItwPresentationCredentialFiscalCodeModal"; import { ItwCredentialTrustmarkScreen } from "../trustmark/screens/ItwCredentialTrustmarkScreen"; import { ItwAlreadyActiveScreen } from "../discovery/screens/ItwAlreadyActiveScreen"; @@ -102,6 +103,10 @@ const InnerNavigator = () => { name={ITW_ROUTES.IDENTIFICATION.SPID.LOGIN} component={ItwSpidIdpLoginScreen} /> + {/* IDENTIFICATION CIE + PIN */}