diff --git a/package.json b/package.json index 858c7fae0ff..dd05eddf3f6 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "@pagopa/io-react-native-jwt": "^1.3.0", "@pagopa/io-react-native-login-utils": "^1.0.8", "@pagopa/io-react-native-secure-storage": "^0.2.0", - "@pagopa/io-react-native-wallet": "^0.26.0", + "@pagopa/io-react-native-wallet": "^0.27.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/identification/components/cie/WebViewComponent.tsx b/ts/features/itwallet/identification/components/cie/WebViewComponent.tsx new file mode 100644 index 00000000000..ac3d80cf2d0 --- /dev/null +++ b/ts/features/itwallet/identification/components/cie/WebViewComponent.tsx @@ -0,0 +1,220 @@ +import React, { createRef } from "react"; +import { Platform } from "react-native"; +import { WebView } from "react-native-webview"; +import type { + WebViewErrorEvent, + WebViewHttpErrorEvent, + WebViewMessageEvent, + WebViewNavigation, + WebViewNavigationEvent +} from "react-native-webview/lib/WebViewTypes"; +import { CieError, CieErrorType } from "./error"; +import { CieEvent } from "./event"; +import { startCieAndroid, startCieiOS, type ContinueWithUrl } from "./manager"; + +const AUTH_LINK_PATTERN = "lettura carta"; + +/* To obtain the authentication URL on CIE L3 it is necessary to take the + * link contained in the "Entra con lettura carta CIE" button. + * This link can then be used on CieManager. + * This javascript code takes the link in question and sends it to the react native function via postMessage + */ +const injectedJavaScript = ` + (function() { + function sendDocumentContent() { + const idpAuthUrl = [...document.querySelectorAll("a")] + .filter(a => a.textContent.toLowerCase().includes("${AUTH_LINK_PATTERN}")) + .map(a=>a.href)[0]; + + if(idpAuthUrl) { + window.ReactNativeWebView.postMessage(idpAuthUrl); + } + } + if (document.readyState === 'complete') { + sendDocumentContent(); + } else { + window.addEventListener('load', sendDocumentContent); + } + })(); + true; + `; + +export type OnCieSuccess = (url: string) => void; +export type OnCieError = (e: CieError) => void; +export type OnCieEvent = (e: CieEvent) => void; + +type WebViewComponentProps = { + authUrl: string; + onSuccess: OnCieSuccess; + onError: OnCieError; + pin: string; + useUat: boolean; + redirectUrl: string; + onEvent: OnCieEvent; +}; + +/* + * To make sure the server recognizes the client as valid iPhone device (iOS only) we use a custom header + * on Android it is not required. + */ +const iOSUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"; +const defaultUserAgent = Platform.select({ + ios: iOSUserAgent, + default: undefined +}); + +const webView = createRef(); + +/** + * WebViewComponent + * + * Component that manages authentication via CIE L3 (NFC+PIN) based on WebView (react-native-webview). + * In particular, once rendered, it makes a series of calls to the authUrl in the WebView, + * extrapolates the authentication URL necessary for CieManager to sign via certificate + * and calls the CIE SDK which is responsible for starting card reading via NFC. + * At the end of the reading, a redirect is made in the WebView towards the page that asks + * the user for consent to send the data to the Service Provider. This moment can be captured + * via the onUserInteraction parameter. When the user allows or denies their consent, + * a redirect is made to the URL set by the Service Provider. + * This url can be configured using the redirectUrl parameter which allows you to close the WebView. + * The event can then be captured via the onSuccess parameter. + * + * @param {WebViewComponentProps} props - Parameters required by the component. + * @param {string} params.authUrl -The authentication URL of the Service Provider to which to authenticate. + * @param {boolean} params.useUat - If set to true it uses the CIE testing environment. + * @param {string} params.pin - CIE pin for use with NFC reading. + * @param {Function} params.onError - Callback function in case of error. The function is passed the Error parameter. + * @param {Function} params.onSuccess - Callback at the end of authentication to which the redirect URL including parameters is passed. + * @param {string} params.redirectUrl - Redirect URL set by the Service Provider. It is used to stop the flow and return to the calling function via onSuccess. + * @param {Function} params.onEvent - Callback function that is called whenever there is a new CieEvent from the CIE reader. + * @returns {JSX.Element} - The configured component with WebView. + */ +export const WebViewComponent = (props: WebViewComponentProps) => { + const [webViewUrl, setWebViewUrl] = React.useState(props.authUrl); + const [isCardReadingFinished, setCardReadingFinished] = React.useState(false); + const cieCompletedEventEmitted = React.useRef(false); + + /* + * Once the reading of the card with NFC is finished, it is necessary + * to change the URL of the WebView by redirecting to the URL returned by + * CieManager to allow the user to continue with the consent authorization + * */ + const continueWithUrl: ContinueWithUrl = (callbackUrl: string) => { + setCardReadingFinished(true); + setWebViewUrl(callbackUrl); + }; + + // This function is called from the injected javascript code (postMessage). Which receives the authentication URL + const handleMessage = async (event: WebViewMessageEvent) => { + const cieAuthorizationUri = event.nativeEvent.data; + const startCie = Platform.select({ + ios: startCieiOS, + default: startCieAndroid + }); + await startCie( + props.useUat, + props.pin, + props.onError, + props.onEvent, + cieAuthorizationUri, + continueWithUrl + ); + }; + + // This function is called when authentication with CIE ends and the SP URL containing code and state is returned + const handleShouldStartLoading = + (onSuccess: OnCieSuccess, redirectUrl: string) => + (event: WebViewNavigation): boolean => { + if (isCardReadingFinished && event.url.includes(redirectUrl)) { + onSuccess(event.url); + return false; + } else { + return true; + } + }; + + const handleOnLoadEnd = + (onError: OnCieError, onCieEvent: OnCieEvent) => + (e: WebViewNavigationEvent | WebViewErrorEvent) => { + const eventTitle = e.nativeEvent.title.toLowerCase(); + if ( + eventTitle === "pagina web non disponibile" || + // On Android, if we attempt to access the idp URL twice, + // we are presented with an error page titled "ERROR". + eventTitle === "errore" + ) { + handleOnError(onError)(new Error(eventTitle)); + } + + /** + * At the end of loading the page, if the card has already been read + * then the WebView has loaded the page to ask the user for consent, + * so send the completed event. + * The ref here prevents the "read completed" event being fired multiple times + * when the webview finishes loading the second url. + */ + if (isCardReadingFinished && !cieCompletedEventEmitted.current) { + onCieEvent(CieEvent.completed); + // eslint-disable-next-line functional/immutable-data + cieCompletedEventEmitted.current = true; + } + }; + + const handleOnError = + (onError: OnCieError) => + (e: WebViewErrorEvent | WebViewHttpErrorEvent | Error): void => { + const error = e as Error; + const webViewError = e as WebViewErrorEvent; + const webViewHttpError = e as WebViewHttpErrorEvent; + if (webViewHttpError.nativeEvent.statusCode) { + const { description, statusCode } = webViewHttpError.nativeEvent; + onError( + new CieError({ + message: `WebView http error: ${description} with status code: ${statusCode}`, + type: CieErrorType.WEB_VIEW_ERROR + }) + ); + } else if (webViewError.nativeEvent) { + const { code, description } = webViewError.nativeEvent; + onError( + new CieError({ + message: `WebView error: ${description} with code: ${code}`, + type: CieErrorType.WEB_VIEW_ERROR + }) + ); + } else if (error.message !== undefined) { + onError( + new CieError({ + message: `${error.message}`, + type: CieErrorType.WEB_VIEW_ERROR + }) + ); + } else { + onError( + new CieError({ + message: "An error occurred in the WebView", + type: CieErrorType.WEB_VIEW_ERROR + }) + ); + } + }; + + return ( + + ); +}; diff --git a/ts/features/itwallet/identification/components/cie/error.ts b/ts/features/itwallet/identification/components/cie/error.ts new file mode 100644 index 00000000000..0609e7fa09e --- /dev/null +++ b/ts/features/itwallet/identification/components/cie/error.ts @@ -0,0 +1,58 @@ +export enum CieErrorType { + GENERIC, + TAG_NOT_VALID, + WEB_VIEW_ERROR, + NFC_ERROR, + AUTHENTICATION_ERROR, + PIN_ERROR, + PIN_LOCKED, + CERTIFICATE_ERROR +} + +interface BaseCieError { + message: string; + type?: CieErrorType; +} + +interface PinErrorOptions extends BaseCieError { + type: CieErrorType.PIN_ERROR; + attemptsLeft: number; +} + +interface NonPinErrorOptions extends BaseCieError { + type?: Exclude; + attemptsLeft?: number; +} + +type ErrorOptions = PinErrorOptions | NonPinErrorOptions; + +export class CieError extends Error { + public type: CieErrorType; + public attemptsLeft?: number; + constructor(options: ErrorOptions) { + super(options.message); + + if (options.type) { + this.type = options.type; + } else { + this.type = CieErrorType.GENERIC; + } + + if (this.type === CieErrorType.PIN_ERROR) { + this.attemptsLeft = options.attemptsLeft; + } else if (this.type === CieErrorType.PIN_LOCKED) { + this.attemptsLeft = 0; + } + + this.name = this.constructor.name; + } + + toString(): string { + return JSON.stringify({ + name: this.name, + type: CieErrorType[this.type], + message: this.message, + attemptsLeft: this.attemptsLeft + }); + } +} diff --git a/ts/features/itwallet/identification/components/cie/event.ts b/ts/features/itwallet/identification/components/cie/event.ts new file mode 100644 index 00000000000..3dd6bd9d63c --- /dev/null +++ b/ts/features/itwallet/identification/components/cie/event.ts @@ -0,0 +1,5 @@ +export enum CieEvent { + "reading" = "reading", + "completed" = "completed", + "waiting_card" = "waiting_card" +} diff --git a/ts/features/itwallet/identification/components/cie/index.ts b/ts/features/itwallet/identification/components/cie/index.ts new file mode 100644 index 00000000000..e2f91a584d6 --- /dev/null +++ b/ts/features/itwallet/identification/components/cie/index.ts @@ -0,0 +1,4 @@ +import { WebViewComponent } from "./WebViewComponent"; +import { CieError, CieErrorType } from "./error"; +import { CieEvent } from "./event"; +export { WebViewComponent, CieError, CieErrorType, CieEvent }; diff --git a/ts/features/itwallet/identification/components/cie/manager.ts b/ts/features/itwallet/identification/components/cie/manager.ts new file mode 100644 index 00000000000..dce18ed9408 --- /dev/null +++ b/ts/features/itwallet/identification/components/cie/manager.ts @@ -0,0 +1,183 @@ +import cieManager, { Event as CEvent } from "@pagopa/react-native-cie"; +import { Platform } from "react-native"; +import { OnCieEvent, OnCieError } from "./WebViewComponent"; +import { CieError, CieErrorType } from "./error"; +import { CieEvent } from "./event"; + +const BASE_UAT_URL = "https://collaudo.idserver.servizicie.interno.gov.it/idp/"; + +export type ContinueWithUrl = (callbackUrl: string) => void; + +export const startCieAndroid = async ( + useCieUat: boolean, + ciePin: string, + onError: OnCieError, + onEvent: OnCieEvent, + cieAuthorizationUri: string, + continueWithUrl: ContinueWithUrl +) => { + try { + cieManager.removeAllListeners(); + cieManager + .start() + .then(async () => { + cieManager.onEvent(handleCieEvent(onError, onEvent)); + cieManager.onError((e: Error) => + onError(new CieError({ message: e.message })) + ); + cieManager.onSuccess(handleCieSuccess(continueWithUrl)); + await cieManager.setPin(ciePin); + cieManager.setAuthenticationUrl(cieAuthorizationUri); + cieManager.enableLog(useCieUat); + cieManager.setCustomIdpUrl(useCieUat ? getCieUatEndpoint() : null); + await cieManager.startListeningNFC(); + onEvent(CieEvent.waiting_card); + }) + .catch(onError); + } catch (e) { + onError( + new CieError({ + message: `Unable to start CIE NFC manager on Android: ${e}`, + type: CieErrorType.NFC_ERROR + }) + ); + } +}; + +export const startCieiOS = async ( + useCieUat: boolean, + ciePin: string, + onError: OnCieError, + onEvent: OnCieEvent, + cieAuthorizationUri: string, + continueWithUrl: ContinueWithUrl +) => { + try { + cieManager.removeAllListeners(); + cieManager.onEvent(handleCieEvent(onError, onEvent)); + cieManager.onError((e: Error) => + onError(new CieError({ message: e.message })) + ); + cieManager.onSuccess(handleCieSuccess(continueWithUrl)); + cieManager.enableLog(useCieUat); + cieManager.setCustomIdpUrl(useCieUat ? getCieUatEndpoint() : null); + await cieManager.setPin(ciePin); + cieManager.setAuthenticationUrl(cieAuthorizationUri); + cieManager + .start() + .then(async () => { + await cieManager.startListeningNFC(); + onEvent(CieEvent.waiting_card); + }) + .catch(onError); + } catch (e) { + onError( + new CieError({ + message: `Unable to start CIE NFC manager on iOS: ${e}`, + type: CieErrorType.NFC_ERROR + }) + ); + } +}; + +const handleCieEvent = + (onError: OnCieError, onEvent: OnCieEvent) => (event: CEvent) => { + switch (event.event) { + // Reading starts + case "ON_TAG_DISCOVERED": + onEvent(CieEvent.reading); + break; + // "Function not supported" seems to be TAG_ERROR_NFC_NOT_SUPPORTED + // for the iOS SDK + case "Function not supported" as unknown: + case "TAG_ERROR_NFC_NOT_SUPPORTED": + case "ON_TAG_DISCOVERED_NOT_CIE": + onError( + new CieError({ + message: `Invalid CIE card: ${event.event}`, + type: CieErrorType.TAG_NOT_VALID + }) + ); + break; + case "AUTHENTICATION_ERROR": + case "ON_NO_INTERNET_CONNECTION": + onError( + new CieError({ + message: `Authentication error or no internet connection`, + type: CieErrorType.AUTHENTICATION_ERROR + }) + ); + break; + case "EXTENDED_APDU_NOT_SUPPORTED": + onError( + new CieError({ + message: `APDU not supported`, + type: CieErrorType.NFC_ERROR + }) + ); + break; + case "Transmission Error": + case "ON_TAG_LOST": + onError( + new CieError({ + message: `Trasmission error`, + type: CieErrorType.NFC_ERROR + }) + ); + break; + + // The card is temporarily locked. Unlock is available by CieID app + case "PIN Locked": + case "ON_CARD_PIN_LOCKED": + onError( + new CieError({ + message: `PIN locked`, + type: CieErrorType.PIN_LOCKED + }) + ); + break; + case "ON_PIN_ERROR": + onError( + new CieError({ + message: `PIN locked`, + type: CieErrorType.PIN_ERROR, + attemptsLeft: event.attemptsLeft + }) + ); + break; + + // CIE is Expired or Revoked + case "CERTIFICATE_EXPIRED": + onError( + new CieError({ + message: `Certificate expired`, + type: CieErrorType.CERTIFICATE_ERROR + }) + ); + break; + case "CERTIFICATE_REVOKED": + onError( + new CieError({ + message: `Certificate revoked`, + type: CieErrorType.CERTIFICATE_ERROR + }) + ); + + break; + + default: + break; + } + }; + +const handleCieSuccess = + (continueWithUrl: ContinueWithUrl) => (url: string) => { + continueWithUrl(decodeURIComponent(url)); + }; + +const getCieUatEndpoint = () => + Platform.select({ + ios: `${BASE_UAT_URL}Authn/SSL/Login2`, + android: BASE_UAT_URL, + default: null + }); diff --git a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx index f2211289aa3..e2ddebde7a6 100644 --- a/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx +++ b/ts/features/itwallet/identification/screens/cie/ItwCieCardReaderScreen.tsx @@ -21,7 +21,6 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { StackNavigationProp } from "@react-navigation/stack"; -import { Cie } from "@pagopa/io-react-native-wallet"; import CieCardReadingAnimation, { ReadingState } from "../../../../../components/cie/CieCardReadingAnimation"; @@ -47,6 +46,7 @@ import { trackItWalletCieCardReadingSuccess, trackItWalletErrorCardReading } 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"; diff --git a/yarn.lock b/yarn.lock index 411facc9f30..87562a1278d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,10 +2282,10 @@ resolved "https://registry.yarnpkg.com/@pagopa/io-react-native-secure-storage/-/io-react-native-secure-storage-0.2.0.tgz#386982f350fcefa7bf8dc56e26ff390a9c94db03" integrity sha512-nPdYOKa9DAMtmPanIOwyKzOJ6JpBINfLgOp3wzVT7pCOJPRo0FYHBwyt0nxrKufE42rz9z9Sh8K7Y0faB0kuMw== -"@pagopa/io-react-native-wallet@^0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@pagopa/io-react-native-wallet/-/io-react-native-wallet-0.26.0.tgz#01ccf96e2be5e99643159322d5e21c8b89fb9ca7" - integrity sha512-7HhOfWlqE98Ft0Tz7MMkzs3scrF/qEFBSVuG5i0KrTa6sr8M2D2bKGXxdj5vx2/7H1J525L/mSAiUt8IExHhoQ== +"@pagopa/io-react-native-wallet@^0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@pagopa/io-react-native-wallet/-/io-react-native-wallet-0.27.0.tgz#b692f9b5e9ba71e4a0dbf7802bd7e78ca970148c" + integrity sha512-9fVcBCXSHQcnShZOtwv0pwqkzCed/afhDszh0HmIl46d8jTNSugEcCN4ivIDC+l87wgZ1VxxQad9tM0mn0EkDg== dependencies: js-base64 "^3.7.7" js-sha256 "^0.9.0"