Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/components/AddToWalletButton/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import ActivityIndicator from '@components/ActivityIndicator';
import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext';

import useThemeStyles from '@hooks/useThemeStyles';

import {getPaymentMethods} from '@libs/actions/PaymentMethods';
import {isUkEuExpensifyCard} from '@libs/CardUtils';
import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
Expand All @@ -14,7 +16,7 @@ import ROUTES from '@src/ROUTES';
import type {TokenizationStatus} from '@expensify/react-native-wallet';

import {AddToWalletButton as RNAddToWalletButton} from '@expensify/react-native-wallet';
import React, {useCallback, useState} from 'react';
import React, {useState} from 'react';
import {Alert} from 'react-native';

import type AddToWalletButtonProps from './types';
Expand All @@ -26,11 +28,16 @@ const isIOS = getPlatform() === CONST.PLATFORM.IOS;
function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddToWalletButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const styles = useThemeStyles();
const {executeScenario} = useMultifactorAuthentication();
const {isInWallet, isLoading: isCardLoading, isCardAvailable, isWalletAvailable} = useIsCardInWallet(card);

const handleOnPress = useCallback(() => {
const handleOnPress = () => {
if (isUkEuExpensifyCard(card)) {
executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName, cardDescription});
return;
}
setIsLoading(true);
handleAddCardToWallet(card, cardHolderName, cardDescription, () => setIsLoading(false))
handleAddCardToWallet(card, cardHolderName, cardDescription)
.then((status: TokenizationStatus) => {
if (status === 'success') {
Log.info('Card added to wallet');
Expand All @@ -47,7 +54,7 @@ function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddTo
Log.warn(`Error while adding card to wallet: ${error}`);
Alert.alert('Failed to add card to wallet', 'Please try again later.');
});
}, [card, cardDescription, cardHolderName]);
};

if (!isWalletAvailable || isInWallet == null || isInWallet || !isCardAvailable) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ type MultifactorAuthenticationContextProviderProps = {
function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) {
const state = useMultifactorAuthenticationState();
const {dispatch} = useMultifactorAuthenticationActions();
const actionControllerRef = useRef<AbortController | undefined>(undefined);

useEffect(
() => () => {
actionControllerRef.current?.abort();
actionControllerRef.current = undefined;
},
[state.scenario],
);

const biometrics = useBiometrics();
const {accountID} = useCurrentUserPersonalDetails();
Expand Down Expand Up @@ -358,12 +367,30 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
}

// Call backend with signed challenge
const scenarioAPIResponse = await processScenarioAction(scenario.action, {
signedChallenge: result.signedChallenge,
authenticationMethod: result.authenticationMethod.marqetaValue,
...payload,
const controller = new AbortController();
actionControllerRef.current = controller;
const scenarioAPIResponse = await processScenarioAction(
scenario.action,
{
signedChallenge: result.signedChallenge,
authenticationMethod: result.authenticationMethod.marqetaValue,
...payload,
},
{signal: controller.signal},
).catch((scenarioActionError: unknown) => {
if (controller.signal.aborted || actionControllerRef.current !== controller) {
return undefined;
}
actionControllerRef.current = undefined;
throw scenarioActionError;
});

// A canceled action can finish after the next flow has already started.
if (!scenarioAPIResponse || controller.signal.aborted || actionControllerRef.current !== controller) {
return;
}
actionControllerRef.current = undefined;

addMFABreadcrumb(
'Scenario action completed',
scenarioAPIResponse.success
Expand Down Expand Up @@ -504,6 +531,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
* close the modal directly instead to avoid getting stuck.
*/
const cancel = useCallback(async () => {
actionControllerRef.current?.abort();
if (!state.scenario || isOffline) {
addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning');
dispatch({type: 'CLOSE_MODAL'});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** Native wallet provisioning starts only after the MFA provider signs a challenge. */
import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types';

import {getPaymentMethods} from '@libs/actions/PaymentMethods';
import getPlatform from '@libs/getPlatform';
import Navigation from '@libs/Navigation/Navigation';
import {handleAddCardToWallet} from '@libs/Wallet';
import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError';

import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {Card} from '@src/types/onyx';

type Payload = {
card: Card;
cardHolderName: string;
cardDescription: string;
};

export default {
allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM],
// Hermes V1 does not suspend at await inside async arrow functions with destructured parameters, so destructure in the body.
action: async (params, context) => {
const {card, cardHolderName, cardDescription, signedChallenge, authenticationMethod} = params;
try {
const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, {
authentication: {signedChallenge, authenticationMethod},
signal: context?.signal,
});
if (status === 'success' && !context?.signal.aborted) {
return {httpStatusCode: 200, reason: undefined, message: undefined};
}
return {
httpStatusCode: undefined,
reason:
status === 'canceled' || context?.signal.aborted
? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED
: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED,
message: undefined,
};
} catch (error) {
if (error instanceof WalletProvisioningError) {
return error.response;
}
// Native exceptions can contain card data. The shared MFA failure UI only needs a reason.
return {
httpStatusCode: undefined,
reason: context?.signal.aborted ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION,
message: undefined,
};
}
},
callback: async (isSuccessful, _input, payload) => {
if (!isSuccessful || !payload || !('card' in payload)) {
return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN;
}
getPaymentMethods();
if (getPlatform() === CONST.PLATFORM.IOS) {
Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_ADDED_TO_WALLET.getRoute(String(payload.card.cardID)));
}
return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN;
},
} as const satisfies MultifactorAuthenticationScenarioCustomConfig<Payload>;

export type {Payload};
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import CONST from '@src/CONST';

import type {EmptyObject} from 'type-fest';

import type {Payload as AddToDigitalWalletPayload} from './AddToDigitalWallet';
import type {Payload as AuthorizeTransactionPayload} from './AuthorizeTransaction';
import type {Payload as ChangePINPayload} from './ChangePIN';
import type {Payload as RevealPINPayload} from './RevealPIN';
import type {Payload as SetPersonalDetailsAndRevealCardDetailsPayload} from './SetPersonalDetailsAndRevealCardDetails';
import type {Payload as SetPINOrderCardPayload} from './SetPINOrderCard';

import AddToDigitalWallet from './AddToDigitalWallet';
import AuthorizeTransaction from './AuthorizeTransaction';
import BiometricsTest from './BiometricsTest';
import ChangePIN from './ChangePIN';
Expand All @@ -23,6 +25,7 @@ import SetPINOrderCard from './SetPINOrderCard';
* Each scenario that requires additional parameters should have its payload type defined here.
*/
type Payloads = {
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: AddToDigitalWalletPayload;
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: EmptyObject;
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: SetPINOrderCardPayload;
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: AuthorizeTransactionPayload;
Expand All @@ -35,6 +38,7 @@ type Payloads = {
* Configuration records for all multifactor authentication scenarios.
*/
const Configs = {
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: customConfig(AddToDigitalWallet),
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: customConfig(BiometricsTest),
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: customConfig(SetPINOrderCard),
[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: customConfig(AuthorizeTransaction),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const SCENARIO_NAMES = {
AUTHORIZE_TRANSACTION: 'AUTHORIZE-TRANSACTION',
REVEAL_PIN: 'REVEAL-PIN',
CHANGE_PIN: 'CHANGE-PIN',
ADD_TO_DIGITAL_WALLET: 'ADD-TO-DIGITAL-WALLET',
SET_PERSONAL_DETAILS_AND_REVEAL_CARD_DETAILS: 'SET-PERSONAL-DETAILS-AND-REVEAL-CARD-DETAILS',
} as const;

Expand Down
1 change: 1 addition & 0 deletions src/components/MultifactorAuthentication/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type MultifactorAuthenticationScenarioResponse = {
*/
type MultifactorAuthenticationScenarioPureMethod<T extends Record<string, unknown>> = (
params: MultifactorAuthenticationActionParams<T, 'signedChallenge'>,
context?: {signal: AbortSignal},
) => Promise<MultifactorAuthenticationScenarioResponse>;

/**
Expand Down
6 changes: 5 additions & 1 deletion src/libs/API/parameters/CreateDigitalWalletParams.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';

type CreateDigitalAppleWalletParams = {
platform: 'ios';
appVersion: string;
Expand All @@ -16,6 +18,8 @@ type CreateDigitalGoogleWalletParams = {
cardID: number;
};

type CreateDigitalWalletParams = CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams;
type WalletAuthenticationParams = {signedChallenge: string; authenticationMethod: AuthTypeInfo['marqetaValue']} | {signedChallenge?: never; authenticationMethod?: never};

type CreateDigitalWalletParams = (CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams) & WalletAuthenticationParams;

export default CreateDigitalWalletParams;
1 change: 1 addition & 0 deletions src/libs/MultifactorAuthentication/shared/VALUES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const API_RESPONSE_MAP = {

/** No custom error handling — all responses rely on category fallback reasons. */
CHANGE_CARD_PIN: {},
CREATE_DIGITAL_WALLET: {},
} as const;

type DeepLeafValues<T> = T extends Record<string, unknown> ? DeepLeafValues<T[keyof T]> : T;
Expand Down
10 changes: 10 additions & 0 deletions src/libs/Wallet/WalletProvisioningError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Carries only the normalized failure, never encrypted card data or a challenge. */
import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types';

class WalletProvisioningError extends Error {
constructor(readonly response: MultifactorAuthenticationScenarioResponse) {
super('Digital wallet provisioning failed');
}
}

export default WalletProvisioningError;
21 changes: 17 additions & 4 deletions src/libs/Wallet/index.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,27 @@ import type {AndroidCardData, AndroidWalletData, CardStatus, TokenizationStatus}

import {addCardToGoogleWallet, checkWalletAvailability, getCardStatusBySuffix, getSecureWalletInfo} from '@expensify/react-native-wallet';

import type {AddCardToWalletOptions} from './types';

function checkIfWalletIsAvailable(): Promise<boolean> {
return checkWalletAvailability();
}

function handleAddCardToWallet(card: Card, cardHolderName: string): Promise<TokenizationStatus> {
return getSecureWalletInfo().then((walletData: AndroidWalletData) =>
createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}).then((cardData: AndroidCardData) => addCardToGoogleWallet(cardData)),
);
function handleAddCardToWallet(card: Card, cardHolderName: string, _cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise<TokenizationStatus> {
if (signal?.aborted) {
return Promise.resolve('canceled');
}
return getSecureWalletInfo().then((walletData: AndroidWalletData) => {
if (signal?.aborted) {
return 'canceled';
}
return createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}, authentication).then((cardData: AndroidCardData) => {
if (signal?.aborted) {
return 'canceled';
}
return addCardToGoogleWallet(cardData);
});
});
}

function isCardInWallet(card: Card): Promise<boolean> {
Expand Down
45 changes: 43 additions & 2 deletions src/libs/Wallet/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,60 @@ import type {IOSCardData, TokenizationStatus} from '@expensify/react-native-wall

import {addCardToAppleWallet, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet';

import type {AddCardToWalletOptions} from './types';

function checkIfWalletIsAvailable(): Promise<boolean> {
return checkWalletAvailability();
}

function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string): Promise<TokenizationStatus> {
async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise<TokenizationStatus> {
const data = {
network: CONST.COMPANY_CARDS.CARD_TYPE.VISA,
lastDigits: card.lastFourPAN,
cardDescription,
cardHolderName,
} as IOSCardData;

return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID));
if (!authentication) {
return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID));
}

if (signal?.aborted) {
return 'canceled';
}

let didRequestProvisioning = false;
let provisioningFailure: unknown;
try {
const status = await addCardToAppleWallet(data, async (nonce, nonceSignature, certificates) => {
try {
// Apple's Try Again callback must not replay a consumed signed challenge.
// Wallet 0.1.22 has no public abort API. Rejection cannot dismiss its sheet.
if (didRequestProvisioning || signal?.aborted) {
throw new Error('Wallet attempt requires fresh authentication');
}
didRequestProvisioning = true;
const payload = await issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID, authentication);
if (signal?.aborted) {
throw new Error('Wallet attempt canceled');
}
return payload;
} catch (error) {
provisioningFailure ??= error;
throw error;
}
});
if (provisioningFailure) {
throw provisioningFailure;
}
if (status === 'success' && !didRequestProvisioning) {
throw new Error('Wallet completed without provisioning');
}
return signal?.aborted ? 'canceled' : status;
} catch (error) {
// A subsequent native rejection must not replace the original server failure.
throw provisioningFailure ?? error;
}
}

function isCardInWallet(card: Card): Promise<boolean> {
Expand Down
4 changes: 3 additions & 1 deletion src/libs/Wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import type {Card} from '@src/types/onyx';

import type {TokenizationStatus} from '@expensify/react-native-wallet';

import type {AddCardToWalletOptions} from './types';

function checkIfWalletIsAvailable(): Promise<boolean> {
return Promise.resolve(false);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _onFinished?: () => void): Promise<TokenizationStatus> {
function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _options?: AddCardToWalletOptions): Promise<TokenizationStatus> {
return Promise.reject(new Error('Add to wallet is not supported on this platform'));
}

Expand Down
15 changes: 15 additions & 0 deletions src/libs/Wallet/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Transient authorization for one native wallet provisioning attempt. */
import type {SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';

type WalletAuthentication = {
signedChallenge: SignedChallenge;
authenticationMethod: AuthTypeInfo['marqetaValue'];
};

type AddCardToWalletOptions = {
authentication?: WalletAuthentication;
signal?: AbortSignal;
};

export type {AddCardToWalletOptions, WalletAuthentication};
3 changes: 2 additions & 1 deletion src/libs/actions/MultifactorAuthentication/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async function processRegistration(params: RegistrationParams): Promise<MFAResul
async function processScenarioAction(
action: MultifactorAuthenticationScenarioConfig['action'],
params: Parameters<MultifactorAuthenticationScenarioConfig['action']>[0],
context?: {signal: AbortSignal},
): Promise<MFAResult<MultifactorAuthenticationScenarioResponse>> {
if (!params.signedChallenge) {
return {
Expand All @@ -41,7 +42,7 @@ async function processScenarioAction(
};
}

const {httpStatusCode, reason, message, body} = await action(params);
const {httpStatusCode, reason, message, body} = await (context ? action(params, context) : action(params));

if (isHttpSuccess(httpStatusCode)) {
return {
Expand Down
Loading
Loading