Skip to content
Closed
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
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A React Native wrapper for the Didit Identity Verification SDK. Supports both iO

## Permissions

The SDK uses the camera, location, and optionally NFC on both platforms. Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it.
The SDK uses the camera and, optionally, NFC on both platforms, plus the device location in workflows with a Location step (see [Location and Bank verification steps](#location-and-bank-verification-steps)). Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it.

### iOS

Expand All @@ -32,6 +32,8 @@ Add the following keys to your app's `Info.plist`:
<string>Photo library access is required to upload document images.</string>
<key>NFCReaderUsageDescription</key>
<string>NFC is used to read passport chip data for identity verification.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to confirm where you are for this verification.</string>
```

Missing required iOS privacy keys will cause iOS to terminate the app as soon as the SDK accesses that protected resource.
Expand Down Expand Up @@ -85,6 +87,68 @@ The SDK handles Android runtime permission requests automatically. When the user

You do not need to request camera permission in your app code before calling `startVerification()` — the SDK manages this internally.

### Location and Bank verification steps

Workflows can include a Location step, which confirms where the user is from the device position, and a Bank step, which connects a bank account. The native SDKs draw both steps inside the verification flow, so the React Native API does not change: there is no new function, configuration option, result type or error type.

> **Availability:** the native SDK versions this package pins do not include these two steps yet. Keep them out of workflows you start from the mobile app until this package's release notes announce them. Adding the location purpose string now means the upgrade needs no further app change.

#### Location

The SDK asks for location access only when the session reaches a Location step, and only while the app is in use, never in the background. A Location step set to use the IP address only never asks.

- **iOS:** add `NSLocationWhenInUseUsageDescription` (shown in the `Info.plist` example above). Add it even if your workflows never use the Location step: once the native SDK contains the step, App Store Connect rejects builds whose code references location APIs without a purpose string (ITMS-90683). To show the prompt in other languages, add an `InfoPlist.strings` file to the app target in Xcode, localize it, and set the key for each language, for example in `es.lproj/InfoPlist.strings`:

```
"NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación.";
```

With Expo, set the key in `ios.infoPlist` and its translations through `locales`:

```json
{
"expo": {
"ios": {
"infoPlist": {
"CFBundleAllowMixedLocalizations": true,
"NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification."
}
},
"locales": { "es": "./locales/es.json" }
}
}
```

`locales/es.json`:

```json
{
"ios": {
"NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación."
}
}
```

- **Android:** the native SDK declares `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`, which merge into your app, and requests them at runtime the same way as the camera permission. On Android 12 and later the user can grant approximate location only; the SDK sends whichever location the user allows and Didit's servers decide whether its accuracy meets the workflow's settings. If none of your workflows use the Location step, you can remove both permissions from the merged manifest:

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove" />
</manifest>
```

- **Store disclosures:** a workflow with a Location step sends the device location to Didit. Declare location data in your App Store privacy details and in your Google Play Data safety form.

#### Bank

The Bank step needs no permission, no `Info.plist` key, and no URL scheme or intent filter. The SDK opens the bank's own consent page in an in-app browser and continues the flow once the connection finishes.

#### Results

Didit's servers decide both steps. Their outcome reaches your app in the same `VerificationResult` as every other step: `completed` with the session status (`Approved`, `Pending` or `Declined`) when the flow ends, `cancelled` if the user closes it, or `failed` on an error.

## Native SDK Variants

NFC and auto-detection are enabled by default on both platforms.
Expand Down
1 change: 1 addition & 0 deletions example-expo/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"bundleIdentifier": "me.didit.sdk.reactnative.expo-example",
"infoPlist": {
"NSCameraUsageDescription": "Camera access is required for identity verification to capture documents and facial photos.",
"NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification.",
"NSMicrophoneUsageDescription": "Microphone access is required to record video for liveness verification.",
"NSPhotoLibraryUsageDescription": "Photo library access is required to upload document images.",
"NFCReaderUsageDescription": "NFC is used to read passport chip data for identity verification.",
Expand Down
2 changes: 2 additions & 0 deletions example-expo/ios/DiditExpoExample/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>Camera access is required for identity verification to capture documents and facial photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to confirm where you are for this verification.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is required to record video for liveness verification.</string>
<key>NSPhotoLibraryUsageDescription</key>
Expand Down
2 changes: 2 additions & 0 deletions example/ios/SdkReactNativeExample/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>Camera access is required for identity verification to capture documents and facial photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to confirm where you are for this verification.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
Expand Down
93 changes: 92 additions & 1 deletion src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ jest.mock('../NativeSdkReactNative', () => ({
__esModule: true,
default: {
startVerification: jest.fn(),
startVerificationWithWorkflow: jest.fn(),
submitTransaction: jest.fn(),
getTransaction: jest.fn(),
onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })),
},
}));

import NativeSdkReactNative from '../NativeSdkReactNative';
import { startVerification, submitTransaction, getTransaction } from '../index';
import {
startVerification,
startVerificationWithWorkflow,
submitTransaction,
getTransaction,
VerificationStatus,
} from '../index';

const mockStartVerification =
NativeSdkReactNative.startVerification as jest.Mock;
const mockStartWithWorkflow =
NativeSdkReactNative.startVerificationWithWorkflow as jest.Mock;
const mockSubmit = NativeSdkReactNative.submitTransaction as jest.Mock;
const mockGet = NativeSdkReactNative.getTransaction as jest.Mock;
const mockOnUpdated = NativeSdkReactNative.onTransactionUpdated as jest.Mock;
Expand Down Expand Up @@ -82,6 +91,88 @@ describe('verification configuration marshalling', () => {
});
});

/**
* The Bank and Location steps are drawn by the native SDKs and decided by the
* server, so the bridge adds nothing for them: the language reaches native on
* both entry points and the decided session comes back as an ordinary result.
*/
describe('Bank and Location steps pass-through', () => {
beforeEach(() => {
mockStartVerification.mockReset();
mockStartWithWorkflow.mockReset();
});

it('forwards the language to native when starting with a session token', async () => {
mockStartVerification.mockResolvedValue({ type: 'cancelled' });

await startVerification('test-token', { languageCode: 'es' });

expect(mockStartVerification).toHaveBeenCalledWith(
'test-token',
expect.objectContaining({ languageCode: 'es' })
);
});

it('forwards the language to native when starting with a workflow', async () => {
mockStartWithWorkflow.mockResolvedValue({ type: 'cancelled' });

await startVerificationWithWorkflow('workflow-id', {
config: { languageCode: 'es' },
});

const [workflowId, , , , , nativeConfig] =
mockStartWithWorkflow.mock.calls[0];
expect(workflowId).toBe('workflow-id');
expect(nativeConfig).toMatchObject({ languageCode: 'es' });
});

it.each([
['Approved', VerificationStatus.Approved],
['Pending', VerificationStatus.Pending],
['Declined', VerificationStatus.Declined],
])(
'returns a session the server decided as %s as a completed result',
async (nativeStatus, expected) => {
mockStartWithWorkflow.mockResolvedValue({
type: 'completed',
sessionId: 'session-step',
status: nativeStatus,
});

const result = await startVerificationWithWorkflow('workflow-id');

expect(result).toEqual({
type: 'completed',
session: { sessionId: 'session-step', status: expected },
});
}
);

it('keeps the native message when a newer native step reports an error type the bridge does not know', async () => {
mockStartVerification.mockResolvedValue({
type: 'failed',
errorType: 'locationUnavailable',
errorMessage: 'Location could not be determined.',
sessionId: 'session-step',
status: 'Pending',
});

const result = await startVerification('test-token');

expect(result).toEqual({
type: 'failed',
error: {
type: 'unknown',
message: 'Location could not be determined.',
},
session: {
sessionId: 'session-step',
status: VerificationStatus.Pending,
},
});
});
});

describe('transaction result marshalling', () => {
beforeEach(() => {
mockSubmit.mockReset();
Expand Down
98 changes: 98 additions & 0 deletions src/__tests__/location-permission-entries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync } from 'fs';
import { join } from 'path';

/**
* The Location verification step asks the OS for "when in use" location. iOS
* terminates an app that requests it without NSLocationWhenInUseUsageDescription,
* and App Store Connect rejects a build whose code references location APIs
* without one, so every example app and the README carry the same purpose
* string. The copy is the Location step's permission prompt in the design.
*
* The Expo example's ios/ folder is checked in next to the app.json it was
* prebuilt from; when they drift, a fresh prebuild ships different copy than
* the one the repository shows.
*/

const repoRoot = join(__dirname, '..', '..');

const read = (relativePath: string) =>
readFileSync(join(repoRoot, relativePath), 'utf8');

const LOCATION_KEY = 'NSLocationWhenInUseUsageDescription';
const LOCATION_PURPOSE =
'Your location is used to confirm where you are for this verification.';
const LOCATION_PURPOSE_ES =
'Tu ubicación se usa para confirmar dónde estás en esta verificación.';

const CLI_PLIST = 'example/ios/SdkReactNativeExample/Info.plist';
const EXPO_PLIST = 'example-expo/ios/DiditExpoExample/Info.plist';
const EXPO_APP_JSON = 'example-expo/app.json';
const CLI_ANDROID_MANIFEST = 'example/android/app/src/main/AndroidManifest.xml';

const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

/** The <string> value of a top-level plist key, or undefined when absent. */
function plistString(contents: string, key: string): string | undefined {
const match = contents.match(
new RegExp(`<key>${escapeRegExp(key)}</key>\\s*<string>([^<]*)</string>`)
);
return match?.[1];
}

function usageDescriptionKeys(contents: string): string[] {
return Array.from(
contents.matchAll(/<key>(\w+UsageDescription)<\/key>/g),
(match) => String(match[1])
).sort();
}

const expoInfoPlist = (): Record<string, unknown> =>
JSON.parse(read(EXPO_APP_JSON)).expo.ios.infoPlist;

describe('location purpose string', () => {
it.each([CLI_PLIST, EXPO_PLIST])('is declared in %s', (plistPath) => {
expect(plistString(read(plistPath), LOCATION_KEY)).toBe(LOCATION_PURPOSE);
});

it('is declared in the Expo example app.json', () => {
expect(expoInfoPlist()[LOCATION_KEY]).toBe(LOCATION_PURPOSE);
});

it('is the one the README tells integrators to add', () => {
expect(plistString(read('README.md'), LOCATION_KEY)).toBe(LOCATION_PURPOSE);
});

it('is documented with the same Spanish translation for bare and Expo apps', () => {
const readme = read('README.md');

expect(readme).toContain(`"${LOCATION_KEY}" = "${LOCATION_PURPOSE_ES}";`);
expect(readme).toContain(`"${LOCATION_KEY}": "${LOCATION_PURPOSE_ES}"`);
});
});

describe('Expo example native project', () => {
it('declares exactly the usage descriptions of its app.json, with the same copy', () => {
const plist = read(EXPO_PLIST);
const infoPlist = expoInfoPlist();
const appJsonKeys = Object.keys(infoPlist)
.filter((key) => key.endsWith('UsageDescription'))
.sort();

expect(usageDescriptionKeys(plist)).toEqual(appJsonKeys);
appJsonKeys.forEach((key) => {
expect(plistString(plist, key)).toBe(infoPlist[key]);
});
});
});

describe('Android example location permissions', () => {
it.each(['ACCESS_FINE_LOCATION', 'ACCESS_COARSE_LOCATION'])(
'declares %s so precise and approximate grants both work',
(permission) => {
expect(read(CLI_ANDROID_MANIFEST)).toContain(
`<uses-permission android:name="android.permission.${permission}" />`
);
}
);
});
Loading