diff --git a/example/src/navigator/MainStackNavigator.tsx b/example/src/navigator/MainStackNavigator.tsx index abce4a38..a9c58127 100644 --- a/example/src/navigator/MainStackNavigator.tsx +++ b/example/src/navigator/MainStackNavigator.tsx @@ -26,6 +26,8 @@ import type { SupportedCredentialsWithoutPid } from "../store/types"; import { useAppDispatch } from "../store/utils"; import { labelByCredentialType } from "../utils/ui"; import IdpSelectionScreen from "../screens/login/IdpSelectionScreen"; +import { PresentationScreen } from "../screens/PresentationScreen"; +import { QrScannerScreen } from "../screens/QrScannerScreen"; /** * MainStackNav parameters list for each defined screen. @@ -47,6 +49,8 @@ export type MainStackNavParamList = { authUrl: string; redirectUri: string; }; + Presentations: undefined; + QrScanner: undefined; }; const Stack = createNativeStackNavigator(); @@ -136,6 +140,16 @@ export const MainStackNavigator = () => { } trustmark`, })} /> + + { icon: "chevronRight", onPress: () => navigation.navigate("Settings"), }, + { + label: "Presentations", + description: "Present credential", + icon: "chevronRight", + onPress: () => + pid + ? navigation.navigate("Presentations") + : Alert.alert("Obtain a PID first"), + }, ], [hasIntegrityKeyTag, navigation, pid, credentials, hasSomeCredential] ); diff --git a/example/src/screens/PresentationScreen.tsx b/example/src/screens/PresentationScreen.tsx new file mode 100644 index 00000000..cfeeab49 --- /dev/null +++ b/example/src/screens/PresentationScreen.tsx @@ -0,0 +1,63 @@ +import React, { useMemo } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { useAppSelector } from "../store/utils"; +import TestScenario, { + type TestScenarioProp, +} from "../components/TestScenario"; +import { selectPresentationAsyncStatus } from "../store/reducers/presentation"; + +import { useDebugInfo } from "../hooks/useDebugInfo"; +import { IOVisualCostants, VSpacer } from "@pagopa/io-app-design-system"; +import { FlatList } from "react-native"; + +export const PresentationScreen = () => { + const navigation = useNavigation(); + const presentationState = useAppSelector(selectPresentationAsyncStatus); + + useDebugInfo({ + presentationState, + }); + + const scenarios: Array = useMemo( + () => [ + { + title: "PID Remote Cross-Device", + onPress: () => navigation.navigate("QrScanner"), + isLoading: presentationState.isLoading, + hasError: presentationState.hasError, + isDone: presentationState.isDone, + icon: "device", + }, + ], + [ + navigation, + presentationState.hasError, + presentationState.isDone, + presentationState.isLoading, + ] + ); + + return ( + `${item.title}-${index}`} + renderItem={({ item }) => ( + <> + + + + )} + /> + ); +}; diff --git a/example/src/screens/QrScannerScreen.tsx b/example/src/screens/QrScannerScreen.tsx new file mode 100644 index 00000000..5bf6b64f --- /dev/null +++ b/example/src/screens/QrScannerScreen.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from "react"; +import { Text, View, Alert, StyleSheet } from "react-native"; +import { + Camera, + useCameraDevice, + useCodeScanner, +} from "react-native-vision-camera"; +import { useAppDispatch } from "../store/utils"; +import { useNavigation } from "@react-navigation/native"; +// Thunk or action you want to dispatch +import { remoteCrossDevicePresentationThunk } from "../thunks/presentation"; + +export const QrScannerScreen = () => { + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const [hasPermission, setHasPermission] = useState(false); + + const device = useCameraDevice("back"); + + // 3. Ask for camera permission on mount + useEffect(() => { + (async () => { + const cameraPermission = await Camera.requestCameraPermission(); + if (cameraPermission.toString() === "granted") { + setHasPermission(true); + } else { + Alert.alert("Error", "Camera permission not granted!"); + } + })(); + }, []); + + const codeScanner = useCodeScanner({ + codeTypes: ["qr", "ean-13"], + onCodeScanned: (codes) => { + dispatch( + remoteCrossDevicePresentationThunk({ qrcode: codes[0]?.value || "" }) + ); + + navigation.goBack(); + }, + }); + + if (!device) { + return ( + + Camera not available! + + ); + } + + return ( + + {hasPermission ? ( + + ) : ( + Camera permission not granted! + )} + + ); +}; + +const style = StyleSheet.create({ + camera: { + width: 500, + height: 500, + }, +}); diff --git a/example/src/store/reducers/presentation.ts b/example/src/store/reducers/presentation.ts new file mode 100644 index 00000000..9f3d7cb4 --- /dev/null +++ b/example/src/store/reducers/presentation.ts @@ -0,0 +1,97 @@ +import { createSlice } from "@reduxjs/toolkit"; + +import { remoteCrossDevicePresentationThunk as remoteCrossDevicePresentationThunk } from "../../thunks/presentation"; +import { persistReducer, type PersistConfig } from "redux-persist"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { asyncStatusInitial } from "../utils"; +import type { RootState, AsyncStatus } from "../types"; +import { sessionReset } from "./sesssion"; + +/** + * State type definition for the presentation slice + * the state contains: + * - async state: isLoading, isDone, hasError as defined in {@link AsyncStatus} + */ +type PresentationState = { + redirectUri: string | undefined; + asyncStatus: AsyncStatus; +}; + +// Initial state for the presentation slice +const initialState: PresentationState = { + redirectUri: undefined, + asyncStatus: asyncStatusInitial, +}; + +/** + * Redux slice for the presentation state which contains the key tag used to register the wallet presentation. + */ +const presentationSlice = createSlice({ + name: "presentation", + initialState, + reducers: { + presentationReset: () => initialState, + }, + extraReducers: (builder) => { + // Dispatched when is created. Sets the key tag in the state and its state to isDone while resetting isLoading and hasError. + builder.addCase( + remoteCrossDevicePresentationThunk.fulfilled, + (state, action) => { + state.redirectUri = action.payload.result.redirect_uri; + state.asyncStatus.isDone = true; + state.asyncStatus.isLoading = initialState.asyncStatus.isLoading; + state.asyncStatus.hasError = initialState.asyncStatus.hasError; + } + ); + + // Dispatched when is pending. Sets the state to isLoading and resets isDone and hasError. + builder.addCase(remoteCrossDevicePresentationThunk.pending, (state) => { + state.asyncStatus.isDone = false; + state.asyncStatus.isLoading = true; + state.asyncStatus.hasError = initialState.asyncStatus.hasError; + }); + + // Dispatched when is rejected. Sets the state to hasError and resets isLoading and isDone. + builder.addCase( + remoteCrossDevicePresentationThunk.rejected, + (state, action) => { + state.asyncStatus.isDone = initialState.asyncStatus.isDone; + state.asyncStatus.isLoading = initialState.asyncStatus.isLoading; + state.asyncStatus.hasError = { status: true, error: action.error }; + } + ); + + // Reset the attestation state when the session is reset. + builder.addCase(sessionReset, () => initialState); + }, +}); + +/** + * Exports the actions for the presentation slice. + */ +export const { presentationReset } = presentationSlice.actions; + +/** + * Configuration for the presentation slice to be persisted in the Redux store. + * Only the keyTag is persisted to avoid regenerating the wallet presentation at each app launch. + */ +const persistConfig: PersistConfig = { + key: "presentation", + storage: AsyncStorage, +}; + +/** + * Persisted reducer for the presentation slice. + */ +export const presentationReducer = persistReducer( + persistConfig, + presentationSlice.reducer +); + +/** + * Selects the presentation state from the root state. + * @param state - The root state of the Redux store + * @returns The presentation state as {@link AsyncStatus} + */ +export const selectPresentationAsyncStatus = (state: RootState) => + state.presentation.asyncStatus; diff --git a/example/src/store/store.ts b/example/src/store/store.ts index 913764c3..95b3cc5a 100644 --- a/example/src/store/store.ts +++ b/example/src/store/store.ts @@ -15,6 +15,7 @@ import { credentialReducer } from "./reducers/credential"; import { debugSlice } from "./reducers/debug"; import { environmentReducer } from "./reducers/environment"; import { pidReducer } from "./reducers/pid"; +import { presentationReducer } from "./reducers/presentation"; /** * Redux store configuration. @@ -28,6 +29,7 @@ export const store = configureStore({ attestation: attestationReducer, credential: credentialReducer, pid: pidReducer, + presentation: presentationReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/example/src/thunks/presentation.ts b/example/src/thunks/presentation.ts new file mode 100644 index 00000000..a8f788e0 --- /dev/null +++ b/example/src/thunks/presentation.ts @@ -0,0 +1,109 @@ +import appFetch from "../utils/fetch"; +import { createAppAsyncThunk } from "./utils"; +import { WIA_KEYTAG } from "../utils/crypto"; +import { + createCryptoContextFor, + Credential, +} from "@pagopa/io-react-native-wallet"; +import { + selectAttestation, + shouldRequestAttestationSelector, +} from "../store/reducers/attestation"; +import { selectPid } from "../store/reducers/pid"; +import { getAttestationThunk } from "./attestation"; +import { SdJwt } from "@pagopa/io-react-native-wallet"; +import type { InputDescriptor } from "src/credential/presentation/types"; +export type RemoteCrossDevicePresentationThunkInput = { + qrcode: string; +}; + +export type RemoteCrossDevicePresentationThunkOutput = { + result: Awaited< + ReturnType + >; +}; + +/** + * Thunk to present credential. + */ +export const remoteCrossDevicePresentationThunk = createAppAsyncThunk< + RemoteCrossDevicePresentationThunkOutput, + RemoteCrossDevicePresentationThunkInput +>("presentation/remote", async (args, { getState, dispatch }) => { + // Checks if the wallet instance attestation needs to be reuqested + if (shouldRequestAttestationSelector(getState())) { + await dispatch(getAttestationThunk()); + } + + // Gets the Wallet Instance Attestation from the persisted store + const walletInstanceAttestation = selectAttestation(getState()); + if (!walletInstanceAttestation) { + throw new Error("Wallet Instance Attestation not found"); + } + const qrcode = args.qrcode; + + const { requestURI } = Credential.Presentation.startFlowFromQR(qrcode); + + const wiaCryptoContext = createCryptoContextFor(WIA_KEYTAG); + + const { requestObjectEncodedJwt } = + await Credential.Presentation.getRequestObject(requestURI, { + wiaCryptoContext: wiaCryptoContext, + appFetch: appFetch, + walletInstanceAttestation: walletInstanceAttestation, + }); + + const jwks = await Credential.Presentation.fetchJwksFromRequestObject( + requestObjectEncodedJwt, + { + context: { appFetch }, + } + ); + + const { requestObject } = + await Credential.Presentation.verifyRequestObjectSignature( + requestObjectEncodedJwt, + jwks.keys + ); + + const { presentationDefinition } = + await Credential.Presentation.fetchPresentDefinition(requestObject, { + appFetch: appFetch, + }); + + // We suppose that request is about PID + // In this case no check about other credentials + const pid = selectPid(getState()); + if (!pid) { + throw new Error("PID not found"); + } + const pidCredentialJwt = SdJwt.decode(pid.credential); + + // We support only one credential for now, we get first input_descriptor + const inputDescriptor = + presentationDefinition.input_descriptors[0] || + ({} as unknown as InputDescriptor); + + const { requiredDisclosures } = + Credential.Presentation.evaluateInputDescriptorForSdJwt4VC( + inputDescriptor, + pidCredentialJwt.sdJwt.payload, + pidCredentialJwt.disclosures + ); + + const disclosuresRequestedClaimName = [ + ...requiredDisclosures.map((item) => item.decoded[1]), + ]; + + const credentialCryptoContext = createCryptoContextFor(pid.keyTag); + + const authResponse = await Credential.Presentation.sendAuthorizationResponse( + requestObject, + presentationDefinition, + jwks.keys, + [pid.credential, disclosuresRequestedClaimName, credentialCryptoContext], + { appFetch: appFetch } + ); + + return { result: authResponse }; +});