teamdigitale/italia-app

View on GitHub
ts/components/cie/CieRequestAuthenticationOverlay.tsx

Summary

Maintainability
C
1 day
Test Coverage
import * as React from "react";
import { createRef, useEffect } from "react";
import { View, Platform, StyleSheet } from "react-native";
import WebView from "react-native-webview";
import {
  WebViewErrorEvent,
  WebViewHttpErrorEvent,
  WebViewNavigation,
  WebViewNavigationEvent,
  WebViewSource
} from "react-native-webview/lib/WebViewTypes";
import { pipe } from "fp-ts/lib/function";
import * as O from "fp-ts/lib/Option";
import * as T from "fp-ts/lib/Task";
import * as TE from "fp-ts/lib/TaskEither";
import { LoginUtilsError } from "@pagopa/io-react-native-login-utils";
import CookieManager from "@react-native-cookies/cookies";
import { IOColors } from "@pagopa/io-app-design-system";
import { useHardwareBackButton } from "../../hooks/useHardwareBackButton";
import I18n from "../../i18n";
import { getIdpLoginUri } from "../../utils/login";
import { closeInjectedScript } from "../../utils/webview";
import { IOStyles } from "../core/variables/IOStyles";
import { withLoadingSpinner } from "../helpers/withLoadingSpinner";
import { lollipopKeyTagSelector } from "../../features/lollipop/store/reducers/lollipop";
import { useIODispatch, useIOSelector } from "../../store/hooks";
import { isMixpanelEnabled } from "../../store/reducers/persistedPreferences";
import { regenerateKeyGetRedirectsAndVerifySaml } from "../../features/lollipop/utils/login";
import { trackSpidLoginError } from "../../utils/analytics";
import { isFastLoginEnabledSelector } from "../../features/fastLogin/store/selectors";
import { isCieLoginUatEnabledSelector } from "../../features/cieLogin/store/selectors";
import { cieFlowForDevServerEnabled } from "../../features/cieLogin/utils";
import { selectedIdentityProviderSelector } from "../../store/reducers/authentication";
import { OperationResultScreenContent } from "../screens/OperationResultScreenContent";

const styles = StyleSheet.create({
  errorContainer: {
    backgroundColor: IOColors.white
  }
});

// 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
});

/**
 * This JS is injection on every page load. It tries to decrease to 0 the sleeping time of a script.
 * That sleeping is used to allow user to read page content until the content changes to an automatic redirect.
 * This script also tries also to call apriIosUL.
 * If it is defined it starts the authentication process (iOS only).
 */
const injectJs =
  Platform.OS === "ios"
    ? `
  seconds = 0;
  if(typeof apriIosUL !== 'undefined' && apriIosUL !== null){
    apriIosUL();
  }
`
    : undefined;

type Props = {
  onClose: () => void;
  onSuccess: (authorizationUri: string) => void;
};

type InternalState = {
  authUrl: string | undefined;
  error: boolean;
  key: number;
};

const generateResetState: () => InternalState = () => ({
  authUrl: undefined,
  error: false,
  key: 1
});

const generateFoundAuthUrlState: (
  authUr: string,
  state: InternalState
) => InternalState = (authUrl: string, state: InternalState) => ({
  ...state,
  authUrl
});

const generateErrorState: (state: InternalState) => InternalState = (
  state: InternalState
) => ({
  ...state,
  error: true
});

const generateRetryState: (state: InternalState) => InternalState = (
  state: InternalState
) => ({
  ...state,
  error: false,
  key: state.key + 1
});

type RequestInfoAuthorizedState = {
  requestState: "AUTHORIZED";
  nativeAttempts: number;
  url: string;
};

type RequestInfoLoadingState = {
  requestState: "LOADING";
  nativeAttempts: number;
};

type RequestInfo = RequestInfoLoadingState | RequestInfoAuthorizedState;

function retryRequest(
  setInternalState: React.Dispatch<React.SetStateAction<InternalState>>,
  setRequestInfo: React.Dispatch<React.SetStateAction<RequestInfo>>
) {
  setInternalState(generateRetryState);
  setRequestInfo(requestInfo => ({
    requestState: "LOADING",
    nativeAttempts: requestInfo.nativeAttempts + 1
  }));
}

export enum CieEntityIds {
  PROD = "xx_servizicie",
  DEV = "xx_servizicie_coll"
}

const CieWebView = (props: Props) => {
  const [internalState, setInternalState] = React.useState<InternalState>(
    generateResetState()
  );

  const [requestInfo, setRequestInfo] = React.useState<RequestInfo>({
    requestState: "LOADING",
    nativeAttempts: 0
  });

  const useCieUat = useIOSelector(isCieLoginUatEnabledSelector);
  const CIE_IDP_ID = useCieUat ? CieEntityIds.DEV : CieEntityIds.PROD;
  const loginUri = getIdpLoginUri(CIE_IDP_ID, 3);

  const mixpanelEnabled = useIOSelector(isMixpanelEnabled);
  const dispatch = useIODispatch();

  const maybeKeyTag = useIOSelector(lollipopKeyTagSelector);
  const isFastLogin = useIOSelector(isFastLoginEnabledSelector);
  const idp = useIOSelector(selectedIdentityProviderSelector);

  const webView = createRef<WebView>();
  const { onSuccess } = props;

  const handleOnError = React.useCallback(
    (
      e: Error | LoginUtilsError | WebViewErrorEvent | WebViewHttpErrorEvent
    ) => {
      trackSpidLoginError("cie", e);
      setInternalState(state => generateErrorState(state));
    },
    []
  );

  useEffect(() => {
    if (internalState.authUrl !== undefined) {
      onSuccess(internalState.authUrl);
      // reset the state when authUrl has been found
      setInternalState(generateResetState());
    }
  }, [internalState.authUrl, onSuccess]);

  const handleOnShouldStartLoadWithRequest = (
    event: WebViewNavigation
  ): boolean => {
    if (internalState.authUrl !== undefined) {
      return false;
    }

    const url = event.url;

    // on iOS when authnRequestString is present in the url, it means we have all stuffs to go on.
    if (
      url !== undefined &&
      Platform.OS === "ios" &&
      url.indexOf("authnRequestString") !== -1
    ) {
      // avoid redirect and follow the 'happy path'
      if (webView.current !== null) {
        setInternalState(state => generateFoundAuthUrlState(url, state));
      }
      return false;
    }

    // Once the returned url contains the "OpenApp" string, then the authorization has been given
    if (url && url.indexOf("OpenApp") !== -1) {
      setInternalState(state => generateFoundAuthUrlState(url, state));
      return false;
    }

    if (cieFlowForDevServerEnabled && url.indexOf("token=") !== -1) {
      setInternalState(state => generateFoundAuthUrlState(url, state));
      return false;
    }

    return true;
  };

  const handleOnLoadEnd = (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(new Error(eventTitle));
    }
    // inject JS on every page load end
    if (injectJs && webView.current) {
      webView.current.injectJavaScript(closeInjectedScript(injectJs));
    }
  };

  if (internalState.error) {
    return (
      <ErrorComponent
        onRetry={() => {
          retryRequest(setInternalState, setRequestInfo);
        }}
        onClose={props.onClose}
      />
    );
  }

  if (O.isSome(maybeKeyTag) && requestInfo.requestState === "LOADING") {
    void pipe(
      TE.tryCatch(
        () =>
          Platform.OS === "android"
            ? CookieManager.removeSessionCookies()
            : Promise.resolve(true),
        () => new Error("Error clearing cookies")
      ),
      TE.chain(
        _ => () =>
          regenerateKeyGetRedirectsAndVerifySaml(
            loginUri,
            maybeKeyTag.value,
            mixpanelEnabled,
            isFastLogin,
            dispatch,
            idp?.id
          )
      ),
      TE.fold(
        e => T.of(handleOnError(e)),
        url =>
          T.of(
            setRequestInfo({
              requestState: "AUTHORIZED",
              nativeAttempts: requestInfo.nativeAttempts,
              url
            })
          )
      )
    )();
  }

  const WithLoading = withLoadingSpinner(() => (
    <View style={IOStyles.flex}>
      {requestInfo.requestState === "AUTHORIZED" &&
        internalState.authUrl === undefined && (
          <WebView
            androidCameraAccessDisabled={true}
            androidMicrophoneAccessDisabled={true}
            ref={webView}
            userAgent={defaultUserAgent}
            javaScriptEnabled={true}
            injectedJavaScript={injectJs}
            onLoadEnd={handleOnLoadEnd}
            onError={handleOnError}
            onHttpError={handleOnError}
            onShouldStartLoadWithRequest={handleOnShouldStartLoadWithRequest}
            source={{ uri: requestInfo.url } as WebViewSource}
            key={internalState.key}
          />
        )}
    </View>
  ));

  return (
    <WithLoading
      isLoading={!cieFlowForDevServerEnabled}
      loadingOpacity={1.0}
      loadingCaption={I18n.t("global.genericWaiting")}
      onCancel={props.onClose}
    />
  );
};

const ErrorComponent = (
  props: { onRetry: () => void } & Pick<Props, "onClose">
) => (
  <View style={[IOStyles.flex, styles.errorContainer]}>
    <OperationResultScreenContent
      pictogram="umbrellaNew"
      title={I18n.t("authentication.errors.network.title")}
      action={{
        label: I18n.t("global.buttons.retry"),
        accessibilityLabel: I18n.t("global.buttons.retry"),
        onPress: props.onRetry
      }}
      secondaryAction={{
        label: I18n.t("global.buttons.cancel"),
        accessibilityLabel: I18n.t("global.buttons.cancel"),
        onPress: props.onClose
      }}
    />
  </View>
);

/**
 * A screen to manage the request of authentication once the pin of the user's CIE has been inserted
 * 1) Start the first request with the getIdpLoginUri(CIE_IDP_ID) uri
 * 2) Accepts all the redirects until the uri with the right path is found and stop the loading
 * 3) Dispatch the found uri using the `onSuccess` callback
 * @param props
 * @constructor
 */
export const CieRequestAuthenticationOverlay = (
  props: Props
): React.ReactElement => {
  // Disable android back button
  useHardwareBackButton(() => {
    props.onClose();
    return true;
  });

  return <CieWebView {...props} />;
};