From a04da647bc5305e02772c493a1e68cc9c22426c3 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Tue, 13 Oct 2026 13:02:03 +0000 Subject: [PATCH] DID-7085: [SDKA6b] React Native and Flutter Authentication wrappers bind to the shipped native DiditAuth API Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- README.md | 13 +- build-native-android.js | 2 + package.json | 8 +- .../DiditAuthReactNative.podspec | 51 +++- packages/auth-react-native/README.md | 219 ++++++++++++------ .../auth-react-native/android/build.gradle | 18 +- .../didit/authreactnative/DiditAuthModule.kt | 150 +++++++++++- .../ios/DiditAuthBridge.swift | 174 ++++++++++++++ .../ios/DiditAuthReactNative.mm | 119 ++++++++-- packages/auth-react-native/package.json | 6 +- .../auth-react-native/src/NativeDiditAuth.ts | 35 ++- .../src/__tests__/availability.test.ts | 47 ++-- .../src/__tests__/binding.test.ts | 218 +++++++++++++++++ .../src/__tests__/codegen.test.ts | 121 ++++++++-- .../src/__tests__/packaging.test.ts | 2 + packages/auth-react-native/src/errors.ts | 110 ++++++++- packages/auth-react-native/src/index.ts | 103 +++++--- packages/auth-react-native/src/types.ts | 81 +++++++ src/__tests__/native-android-build.test.ts | 31 +++ src/__tests__/native-sdk-pins.test.ts | 43 +++- src/__tests__/native-source.test.ts | 2 +- 21 files changed, 1362 insertions(+), 191 deletions(-) create mode 100644 packages/auth-react-native/ios/DiditAuthBridge.swift create mode 100644 packages/auth-react-native/src/__tests__/binding.test.ts create mode 100644 packages/auth-react-native/src/types.ts create mode 100644 src/__tests__/native-android-build.test.ts diff --git a/README.md b/README.md index d40c378..5a97fa2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ A React Native wrapper for the Didit Identity Verification SDK. Supports both iOS and Android with a unified TypeScript API built on React Native's New Architecture (TurboModules). -Authentication is being prepared as the separate -[`@didit-protocol/auth-react-native`](packages/auth-react-native/README.md) package. -It is private and unavailable until the native DiditAuth integration is ready. +Authentication is the separate +[`@didit-protocol/auth-react-native`](packages/auth-react-native/README.md) package, +on native DiditAuth. It is not published yet. Both packages pin the same native +SDK release so that one app can use them together. ## Requirements @@ -93,7 +94,7 @@ You do not need to request camera permission in your app code before calling `st Workflows can include a **Bank** step (the person links a bank account) and a **Location** step (the device position is read once, with the person's consent). Both screens are drawn by the native SDKs, so this package needs no new API: they run inside `startVerification()` / `startVerificationWithWorkflow()`, and the outcome comes back as the same `VerificationResult`. -> **Development preview:** these steps currently use the native integration source pinned in `diditNativeSdkVersions.source` in `package.json`. Set `DIDIT_SDK_SOURCE_PATH` to a clean checkout at that revision before building either example. A missing or different checkout fails the build instead of substituting the 4.9.1 release, which does not contain these steps. The temporary source pin must move to the released native version before the stacked push and package release. +> **Development preview:** these steps currently use the native integration source pinned in `diditNativeSdkVersions.source` in `package.json`. Set `DIDIT_SDK_SOURCE_PATH` to a clean checkout at that revision before building either example. A missing or different checkout fails the build instead of substituting a released native SDK. The temporary source pin must move to the released native version before the stacked push and package release. For Android source builds, apply this package's `android/native-source.gradle` from the host's `settings.gradle`. First run `yarn native:android` to build the pinned native revision with its own Gradle wrapper. The settings script exclusively resolves Didit artifacts from that local Maven repository, preventing a fallback to released artifacts. Both examples already do this; Expo prebuild adds it automatically. @@ -249,7 +250,7 @@ The iOS podspec URL is derived from the native SDK version this package pins (`d [ "@didit-protocol/sdk-react-native", { - "iosPodspecUrl": "https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.9.1/DiditSDK.podspec" + "iosPodspecUrl": "https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.10.0/DiditSDK.podspec" } ] ``` @@ -288,7 +289,7 @@ didit_sdk_subspec = case $DiditSdkIosVariant else raise "Invalid $DiditSdkIosVariant '#{$DiditSdkIosVariant}'. Supported values: all, core, autodetection, nfc." end -pod didit_sdk_subspec, :podspec => 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.9.1/DiditSDK.podspec' +pod didit_sdk_subspec, :podspec => 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.10.0/DiditSDK.podspec' ``` ##### Swift Package Manager (optional) diff --git a/build-native-android.js b/build-native-android.js index e09e664..9d31a1c 100644 --- a/build-native-android.js +++ b/build-native-android.js @@ -18,6 +18,8 @@ const modules = [ 'sdk-autodetection', 'sdk-nfc', 'sdk-wallet', + 'sdk-signals', + 'sdk-auth', ]; const args = [ ...modules.map( diff --git a/package.json b/package.json index 301927b..bed7f5a 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,11 @@ "name": "@didit-protocol/sdk-react-native", "version": "4.9.1", "diditNativeSdkVersions": { - "ios": "4.9.1", - "android": "4.9.1", + "ios": "4.10.0", + "android": "4.10.0", "source": { - "branch": "integration/console-v2", - "revision": "88259c3b2ac57520ead46579ee7c1fb56686cdb1" + "branch": "main", + "revision": "cda7fe301fbda606d4256cdd77baf2e850baba17" } }, "description": "React Native wrapper for Didit Identity Verification SDK", diff --git a/packages/auth-react-native/DiditAuthReactNative.podspec b/packages/auth-react-native/DiditAuthReactNative.podspec index 4f61005..e798f41 100644 --- a/packages/auth-react-native/DiditAuthReactNative.podspec +++ b/packages/auth-react-native/DiditAuthReactNative.podspec @@ -2,6 +2,29 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +# Single source of truth for the native iOS version: the same release as the +# verification package's, so both can share one app. +didit_sdk_ios_version = package.dig("diditNativeSdkVersions", "ios").to_s +if didit_sdk_ios_version.empty? + raise "Missing 'diditNativeSdkVersions.ios' in package.json - cannot pin DiditSDK/Auth." +end + +# "cocoapods" (default) depends on the DiditSDK/Auth subspec, whose podspec the +# host declares; "spm" adds the DiditAuth product of the sdk-ios package. One +# setting for both Didit packages, as in SdkReactNative.podspec. +didit_sdk_ios_linkage = ( + defined?($DiditSdkIosLinkage) && $DiditSdkIosLinkage ? + $DiditSdkIosLinkage.to_s : + ENV.fetch("DIDIT_SDK_IOS_LINKAGE", "cocoapods") +).downcase + +unless ["spm", "cocoapods"].include?(didit_sdk_ios_linkage) + raise "Invalid DiditSdk iOS linkage '#{didit_sdk_ios_linkage}'. Set $DiditSdkIosLinkage or DIDIT_SDK_IOS_LINKAGE to one of: spm, cocoapods." +end +if didit_sdk_ios_linkage == "spm" && !defined?(spm_dependency) + raise "DiditSdk iOS linkage 'spm' requires React Native 0.75 or newer (spm_dependency is unavailable). Use the 'cocoapods' linkage instead." +end + Pod::Spec.new do |s| s.name = "DiditAuthReactNative" s.version = package["version"] @@ -9,11 +32,33 @@ Pod::Spec.new do |s| s.homepage = package["homepage"] s.license = package["license"] s.authors = "Didit" - s.platforms = { :ios => min_ios_version_supported } + # DiditAuth needs iOS 15; its hosted pages need 17.4 at run time. + s.platforms = { :ios => [min_ios_version_supported, "15.0"].max_by { |version| Gem::Version.new(version) } } s.source = { :git => "https://github.com/didit-protocol/sdk-react-native.git", :tag => "auth-react-native-v#{s.version}" } - s.source_files = "ios/**/*.{h,mm}" + s.source_files = "ios/**/*.{h,mm,swift}" s.private_header_files = "ios/**/*.h" - # No DiditAuth dependency until a reviewed native artifact is available. + s.static_framework = true + s.swift_version = "5.0" + + s.pod_target_xcconfig = { + "FRAMEWORK_SEARCH_PATHS" => [ + '$(inherited)', + '"${PODS_CONFIGURATION_BUILD_DIR}/DiditSDK"', + '"${PODS_XCFRAMEWORKS_BUILD_DIR}/DiditSDK"', + '"${PODS_XCFRAMEWORKS_BUILD_DIR}/DiditSDK/Auth"' + ].join(" ") + } + + if didit_sdk_ios_linkage == "spm" + spm_dependency(s, + url: "https://github.com/didit-protocol/sdk-ios.git", + requirement: { kind: "exactVersion", version: didit_sdk_ios_version }, + products: ["DiditAuth"] + ) + else + s.dependency "DiditSDK/Auth", didit_sdk_ios_version + end + install_modules_dependencies(s) end diff --git a/packages/auth-react-native/README.md b/packages/auth-react-native/README.md index 32a1090..ebbf5d5 100644 --- a/packages/auth-react-native/README.md +++ b/packages/auth-react-native/README.md @@ -1,75 +1,152 @@ # DiditAuth for React Native -**Not ready for authentication or publication.** This private workspace prepares -`@didit-protocol/auth-react-native` as an independent native module. Both native -implementations currently reject every operation. There is no released native -DiditAuth dependency or reviewed initialization/result contract to connect yet. - -## Current behavior - -The named exports `signIn`, `currentSession`, `getToken`, `signOut` and `stepUp` -take no arguments and return `Promise` in this unavailable-only build. -They reserve the native method names, not the final integration API. Configuration, -options, successful session/token types and cancellation semantics must be added -with the reviewed native contract before publication. - -Importing the package without a linked native module is safe. Calling an operation -rejects with `code: 'ERR_DIDIT_AUTH_UNAVAILABLE'`. The native placeholders use the -same code as the JavaScript `DiditAuthUnavailableError`. Match the `code` across -the native boundary; native errors are not instances of the JavaScript class. -Other native rejections propagate unchanged. An unexpected native resolution also -rejects as unavailable, so this build never reports a successful authentication, -signed-out session, token retrieval, logout or step-up. - -No credentials are accepted, stored, logged or sent. The package has no dependency -on the verification wrapper or its camera, NFC and location features. It registers -its own `DiditAuthReactNative` TurboModule and `DiditAuthReactNativeSpec` codegen -library. It adds no screens, network calls, URL schemes or OS permissions. - -## Local integration - -Use React Native's New Architecture. Codegen is checked with React Native 0.77 and -the repository's 0.83 version; platform support remains subject to native builds -and the eventual DiditAuth dependency's minimum versions. Expo Go cannot load the -native module; a development build will be needed for a real native integration. - -From the repository root: - -```sh -yarn install --immutable -yarn build:auth -yarn workspace @didit-protocol/auth-react-native pack --out /tmp/didit-auth-react-native.tgz +`@didit-protocol/auth-react-native` signs your app's end users up and in with Didit +Authentication. It calls native DiditAuth (`me.didit:didit-auth` on Android, +`DiditSDK/Auth` on iOS): your application's hosted Didit page with PKCE, passkeys, +sessions in the Keychain or Android Keystore, sign-out and step-ups before +sensitive actions. Every request carries the device signals of DiditSignals. + +**Not published yet.** The package is private and pins native SDK 4.10.0 +(`diditNativeSdkVersions` in its `package.json`), the first release with DiditAuth. + +## Requirements + +- React Native 0.77 or later with the New Architecture. Expo Go cannot load the + native module; use a development build. +- iOS 15 or later; the hosted pages (sign-in and step-up checks) need iOS 17.4. + Below 17.4 those calls fail with `browser_unavailable`. +- Android: passkeys need Android 9 or later. + +## Set up the redirect + +Didit sends the person back to your app with an HTTPS redirect; it accepts no +custom URL schemes. + +1. In the Didit console (Authentication, Developers), add the redirect URI, for + example `https://app.example.com/didit/callback`, and copy the publishable key. +2. Android: make that address an App Link of your app and declare DiditAuth's + redirect activity with it in `android/app/src/main/AndroidManifest.xml` (add + `xmlns:tools="http://schemas.android.com/tools"` to the manifest element): + + ```xml + + + + + + + + + ``` + + Publish `https://app.example.com/.well-known/assetlinks.json` for your package + and signing certificate. +3. iOS: add `webcredentials:app.example.com` to the app's Associated Domains and + serve `https://app.example.com/.well-known/apple-app-site-association` listing + your app under `webcredentials`. +4. Passkeys: add your Android package and certificate fingerprints, and your iOS + app ID, under Native apps in the console. + +The native SDK guides have the details: [Android](https://github.com/didit-protocol/sdk-android) +and [iOS](https://github.com/didit-protocol/sdk-ios). + +## Install the native SDK + +Android resolves `me.didit:didit-auth` from the Didit Maven repository. Add it to the +project-level `settings.gradle` (the verification package needs the same entry): + +```groovy +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + url "https://raw.githubusercontent.com/didit-protocol/sdk-android/main/repository" + content { includeGroup "me.didit" } + } + } +} +``` + +On iOS, CocoaPods is the default. DiditSDK is not on the CocoaPods trunk, so the +Podfile names its podspec at the pinned version: + +```ruby +pod 'DiditSDK/Auth', :podspec => 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.10.0/DiditSDK.podspec' +``` + +To resolve it with Swift Package Manager instead, set `$DiditSdkIosLinkage = 'spm'` +before `use_native_modules!` and leave the `pod` line out. The package then adds the +`DiditAuth` product of `sdk-ios`. + +## Use + +```ts +import { createDiditAuth, DiditAuthError } from '@didit-protocol/auth-react-native'; + +// One per application; reuse it. +const auth = createDiditAuth({ + publishableKey: 'pk_live_...', + redirectUri: 'https://app.example.com/didit/callback', +}); + +const session = await auth.signIn(); // hosted page: sign-up and sign-in +await auth.signInWithPasskey(); // a passkey of this device +const passkeyId = await auth.registerPasskey(); // needs a session at most five minutes old + +await auth.currentSession(); // DiditSession or null, cached for 30 seconds +await auth.currentSession({ force: true }); // asks Didit now +const token = await auth.getToken(); // five-minute token for your backend, or null +await auth.signOut(); + +const result = await auth.stepUp({ action: 'payout', amount: '900.00', currency: 'EUR' }); +if (result.approved) { + // Your backend confirms with GET step-up/{result.id}/ before it pays out. +} +``` + +`stepUp` also takes `{ action: 'bank' }` (adding or changing a bank account) and +`{ action: 'contact' }` (changing the email or phone). The result's `status` is +`not_required`, `approved`, `pending`, `review`, `declined` or `expired`. + +A `DiditSession` has `userId`, `sessionId`, `applicationId`, `vendorData`, `email`, +`emailVerified`, `phone`, `methods`, `mfaVerified`, `verified`, `organizations` and +`expiresAt`. Verify the access token on your backend against the issuer's JWKS. +Refresh tokens stay in native storage and never reach JavaScript. + +## Errors + +Every call rejects with a `DiditAuthError` carrying native DiditAuth's `code` and +`message` unchanged, plus `status`, `fields` and `retryAfter` when Didit sent them: + +- the Didit API's own codes, such as `invalid_grant`, `method_unavailable` or + `reauthentication_required`; +- `cancelled`, `invalid_state`, `not_signed_in`, `passkey_cancelled`, + `passkey_unavailable`, `browser_unavailable`, `storage_unavailable`, + `invalid_publishable_key`, `invalid_redirect_uri`, `network_error`, + `rate_limited` (see `retryAfter`) and `server_error`. + +```ts +try { + await auth.signIn(); +} catch (error) { + if (error instanceof DiditAuthError && error.code === 'cancelled') return; + throw error; +} ``` -The archive can be installed in a disposable React Native application for -autolinking checks, either alone or alongside the verification package. `private: -true` blocks registry publication. The root verification release does not include -this workspace. This package uses the existing Bob toolchain for CommonJS, ESM, -TypeScript declarations and React Native source exports. - -## Completing the native integration - -Before enabling or publishing the package: - -1. Connect the reviewed native DiditAuth initialization and method contracts. - Replace the rejecting native implementations and the JavaScript success gate - together. Define typed session results, options and distinct cancellation, - signed-out, expired/revoked and failure outcomes from that contract. -2. Add real distribution coordinates with native versions single-sourced through - `diditNativeSdkVersions`; never borrow the verification package's pins. -3. Keep PKCE/state validation, callback ownership, passkeys, secure token storage, - serialized refresh, logout/revocation and automatic signals in native DiditAuth. - Treat access tokens as opaque values. Never expose refresh tokens to JavaScript. -4. Document and test the native callback/associated-domain setup against the - supported backend contract before adding an Expo configuration plugin. Verify - app/environment isolation, process restoration and callback replay rejection. -5. Run real native sign-in, cancel, session restore/expiry/revocation, concurrent - refresh, sign-out and step-up tests. Capture the launched hosted/native flows - in both themes and required sizes. iOS compilation and device/passkey checks - require a Mac. Codegen and package tests alone do not prove native runtime or - authentication readiness. - -The matching Flutter `didit_auth` requires its own package in `sdk-flutter`, with -the same native dependency and acceptance checks. It is not included in this -React Native workspace. Hosted pages, backend contracts and native DiditAuth -remain separate dependencies; this package does not replace their implementations. +Calls also fail with `browser_unavailable` when no screen of the app is in the +foreground. In a build without the native module, such as Expo Go, importing the +package works and every call rejects with `DiditAuthUnavailableError` (code +`ERR_DIDIT_AUTH_UNAVAILABLE`). + +## With the verification package + +`@didit-protocol/sdk-react-native` and this package pin the same native release, so +both can be installed in one app. On iOS the verification SDK's `DiditSDK` binary +also contains DiditAuth; with both packages the app ships a second copy of +DiditAuth and DiditSignals in the `DiditAuth` binary. diff --git a/packages/auth-react-native/android/build.gradle b/packages/auth-react-native/android/build.gradle index d6b7062..44c439c 100644 --- a/packages/auth-react-native/android/build.gradle +++ b/packages/auth-react-native/android/build.gradle @@ -20,6 +20,8 @@ if (project.extensions.findByName('kotlin') == null) { } apply plugin: "com.facebook.react" +def nativePins = new groovy.json.JsonSlurper().parse(file("../package.json")).diditNativeSdkVersions + android { namespace "me.didit.authreactnative" compileSdkVersion authExtOrDefault("compileSdkVersion", 36) @@ -39,8 +41,20 @@ android { } } +repositories { + // Didit SDK Maven repository, scoped to Didit's group like the verification + // package's: unfiltered, Gradle would probe it for every dependency. + maven { + url "https://raw.githubusercontent.com/didit-protocol/sdk-android/main/repository" + content { includeGroup "me.didit" } + } +} + dependencies { implementation "com.facebook.react:react-android" - // No DiditAuth artifact is released yet. Keep this unavailable-only module - // independent of verification and add real pins only after native review. + + // Didit Authentication; it brings didit-signals only, not the verification SDK. + implementation "me.didit:didit-auth:${nativePins.android}" + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" } diff --git a/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt b/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt index c922276..a7ffd06 100644 --- a/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt +++ b/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt @@ -1,25 +1,159 @@ package me.didit.authreactnative +import android.app.Activity +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.annotations.ReactModule +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import me.didit.sdk.auth.DiditAuth +import me.didit.sdk.auth.DiditAuthException +import me.didit.sdk.auth.DiditSession +import me.didit.sdk.auth.DiditStepUpAction +import me.didit.sdk.auth.DiditStepUpResult +import org.json.JSONArray +import org.json.JSONObject -/** Native auth remains unavailable until its reviewed implementation is linked. */ +/** Forwards every call to native DiditAuth; its error codes reach JavaScript unchanged. */ @ReactModule(name = DiditAuthModule.NAME) class DiditAuthModule(context: ReactApplicationContext) : NativeDiditAuthSpec(context) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // One DiditAuth per application, created and read on the main thread only. + private val instances = mutableMapOf, DiditAuth>() + override fun getName() = NAME - override fun signIn(promise: Promise) = unavailable(promise) - override fun currentSession(promise: Promise) = unavailable(promise) - override fun getToken(promise: Promise) = unavailable(promise) - override fun signOut(promise: Promise) = unavailable(promise) - override fun stepUp(promise: Promise) = unavailable(promise) + override fun signIn(publishableKey: String, redirectUri: String, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> json(session(auth.signIn(activity()))) } + + override fun signInWithPasskey(publishableKey: String, redirectUri: String, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> json(session(auth.signInWithPasskey(activity()))) } + + override fun registerPasskey(publishableKey: String, redirectUri: String, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> auth.registerPasskey(activity()) } + + override fun currentSession(publishableKey: String, redirectUri: String, force: Boolean, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> auth.currentSession(force)?.let { json(session(it)) } } + + override fun getToken(publishableKey: String, redirectUri: String, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> auth.getToken() } + + override fun signOut(publishableKey: String, redirectUri: String, promise: Promise) = + forward(publishableKey, redirectUri, promise) { auth -> auth.signOut(); null } + + override fun stepUp( + publishableKey: String, + redirectUri: String, + action: String, + amount: String?, + currency: String?, + promise: Promise + ) = forward(publishableKey, redirectUri, promise) { auth -> + json(stepUpResult(auth.stepUp(activity(), stepUpAction(action, amount, currency)))) + } + + override fun invalidate() { + scope.cancel() + super.invalidate() + } - private fun unavailable(promise: Promise) { - promise.reject("ERR_DIDIT_AUTH_UNAVAILABLE", "DiditAuth is unavailable in this build.") + private fun forward( + publishableKey: String, + redirectUri: String, + promise: Promise, + block: suspend (DiditAuth) -> Any? + ) { + scope.launch { + try { + promise.resolve(block(auth(publishableKey, redirectUri))) + } catch (e: CancellationException) { + throw e + } catch (e: DiditAuthException) { + promise.reject(e.code, e.message, e, userInfo(e)) + } catch (e: Exception) { + promise.reject(UNSPECIFIED, e.message, e) + } + } + } + + private fun auth(publishableKey: String, redirectUri: String): DiditAuth = + instances.getOrPut(publishableKey to redirectUri) { + try { + DiditAuth(reactApplicationContext, publishableKey, redirectUri) + } catch (e: IllegalArgumentException) { + // Android validates the redirect with require(); iOS throws this code for it. + throw DiditAuthException("invalid_redirect_uri", e.message ?: "redirectUri is not a valid HTTPS address.") + } + } + + private fun activity(): Activity = + reactApplicationContext.currentActivity + ?: throw DiditAuthException("browser_unavailable", "No activity is in the foreground to show the Didit page.") + + private fun stepUpAction(action: String, amount: String?, currency: String?): DiditStepUpAction = + when (action) { + "payout" -> DiditStepUpAction.Payout( + amount ?: throw DiditAuthException("invalid_request", "A payout step-up needs an amount."), + currency + ) + "bank" -> DiditStepUpAction.BankAccount + "contact" -> DiditStepUpAction.ContactChange + else -> throw DiditAuthException("invalid_request", "Unknown step-up action: $action.") + } + + private fun userInfo(e: DiditAuthException) = Arguments.createMap().apply { + e.status?.let { putInt("status", it) } + e.retryAfterSeconds?.let { putInt("retryAfter", it) } + e.fields?.let { fields -> + putMap("fields", Arguments.createMap().apply { + fields.forEach { (field, codes) -> putArray(field, Arguments.fromList(codes)) } + }) + } } companion object { const val NAME = "DiditAuthReactNative" + + // React Native's code for a rejection that is not a DiditAuth error. + private const val UNSPECIFIED = "EUNSPECIFIED" + + fun session(session: DiditSession): Map = mapOf( + "userId" to session.userId, + "sessionId" to session.sessionId, + "applicationId" to session.applicationId, + "vendorData" to session.vendorData, + "email" to session.email, + "emailVerified" to session.emailVerified, + "phone" to session.phone, + "methods" to session.methods, + "mfaVerified" to session.mfaVerified, + "verified" to session.verified, + "organizations" to session.organizations.map { mapOf("id" to it.id, "name" to it.name, "role" to it.role) }, + "expiresAt" to session.expiresAt + ) + + fun stepUpResult(result: DiditStepUpResult): Map = mapOf( + "id" to result.id, + "status" to result.status, + "approved" to result.isApproved, + "rule" to result.rule, + "method" to result.method + ) + + /** JSON text, keeping nulls (org.json drops null map values). */ + fun json(value: Map): String = (toJson(value) as JSONObject).toString() + + private fun toJson(value: Any?): Any = when (value) { + null -> JSONObject.NULL + is Map<*, *> -> JSONObject().apply { value.forEach { (key, item) -> put(key.toString(), toJson(item)) } } + is Iterable<*> -> JSONArray().apply { value.forEach { put(toJson(it)) } } + else -> value + } } } diff --git a/packages/auth-react-native/ios/DiditAuthBridge.swift b/packages/auth-react-native/ios/DiditAuthBridge.swift new file mode 100644 index 0000000..07abdc9 --- /dev/null +++ b/packages/auth-react-native/ios/DiditAuthBridge.swift @@ -0,0 +1,174 @@ +import UIKit +#if canImport(DiditAuth) +@preconcurrency import DiditAuth +#else +// A DiditSDK source pod compiles DiditAuth into the DiditSDK module. +@preconcurrency import DiditSDK +#endif + +/// Forwards the TurboModule's calls to native DiditAuth; its error codes reach JavaScript unchanged. +/// Sessions and step-up results cross the bridge as JSON strings. +@objcMembers +public final class DiditAuthBridge: NSObject, @unchecked Sendable { + public typealias Resolve = @Sendable (String?) -> Void + public typealias Reject = @Sendable (String, String, NSDictionary?) -> Void + + // React Native's code for a rejection that is not a DiditAuth error. + private static let unspecified = "EUNSPECIFIED" + + // One DiditAuth per application, created and read on the main actor only. + @MainActor private var instances: [String: DiditAuth] = [:] + + public func signIn(publishableKey: String, redirectUri: String, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try Self.json(Self.session(try await auth.signIn(from: try Self.presenter()))) + } + } + + public func signInWithPasskey(publishableKey: String, redirectUri: String, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try Self.json(Self.session(try await auth.signInWithPasskey(from: try Self.presenter()))) + } + } + + public func registerPasskey(publishableKey: String, redirectUri: String, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try await auth.registerPasskey(from: try Self.presenter()) + } + } + + public func currentSession(publishableKey: String, redirectUri: String, force: Bool, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try await auth.currentSession(force: force).map { try Self.json(Self.session($0)) } + } + } + + public func getToken(publishableKey: String, redirectUri: String, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try await auth.getToken() + } + } + + public func signOut(publishableKey: String, redirectUri: String, resolve: @escaping Resolve, reject: @escaping Reject) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + try await auth.signOut() + return nil + } + } + + public func stepUp( + publishableKey: String, + redirectUri: String, + action: String, + amount: String?, + currency: String?, + resolve: @escaping Resolve, + reject: @escaping Reject + ) { + forward(publishableKey, redirectUri, resolve, reject) { auth in + let stepUpAction = try Self.stepUpAction(action, amount: amount, currency: currency) + return try Self.json(Self.stepUpResult(try await auth.stepUp(stepUpAction, from: try Self.presenter()))) + } + } + + private func forward( + _ publishableKey: String, + _ redirectUri: String, + _ resolve: @escaping Resolve, + _ reject: @escaping Reject, + _ operation: @escaping @MainActor @Sendable (DiditAuth) async throws -> String? + ) { + Task { @MainActor in + do { + resolve(try await operation(try self.auth(publishableKey, redirectUri))) + } catch let error as DiditAuthError { + reject(error.code, error.message, Self.userInfo(error) as NSDictionary) + } catch { + reject(Self.unspecified, error.localizedDescription, nil) + } + } + } + + @MainActor + private func auth(_ publishableKey: String, _ redirectUri: String) throws -> DiditAuth { + let key = publishableKey + "\n" + redirectUri + if let auth = instances[key] { return auth } + let auth = try DiditAuth(publishableKey: publishableKey, redirectUri: redirectUri) + instances[key] = auth + return auth + } + + @MainActor + private static func presenter() throws -> UIViewController { + guard let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first, + var controller = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController + else { + throw DiditAuthError("browser_unavailable", "No screen is in the foreground to show the Didit page.") + } + while let presented = controller.presentedViewController, !presented.isBeingDismissed { + controller = presented + } + return controller + } + + private static func stepUpAction(_ action: String, amount: String?, currency: String?) throws -> DiditStepUpAction { + switch action { + case "payout": + guard let amount = amount else { + throw DiditAuthError("invalid_request", "A payout step-up needs an amount.") + } + return .payout(amount: amount, currency: currency) + case "bank": + return .bankAccount + case "contact": + return .contactChange + default: + throw DiditAuthError("invalid_request", "Unknown step-up action: \(action).") + } + } + + private static func userInfo(_ error: DiditAuthError) -> [String: Any] { + var info: [String: Any] = [:] + if let status = error.status { info["status"] = status } + if let fields = error.fields { info["fields"] = fields } + if let retryAfter = error.retryAfter { info["retryAfter"] = retryAfter } + return info + } + + private static func session(_ session: DiditSession) -> [String: Any] { + [ + "userId": session.userId, + "sessionId": session.sessionId, + "applicationId": session.applicationId, + "vendorData": orNull(session.vendorData), + "email": orNull(session.email), + "emailVerified": session.emailVerified, + "phone": orNull(session.phone), + "methods": session.methods, + "mfaVerified": session.mfaVerified, + "verified": orNull(session.verified), + "organizations": session.organizations.map { ["id": $0.id, "name": $0.name, "role": $0.role] }, + "expiresAt": session.expiresAt, + ] + } + + private static func stepUpResult(_ result: DiditStepUpResult) -> [String: Any] { + [ + "id": orNull(result.id), + "status": result.status, + "approved": result.isApproved, + "rule": orNull(result.rule), + "method": orNull(result.method), + ] + } + + private static func orNull(_ value: Any?) -> Any { + value ?? NSNull() + } + + private static func json(_ object: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + } +} diff --git a/packages/auth-react-native/ios/DiditAuthReactNative.mm b/packages/auth-react-native/ios/DiditAuthReactNative.mm index 956a5b8..9cc28e5 100644 --- a/packages/auth-react-native/ios/DiditAuthReactNative.mm +++ b/packages/auth-react-native/ios/DiditAuthReactNative.mm @@ -1,48 +1,137 @@ #import "DiditAuthReactNative.h" -@implementation DiditAuthReactNative +// The Swift-generated header is named after the pod's module (DiditAuthReactNative). +#if __has_include() +#import +#else +#import "DiditAuthReactNative-Swift.h" +#endif + +typedef void (^DiditAuthRejectBlock)(NSString *code, NSString *message, NSDictionary *userInfoFields); + +/// Keeps DiditAuth's status, fields and retryAfter on the JavaScript error's userInfo. +static DiditAuthRejectBlock DiditAuthReject(RCTPromiseRejectBlock reject) +{ + return ^(NSString *code, NSString *message, NSDictionary *userInfoFields) { + NSMutableDictionary *userInfo = [NSMutableDictionary new]; + if (message) { + userInfo[NSLocalizedDescriptionKey] = message; + } + if (userInfoFields) { + [userInfo addEntriesFromDictionary:userInfoFields]; + } + reject(code, message, [NSError errorWithDomain:@"me.didit.sdk.auth" code:0 userInfo:userInfo]); + }; +} + +@implementation DiditAuthReactNative { + DiditAuthBridge *_bridge; +} RCT_EXPORT_MODULE(DiditAuthReactNative) + (BOOL)requiresMainQueueSetup { - return NO; + return NO; +} + +- (instancetype)init +{ + if (self = [super init]) { + _bridge = [[DiditAuthBridge alloc] init]; + } + return self; +} + +- (void)signIn:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_bridge signInWithPublishableKey:publishableKey + redirectUri:redirectUri + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)rejectUnavailable:(RCTPromiseRejectBlock)reject +- (void)signInWithPasskey:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - reject(@"ERR_DIDIT_AUTH_UNAVAILABLE", @"DiditAuth is unavailable in this build.", nil); + [_bridge signInWithPasskeyWithPublishableKey:publishableKey + redirectUri:redirectUri + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)signIn:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)registerPasskey:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self rejectUnavailable:reject]; + [_bridge registerPasskeyWithPublishableKey:publishableKey + redirectUri:redirectUri + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)currentSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)currentSession:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + force:(BOOL)force + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self rejectUnavailable:reject]; + [_bridge currentSessionWithPublishableKey:publishableKey + redirectUri:redirectUri + force:force + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)getToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)getToken:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self rejectUnavailable:reject]; + [_bridge getTokenWithPublishableKey:publishableKey + redirectUri:redirectUri + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)signOut:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)signOut:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self rejectUnavailable:reject]; + [_bridge signOutWithPublishableKey:publishableKey + redirectUri:redirectUri + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } -- (void)stepUp:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)stepUp:(NSString *)publishableKey + redirectUri:(NSString *)redirectUri + action:(NSString *)action + amount:(NSString * _Nullable)amount + currency:(NSString * _Nullable)currency + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self rejectUnavailable:reject]; + [_bridge stepUpWithPublishableKey:publishableKey + redirectUri:redirectUri + action:action + amount:amount + currency:currency + resolve:^(NSString *result) { resolve(result); } + reject:DiditAuthReject(reject)]; } - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { - return std::make_shared(params); + return std::make_shared(params); } @end diff --git a/packages/auth-react-native/package.json b/packages/auth-react-native/package.json index dee8231..e744f71 100644 --- a/packages/auth-react-native/package.json +++ b/packages/auth-react-native/package.json @@ -2,7 +2,11 @@ "name": "@didit-protocol/auth-react-native", "version": "0.0.0", "private": true, - "description": "React Native boundary for DiditAuth (native integration pending)", + "diditNativeSdkVersions": { + "ios": "4.10.0", + "android": "4.10.0" + }, + "description": "React Native wrapper for Didit Authentication (native DiditAuth)", "main": "./lib/commonjs/index.js", "module": "./lib/module/index.js", "types": "./lib/typescript/commonjs/src/index.d.ts", diff --git a/packages/auth-react-native/src/NativeDiditAuth.ts b/packages/auth-react-native/src/NativeDiditAuth.ts index 43bf4fd..6308c21 100644 --- a/packages/auth-react-native/src/NativeDiditAuth.ts +++ b/packages/auth-react-native/src/NativeDiditAuth.ts @@ -1,15 +1,36 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; /** - * Unavailable-only seam, not the final native DiditAuth API. - * Arguments and successful results require the reviewed native contract. + * TurboModule specification for native DiditAuth (`me.didit:didit-auth` on + * Android, `DiditSDK/Auth` on iOS). Every method names the application with its + * publishable key and redirect URI; native keeps one DiditAuth per pair. + * Sessions and step-up results cross the bridge as JSON strings. */ export interface Spec extends TurboModule { - signIn(): Promise; - currentSession(): Promise; - getToken(): Promise; - signOut(): Promise; - stepUp(): Promise; + signIn(publishableKey: string, redirectUri: string): Promise; + signInWithPasskey( + publishableKey: string, + redirectUri: string + ): Promise; + /** Resolves with the new passkey's id. */ + registerPasskey(publishableKey: string, redirectUri: string): Promise; + /** Resolves with null when nobody is signed in. */ + currentSession( + publishableKey: string, + redirectUri: string, + force: boolean + ): Promise; + /** Resolves with null when nobody is signed in. */ + getToken(publishableKey: string, redirectUri: string): Promise; + signOut(publishableKey: string, redirectUri: string): Promise; + /** `action` is "payout", "bank" or "contact"; amount and currency apply to payouts. */ + stepUp( + publishableKey: string, + redirectUri: string, + action: string, + amount: string | null, + currency: string | null + ): Promise; } // Importing the package must also work before a native rebuild, or in Expo Go. diff --git a/packages/auth-react-native/src/__tests__/availability.test.ts b/packages/auth-react-native/src/__tests__/availability.test.ts index 058c258..3599ed1 100644 --- a/packages/auth-react-native/src/__tests__/availability.test.ts +++ b/packages/auth-react-native/src/__tests__/availability.test.ts @@ -1,24 +1,34 @@ import { TurboModuleRegistry } from 'react-native'; import { - currentSession, + createDiditAuth, + DiditAuthError, DiditAuthUnavailableError, - getToken, - signIn, - signOut, - stepUp, } from '../index'; -const operations = [signIn, currentSession, getToken, signOut, stepUp]; +const auth = createDiditAuth({ + publishableKey: 'pk_test_key', + redirectUri: 'https://app.example.com/didit/callback', +}); + +const operations = { + signIn: () => auth.signIn(), + signInWithPasskey: () => auth.signInWithPasskey(), + registerPasskey: () => auth.registerPasskey(), + currentSession: () => auth.currentSession(), + getToken: () => auth.getToken(), + signOut: () => auth.signOut(), + stepUp: () => auth.stepUp({ action: 'bank' }), +}; describe('authentication without a linked native module', () => { - it('can be imported before the native application is rebuilt', () => { + it('can be imported and configured before the native application is rebuilt', () => { expect(TurboModuleRegistry.get('DiditAuthReactNative')).toBeNull(); - expect(operations).toHaveLength(5); + expect(Object.keys(operations)).toEqual(Object.keys(auth)); }); - it.each(operations)( - '%p rejects instead of returning a session or success', - async (operation) => { + it.each(Object.entries(operations))( + '%s rejects instead of returning a session or success', + async (_, operation) => { await expect(operation()).rejects.toMatchObject({ name: 'DiditAuthUnavailableError', code: 'ERR_DIDIT_AUTH_UNAVAILABLE', @@ -27,16 +37,11 @@ describe('authentication without a linked native module', () => { } ); - it('keeps concurrent and repeated calls unavailable', async () => { - const results = await Promise.allSettled( - [...operations, ...operations].map((operation) => operation()) + it('reports unavailability as a DiditAuthError', async () => { + await expect(auth.signIn()).rejects.toBeInstanceOf( + DiditAuthUnavailableError ); - - expect(results.every((result) => result.status === 'rejected')).toBe(true); - }); - - it('uses an Error subclass for JavaScript failures', async () => { - await expect(signIn()).rejects.toBeInstanceOf(DiditAuthUnavailableError); - await expect(signOut()).rejects.toBeInstanceOf(Error); + await expect(auth.signOut()).rejects.toBeInstanceOf(DiditAuthError); + await expect(auth.getToken()).rejects.toBeInstanceOf(Error); }); }); diff --git a/packages/auth-react-native/src/__tests__/binding.test.ts b/packages/auth-react-native/src/__tests__/binding.test.ts new file mode 100644 index 0000000..28ec89f --- /dev/null +++ b/packages/auth-react-native/src/__tests__/binding.test.ts @@ -0,0 +1,218 @@ +import NativeDiditAuth from '../NativeDiditAuth'; +import { + createDiditAuth, + DiditAuthError, + type DiditSession, + type DiditStepUpResult, +} from '../index'; + +jest.mock('../NativeDiditAuth', () => ({ + __esModule: true, + default: { + signIn: jest.fn(), + signInWithPasskey: jest.fn(), + registerPasskey: jest.fn(), + currentSession: jest.fn(), + getToken: jest.fn(), + signOut: jest.fn(), + stepUp: jest.fn(), + }, +})); + +const native = NativeDiditAuth as unknown as Record; +const publishableKey = 'pk_test_key'; +const redirectUri = 'https://app.example.com/didit/callback'; +const auth = createDiditAuth({ publishableKey, redirectUri }); + +// The JSON native DiditAuth's bridge writes for a DiditSession. +const session: DiditSession = { + userId: 'user-id', + sessionId: 'session-id', + applicationId: 'application-id', + vendorData: null, + email: 'person@example.com', + emailVerified: true, + phone: null, + methods: ['password'], + mfaVerified: false, + verified: null, + organizations: [{ id: 'organization-id', name: 'Example', role: 'member' }], + expiresAt: '2026-11-12T08:36:53Z', +}; + +// Native's own codes, plus some of the API's that native passes through. +const codes = [ + 'cancelled', + 'invalid_state', + 'not_signed_in', + 'passkey_cancelled', + 'passkey_unavailable', + 'browser_unavailable', + 'storage_unavailable', + 'rate_limited', + 'invalid_publishable_key', + 'invalid_redirect_uri', + 'network_error', + 'server_error', + 'invalid_grant', + 'reauthentication_required', +]; + +afterEach(() => jest.resetAllMocks()); + +describe('calls native DiditAuth with the application', () => { + it.each([ + ['signIn', () => auth.signIn()], + ['signInWithPasskey', () => auth.signInWithPasskey()], + ] as const)('%s resolves with the native session', async (method, call) => { + native[method]!.mockResolvedValue(JSON.stringify(session)); + + await expect(call()).resolves.toEqual(session); + expect(native[method]).toHaveBeenCalledWith(publishableKey, redirectUri); + }); + + it('registerPasskey resolves with the passkey id', async () => { + native.registerPasskey!.mockResolvedValue('passkey-id'); + + await expect(auth.registerPasskey()).resolves.toBe('passkey-id'); + expect(native.registerPasskey).toHaveBeenCalledWith( + publishableKey, + redirectUri + ); + }); + + it('currentSession uses the native cache unless forced', async () => { + native.currentSession!.mockResolvedValue(JSON.stringify(session)); + + await expect(auth.currentSession()).resolves.toEqual(session); + await expect(auth.currentSession({ force: true })).resolves.toEqual( + session + ); + expect(native.currentSession!.mock.calls).toEqual([ + [publishableKey, redirectUri, false], + [publishableKey, redirectUri, true], + ]); + }); + + it('reports a signed-out person as null, not as an error', async () => { + native.currentSession!.mockResolvedValue(null); + native.getToken!.mockResolvedValue(null); + + await expect(auth.currentSession()).resolves.toBeNull(); + await expect(auth.getToken()).resolves.toBeNull(); + }); + + it('getToken resolves with the native access token', async () => { + native.getToken!.mockResolvedValue('access-token'); + + await expect(auth.getToken()).resolves.toBe('access-token'); + expect(native.getToken).toHaveBeenCalledWith(publishableKey, redirectUri); + }); + + it('signOut resolves once native signed out', async () => { + native.signOut!.mockResolvedValue(null); + + await expect(auth.signOut()).resolves.toBeUndefined(); + expect(native.signOut).toHaveBeenCalledWith(publishableKey, redirectUri); + }); +}); + +describe('step-ups', () => { + const result: DiditStepUpResult = { + id: 'step-up-id', + status: 'approved', + approved: true, + rule: 'payout', + method: 'liveness', + }; + + it.each([ + [ + { action: 'payout', amount: '900.00', currency: 'EUR' }, + ['payout', '900.00', 'EUR'], + ], + [{ action: 'payout', amount: '900.00' }, ['payout', '900.00', null]], + [{ action: 'bank' }, ['bank', null, null]], + [{ action: 'contact' }, ['contact', null, null]], + ] as const)('passes %j to native', async (options, args) => { + native.stepUp!.mockResolvedValue(JSON.stringify(result)); + + await expect(auth.stepUp(options)).resolves.toEqual(result); + expect(native.stepUp).toHaveBeenCalledWith( + publishableKey, + redirectUri, + ...args + ); + }); + + it('returns what Didit recorded when no rule applies', async () => { + const notRequired = { + id: null, + status: 'not_required', + approved: true, + rule: null, + method: null, + }; + native.stepUp!.mockResolvedValue(JSON.stringify(notRequired)); + + await expect(auth.stepUp({ action: 'bank' })).resolves.toEqual(notRequired); + }); +}); + +describe('native errors', () => { + it.each(codes)('surfaces %s unchanged', async (code) => { + native.signIn!.mockRejectedValue( + Object.assign(new Error(`${code} message`), { code, userInfo: null }) + ); + + const error = await auth.signIn().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(DiditAuthError); + expect(error).toMatchObject({ code, message: `${code} message` }); + }); + + it('keeps the status, fields and retry delay Didit sent', async () => { + native.stepUp!.mockRejectedValue( + Object.assign(new Error('Too many requests.'), { + code: 'rate_limited', + userInfo: { + status: 429, + retryAfter: 30, + fields: { return_to: ['invalid_redirect_uri'] }, + }, + }) + ); + + await expect(auth.stepUp({ action: 'bank' })).rejects.toMatchObject({ + code: 'rate_limited', + message: 'Too many requests.', + status: 429, + retryAfter: 30, + fields: { return_to: ['invalid_redirect_uri'] }, + }); + }); + + it('ignores malformed native details instead of reporting wrong ones', async () => { + native.getToken!.mockRejectedValue( + Object.assign(new Error('Server error.'), { + code: 'server_error', + userInfo: { status: '500', fields: { email: 'invalid' } }, + }) + ); + + const error = (await auth.getToken().catch((e: unknown) => e)) as + | DiditAuthError + | undefined; + + expect(error).toMatchObject({ code: 'server_error' }); + expect(error?.status).toBeUndefined(); + expect(error?.fields).toBeUndefined(); + }); + + it('rethrows failures that carry no code as they are', async () => { + const failure = new TypeError('Expected a string'); + native.signOut!.mockRejectedValue(failure); + + await expect(auth.signOut()).rejects.toBe(failure); + }); +}); diff --git a/packages/auth-react-native/src/__tests__/codegen.test.ts b/packages/auth-react-native/src/__tests__/codegen.test.ts index 7cbd1ac..98708f2 100644 --- a/packages/auth-react-native/src/__tests__/codegen.test.ts +++ b/packages/auth-react-native/src/__tests__/codegen.test.ts @@ -3,9 +3,30 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { codegenConfig } from '../../package.json'; -const specPath = join(__dirname, '..', 'NativeDiditAuth.ts'); -const methods = ['signIn', 'currentSession', 'getToken', 'signOut', 'stepUp']; +const packageRoot = join(__dirname, '..', '..'); +const specPath = join(packageRoot, 'src', 'NativeDiditAuth.ts'); const versions = ['react-native-codegen-077', '@react-native/codegen']; +const methods = [ + 'signIn', + 'signInWithPasskey', + 'registerPasskey', + 'currentSession', + 'getToken', + 'signOut', + 'stepUp', +]; + +const kotlinModule = readFileSync( + join( + packageRoot, + 'android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt' + ), + 'utf8' +); +const objcModule = readFileSync( + join(packageRoot, 'ios/DiditAuthReactNative.mm'), + 'utf8' +); function generateNativeInterfaces(version: string, outputDirectory: string) { const { @@ -28,33 +49,93 @@ function generateNativeInterfaces(version: string, outputDirectory: string) { ); } +const squash = (text: string) => + text.replace(/\s+/g, ' ').replace(/\( /g, '(').replace(/ \)/g, ')'); + +// Java's abstract methods as the Kotlin overrides that implement them. +function kotlinOverrides(java: string): string[] { + const kotlinType: Record = { + 'String': 'String', + '@Nullable String': 'String?', + 'boolean': 'Boolean', + 'Promise': 'Promise', + }; + + return Array.from( + java.matchAll(/public abstract void (\w+)\(([^)]*)\);/g), + ([, name, params]) => { + const kotlinParams = params!.split(', ').map((param) => { + const [, type, paramName] = param.match(/^(.+) (\w+)$/)!; + return `${paramName}: ${kotlinType[type!]}`; + }); + return `override fun ${name}(${kotlinParams.join(', ')})`; + } + ); +} + +// The ObjC protocol's method declarations, which the module must define. +function objcSelectors(header: string): string[] { + return Array.from(header.matchAll(/^- \(void\)\w+:[^;]+;/gm), ([method]) => + squash(method!.slice(0, -1)) + ).filter((method) => !method.includes('EventEmitterCallbackWrapper')); +} + describe.each(versions)('authentication codegen with %s', (version) => { - it('generates the promise methods consumed by both native implementations', () => { + let java: string; + let objc: string; + + beforeAll(() => { const directory = mkdtempSync(join(tmpdir(), 'didit-auth-codegen-')); try { expect(generateNativeInterfaces(version, directory)).toBe(true); - verifyNativeSignatures(directory); + java = readFileSync( + join( + directory, + 'java/me/didit/authreactnative/NativeDiditAuthSpec.java' + ), + 'utf8' + ); + objc = readFileSync( + join(directory, 'DiditAuthReactNativeSpec/DiditAuthReactNativeSpec.h'), + 'utf8' + ); } finally { rmSync(directory, { recursive: true, force: true }); } }); -}); -function verifyNativeSignatures(directory: string) { - const java = readFileSync( - join(directory, 'java/me/didit/authreactnative/NativeDiditAuthSpec.java'), - 'utf8' - ); - const objc = readFileSync( - join(directory, 'DiditAuthReactNativeSpec/DiditAuthReactNativeSpec.h'), - 'utf8' - ); + it('passes the application and each call argument to native', () => { + expect(java).toContain('NAME = "DiditAuthReactNative"'); + expect(objc).toContain('NativeDiditAuthSpecJSI'); + methods.forEach((method) => + expect(java).toContain( + `void ${method}(String publishableKey, String redirectUri, ` + ) + ); + expect(java).toContain( + 'void currentSession(String publishableKey, String redirectUri, boolean force, Promise promise)' + ); + expect(java).toContain( + 'void stepUp(String publishableKey, String redirectUri, String action, @Nullable String amount, @Nullable String currency, Promise promise)' + ); + }); - expect(java).toContain('NAME = "DiditAuthReactNative"'); - expect(objc).toContain('NativeDiditAuthSpecJSI'); - methods.forEach((method) => { - expect(java).toContain(`void ${method}(Promise promise)`); - expect(objc).toContain(`(void)${method}:(RCTPromiseResolveBlock)resolve`); + it('is implemented with the same signatures by the Kotlin module', () => { + const overrides = kotlinOverrides(java); + + expect(overrides).toHaveLength(methods.length); + const implemented = squash(kotlinModule); + + expect(overrides.filter((o) => !implemented.includes(o))).toEqual([]); }); -} + + it('is implemented with the same selectors by the Objective-C++ module', () => { + const selectors = objcSelectors(objc); + + expect(selectors).toHaveLength(methods.length); + const implemented = squash(objcModule); + + expect(selectors.filter((s) => !implemented.includes(s))).toEqual([]); + }); +}); diff --git a/packages/auth-react-native/src/__tests__/packaging.test.ts b/packages/auth-react-native/src/__tests__/packaging.test.ts index 549e23e..256900d 100644 --- a/packages/auth-react-native/src/__tests__/packaging.test.ts +++ b/packages/auth-react-native/src/__tests__/packaging.test.ts @@ -33,12 +33,14 @@ describe('independent authentication distribution', () => { 'src/index.ts', 'src/NativeDiditAuth.ts', 'src/errors.ts', + 'src/types.ts', 'android/src/main/AndroidManifest.xml', 'android/build.gradle', 'android/src/main/java/me/didit/authreactnative/DiditAuthModule.kt', 'android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt', 'ios/DiditAuthReactNative.h', 'ios/DiditAuthReactNative.mm', + 'ios/DiditAuthBridge.swift', 'DiditAuthReactNative.podspec', 'react-native.config.js', 'LICENSE', diff --git a/packages/auth-react-native/src/errors.ts b/packages/auth-react-native/src/errors.ts index e061bd8..0eb6732 100644 --- a/packages/auth-react-native/src/errors.ts +++ b/packages/auth-react-native/src/errors.ts @@ -1,9 +1,111 @@ -/** Match failures by code: native rejections do not preserve JS subclasses. */ -export class DiditAuthUnavailableError extends Error { - readonly code = 'ERR_DIDIT_AUTH_UNAVAILABLE'; +/** + * Codes from the Didit API pass through unchanged ("invalid_grant", + * "method_unavailable", "reauthentication_required", ...); native DiditAuth adds: + * - "cancelled": the person closed the sign-in or step-up page + * - "invalid_state": the page returned for a sign-in this app did not start + * - "not_signed_in": the call needs a signed-in person + * - "passkey_cancelled": the person dismissed the passkey prompt + * - "passkey_unavailable": the device cannot run a passkey for this app + * - "browser_unavailable": the page cannot be shown (iOS hosted pages need 17.4) + * - "storage_unavailable": the Keychain or Android Keystore could not keep the tokens + * - "invalid_publishable_key", "invalid_redirect_uri", "network_error", + * "rate_limited" (see `retryAfter`), "server_error" + */ +export type DiditAuthErrorCode = + | 'cancelled' + | 'invalid_state' + | 'not_signed_in' + | 'passkey_cancelled' + | 'passkey_unavailable' + | 'browser_unavailable' + | 'storage_unavailable' + | 'rate_limited' + | 'invalid_publishable_key' + | 'invalid_redirect_uri' + | 'network_error' + | 'server_error' + | 'ERR_DIDIT_AUTH_UNAVAILABLE' + | (string & {}); +export interface DiditAuthErrorOptions { + /** HTTP status of Didit's answer, when there was one. */ + status?: number; + /** Per-field codes, for example { return_to: ["invalid_redirect_uri"] }. */ + fields?: Record; + /** Seconds to wait before trying again, for "rate_limited". */ + retryAfter?: number; +} + +export class DiditAuthError extends Error { + readonly code: DiditAuthErrorCode; + readonly status?: number; + readonly fields?: Record; + readonly retryAfter?: number; + + constructor( + code: DiditAuthErrorCode, + message?: string, + options: DiditAuthErrorOptions = {} + ) { + super(message || code); + this.name = 'DiditAuthError'; + this.code = code; + this.status = options.status; + this.fields = options.fields; + this.retryAfter = options.retryAfter; + } +} + +/** The app was built without the native module, for example in Expo Go. */ +export class DiditAuthUnavailableError extends DiditAuthError { constructor() { - super('DiditAuth is unavailable in this build.'); + super( + 'ERR_DIDIT_AUTH_UNAVAILABLE', + 'DiditAuth is unavailable in this build.' + ); this.name = 'DiditAuthUnavailableError'; } } + +interface NativeRejection { + code?: unknown; + message?: unknown; + userInfo?: { + status?: unknown; + fields?: unknown; + retryAfter?: unknown; + } | null; +} + +const number = (value: unknown) => + typeof value === 'number' ? value : undefined; + +function isFields(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every( + (codes) => + Array.isArray(codes) && codes.every((code) => typeof code === 'string') + ) + ); +} + +/** A native rejection as a DiditAuthError with the same code and message. */ +export function toDiditAuthError(error: unknown): unknown { + const rejection = error as NativeRejection | null; + if (error instanceof DiditAuthError || typeof rejection?.code !== 'string') { + return error; + } + const info = rejection.userInfo ?? {}; + + return new DiditAuthError( + rejection.code, + typeof rejection.message === 'string' ? rejection.message : undefined, + { + status: number(info.status), + fields: isFields(info.fields) ? info.fields : undefined, + retryAfter: number(info.retryAfter), + } + ); +} diff --git a/packages/auth-react-native/src/index.ts b/packages/auth-react-native/src/index.ts index 1df1c4c..768fde9 100644 --- a/packages/auth-react-native/src/index.ts +++ b/packages/auth-react-native/src/index.ts @@ -1,36 +1,85 @@ import NativeDiditAuth, { type Spec } from './NativeDiditAuth'; -import { DiditAuthUnavailableError } from './errors'; +import { DiditAuthUnavailableError, toDiditAuthError } from './errors'; +import type { + DiditAuth, + DiditAuthOptions, + DiditSession, + DiditStepUpOptions, + DiditStepUpResult, +} from './types'; -export { DiditAuthUnavailableError } from './errors'; +export { DiditAuthError, DiditAuthUnavailableError } from './errors'; +export type { DiditAuthErrorCode, DiditAuthErrorOptions } from './errors'; +export type { + DiditAuth, + DiditAuthOptions, + DiditOrganization, + DiditSession, + DiditStepUpOptions, + DiditStepUpResult, +} from './types'; -async function unavailable(method: keyof Spec): Promise { - await NativeDiditAuth?.[method]?.(); - - // Never interpret an absent or mismatched native implementation as success. - throw new DiditAuthUnavailableError(); -} - -/** Reserved for native hosted sign-in. Always rejects in this build. */ -export function signIn(): Promise { - return unavailable('signIn'); +async function call(operation: (native: Spec) => Promise): Promise { + if (!NativeDiditAuth) throw new DiditAuthUnavailableError(); + try { + return await operation(NativeDiditAuth); + } catch (error) { + throw toDiditAuthError(error); + } } -/** Unavailability is a failure, not a signed-out session. */ -export function currentSession(): Promise { - return unavailable('currentSession'); -} +const parse = (json: string): T => JSON.parse(json) as T; -/** Reserved for native token retrieval/refresh. Always rejects in this build. */ -export function getToken(): Promise { - return unavailable('getToken'); -} +/** + * Didit Authentication for your app's end users, on native DiditAuth: the + * hosted sign-in page with PKCE, passkeys, sessions in the Keychain or Android + * Keystore, sign-out and step-ups. Create one per application and reuse it. + */ +export function createDiditAuth({ + publishableKey, + redirectUri, +}: DiditAuthOptions): DiditAuth { + return { + signIn: () => + call(async (native) => + parse(await native.signIn(publishableKey, redirectUri)) + ), + signInWithPasskey: () => + call(async (native) => + parse( + await native.signInWithPasskey(publishableKey, redirectUri) + ) + ), + registerPasskey: () => + call((native) => native.registerPasskey(publishableKey, redirectUri)), + currentSession: (options) => + call(async (native) => { + const session = await native.currentSession( + publishableKey, + redirectUri, + options?.force ?? false + ); -/** Unavailability must not be reported as a successful logout. */ -export function signOut(): Promise { - return unavailable('signOut'); -} + return session === null ? null : parse(session); + }), + getToken: () => + call((native) => native.getToken(publishableKey, redirectUri)), + signOut: () => + call(async (native) => { + await native.signOut(publishableKey, redirectUri); + }), + stepUp: (options: DiditStepUpOptions) => + call(async (native) => { + const payout = options.action === 'payout' ? options : undefined; + const result = await native.stepUp( + publishableKey, + redirectUri, + options.action, + payout?.amount ?? null, + payout?.currency ?? null + ); -/** Reserved for native step-up authentication. Always rejects in this build. */ -export function stepUp(): Promise { - return unavailable('stepUp'); + return parse(result); + }), + }; } diff --git a/packages/auth-react-native/src/types.ts b/packages/auth-react-native/src/types.ts new file mode 100644 index 0000000..49a0909 --- /dev/null +++ b/packages/auth-react-native/src/types.ts @@ -0,0 +1,81 @@ +export interface DiditAuthOptions { + /** The application's publishable key (`pk_live_...` or `pk_test_...`), from the Didit console. */ + publishableKey: string; + /** + * The HTTPS address registered as a redirect URI in the Didit console, and + * an App Link (Android) and associated domain (iOS) of your app. Didit + * accepts no custom URL schemes. + */ + redirectUri: string; +} + +export interface DiditOrganization { + id: string; + name: string; + role: string; +} + +/** The signed-in person, as Didit reports their session. */ +export interface DiditSession { + /** The person's Didit identity id (`sub`). */ + userId: string; + sessionId: string; + applicationId: string; + /** Your id for the person: ties them to their verifications and transactions. */ + vendorData: string | null; + email: string | null; + emailVerified: boolean; + phone: string | null; + /** How the session was created, for example ["password"] or ["passkey"]. */ + methods: string[]; + mfaVerified: boolean; + /** The verified identity level and the claims your application selected, or null. */ + verified: Record | null; + organizations: DiditOrganization[]; + /** When the session ends (ISO 8601). */ + expiresAt: string; +} + +/** A sensitive action your application's step-up rules can apply to. */ +export type DiditStepUpOptions = + /** A payout; `amount` is a decimal string such as "900.00", `currency` an ISO 4217 code. */ + | { action: 'payout'; amount: string; currency?: string } + /** "bank": adding or changing a bank account. "contact": changing the email or phone. */ + | { action: 'bank' | 'contact' }; + +/** + * What came of a step-up. Before acting, your backend reads the outcome itself + * with `GET step-up/{id}/`: an app can be tampered with, Didit's record cannot. + */ +export interface DiditStepUpResult { + /** The step-up's id; null when no rule asked for one. */ + id: string | null; + /** "not_required", "approved", "pending", "review", "declined" or "expired". */ + status: string; + /** The action may go ahead: no step-up was needed, or the person passed it. */ + approved: boolean; + /** The rule that asked for it, for example "payout" or "device". */ + rule: string | null; + /** "liveness", "selfie" or "full_id". */ + method: string | null; +} + +export interface DiditAuth { + /** Signs the person up or in on the application's hosted Didit page. */ + signIn(): Promise; + /** Signs the person in with a passkey of this device. */ + signInWithPasskey(): Promise; + /** Adds a passkey of this device to the signed-in person and returns its id. */ + registerPasskey(): Promise; + /** + * The signed-in person, or null. Answers are reused for 30 seconds; pass + * `force` before a decision that must see a revocation immediately. + */ + currentSession(options?: { force?: boolean }): Promise; + /** A five-minute access token for your backend, renewed when needed; null when signed out. */ + getToken(): Promise; + /** Signs the person out on this device, and ends the session at Didit when it can be reached. */ + signOut(): Promise; + /** Runs the identity check a step-up rule asks for, and returns what Didit recorded. */ + stepUp(options: DiditStepUpOptions): Promise; +} diff --git a/src/__tests__/native-android-build.test.ts b/src/__tests__/native-android-build.test.ts new file mode 100644 index 0000000..da61046 --- /dev/null +++ b/src/__tests__/native-android-build.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from 'child_process'; + +jest.mock('child_process', () => ({ + spawnSync: jest.fn(() => ({ status: 0 })), +})); +jest.mock('fs', () => ({ + mkdirSync: jest.fn(), + rmSync: jest.fn(), + writeFileSync: jest.fn(), +})); +jest.mock('../../native-sdk-source', () => ({ + resolveNativeSdkSource: jest.fn(() => '/native/android'), + androidRepositoryPath: jest.fn(() => '/native-repository'), +})); + +it('publishes every Didit module either package resolves from the source', () => { + require('../../build-native-android'); + + // The source repository is the only one Gradle reads for me.didit, so the + // authentication package's didit-auth and didit-signals must be in it too. + const [command, args] = (spawnSync as jest.Mock).mock.calls[0]; + expect(command).toBe('/native/android/gradlew'); + expect(args).toEqual( + expect.arrayContaining( + ['sdk', 'sdk-core', 'sdk-signals', 'sdk-auth'].map( + (name) => `:${name}:publishReleasePublicationToLocalRepository` + ) + ) + ); + expect(args).toContain('-PsdkRepoDir=/native-repository'); +}); diff --git a/src/__tests__/native-sdk-pins.test.ts b/src/__tests__/native-sdk-pins.test.ts index 36d0f43..c290a58 100644 --- a/src/__tests__/native-sdk-pins.test.ts +++ b/src/__tests__/native-sdk-pins.test.ts @@ -168,7 +168,7 @@ describe('swift package manager linkage', () => { }); describe('hand-maintained pins stay in lockstep', () => { - it.each(['README.md'])( + it.each(['README.md', 'packages/auth-react-native/README.md'])( '%s references only the declared iOS version', (relativePath) => { const refs = podspecUrlRefs(read(relativePath)); @@ -194,3 +194,44 @@ describe('hand-maintained pins stay in lockstep', () => { ); }); }); + +describe('authentication package pins', () => { + const authPackage = JSON.parse( + read('packages/auth-react-native/package.json') + ); + + it('pins the native release the verification package pins', () => { + // One app can link both packages: Gradle, CocoaPods and SwiftPM each need + // a single Didit native version. + expect(authPackage.diditNativeSdkVersions).toEqual({ + ios: declaredIosVersion, + android: declaredAndroidVersion, + }); + }); + + it('derives the DiditSDK/Auth dependency from its package.json', () => { + const podspec = read( + 'packages/auth-react-native/DiditAuthReactNative.podspec' + ); + + expect(podspec).toContain('package.dig("diditNativeSdkVersions", "ios")'); + expect(podspec).toMatch( + /s\.dependency\s+"DiditSDK\/Auth"\s*,\s*didit_sdk_ios_version/ + ); + expect(podspec).toMatch( + /requirement:\s*\{\s*kind:\s*"exactVersion",\s*version:\s*didit_sdk_ios_version\s*\}/ + ); + expect(podspec).toContain('products: ["DiditAuth"]'); + expect(podspec).not.toMatch(/["']\d+\.\d+\.\d+["']/); + }); + + it('depends on me.didit:didit-auth at the declared Android version', () => { + const gradle = read('packages/auth-react-native/android/build.gradle'); + + expect(gradle).toContain( + 'parse(file("../package.json")).diditNativeSdkVersions' + ); + expect(gradle).toContain('me.didit:didit-auth:${nativePins.android}'); + expect(gradle).toContain('content { includeGroup "me.didit" }'); + }); +}); diff --git a/src/__tests__/native-source.test.ts b/src/__tests__/native-source.test.ts index dbfb782..5b4d9d5 100644 --- a/src/__tests__/native-source.test.ts +++ b/src/__tests__/native-source.test.ts @@ -22,7 +22,7 @@ afterEach(() => { }); it('requires an immutable revision for the integration branch', () => { - expect(diditNativeSdkVersions.source.branch).toBe('integration/console-v2'); + expect(diditNativeSdkVersions.source.branch).toBe('main'); expect(diditNativeSdkVersions.source.revision).toMatch(/^[a-f0-9]{40}$/); });