diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3adbe1001..6fa5ca741 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,3 +84,19 @@ jobs: VERSION=$(node -p "require('./packages/core/package.json').version") git tag "v$VERSION" git push origin "v$VERSION" + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -p "require('./packages/core/package.json').version") + NOTES=$(node -e " + const fs = require('fs'); + const log = fs.readFileSync('packages/core/CHANGELOG.md', 'utf8'); + const match = log.match(/## ${VERSION}\n([\s\S]*?)(?=\n## |\$)/); + process.stdout.write(match ? match[1].trim() : 'See CHANGELOG.md for details.'); + ") + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes "$NOTES" \ + --verify-tag diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 9bf917b23..86d7ed3f4 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78dc58915..9ff4a4a6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,9 +57,7 @@ To run the sample app on iOS: yarn sample:run:ios ``` -Make sure your code passes TypeScript and ESLint checks. - -TODO: Add project-wide `yarn typecheck` and `yarn lint` scripts, then replace this section with: +Make sure your code passes TypeScript and ESLint checks: ```sh yarn typecheck @@ -68,7 +66,9 @@ yarn lint To fix formatting/lint errors: -TODO: Add and document `yarn lint --fix` once lint setup is available. +```sh +yarn lint --fix +``` Remember to add tests for your change when possible. Run relevant unit tests by: @@ -84,9 +84,9 @@ yarn test:storage The root `package.json` contains scripts for common tasks: - `yarn install`: install workspace dependencies. -- TODO: Add `yarn typecheck` script for TypeScript checks. -- TODO: Add `yarn lint` script for ESLint checks. -- TODO: Add `yarn lint --fix` support as part of lint setup. +- `yarn typecheck`: run TypeScript checks across all packages. +- `yarn lint`: run ESLint across all packages. +- `yarn lint --fix`: auto-fix lint and formatting errors. - `yarn packages:build`: build all workspaces in topological order. - `yarn sample:run:android`: run the sample app on Android. - `yarn sample:run:ios`: run the sample app on iOS. @@ -146,6 +146,7 @@ The CLI will prompt you to select a bump type (`patch`, `minor`, or `major`) and The changeset description becomes the entry in each package's `CHANGELOG.md` on the next release, linked to your PR and commit SHA. CI will fail if no changeset file is present. **Bump type guidance:** + - `patch` — bug fixes, non-breaking internal changes - `minor` — new backwards-compatible features - `major` — breaking API changes @@ -172,6 +173,7 @@ To release, trigger the [Release workflow](../../actions/workflows/release.yml) **Why `workflow_dispatch` instead of the Changesets bot?** We deliberately chose manual `workflow_dispatch` instead of the Changesets GitHub bot because: + - This is an early-stage SDK where releases should be deliberate, not automatic - It avoids a permanently-open "Release PR" that creates pressure to ship before the team is ready - One explicit button-push makes the release boundary clear diff --git a/PingSampleApp/android/settings.gradle b/PingSampleApp/android/settings.gradle index 54ec79814..bc601cb06 100644 --- a/PingSampleApp/android/settings.gradle +++ b/PingSampleApp/android/settings.gradle @@ -22,7 +22,6 @@ dependencyResolutionManagement { include ':app' includeBuild('../../node_modules/@react-native/gradle-plugin') -// TODO REVISIT include(":ping-identity_rn-core") project(":ping-identity_rn-core").projectDir = file("../../packages/core/android") include(":ping-identity_rn-oidc") diff --git a/PingSampleApp/ios/PingSampleApp.xcodeproj/project.pbxproj b/PingSampleApp/ios/PingSampleApp.xcodeproj/project.pbxproj index a1c3f4072..4aa9d9d65 100644 --- a/PingSampleApp/ios/PingSampleApp.xcodeproj/project.pbxproj +++ b/PingSampleApp/ios/PingSampleApp.xcodeproj/project.pbxproj @@ -274,14 +274,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-frameworks.sh\"\n"; @@ -317,14 +313,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PingSampleApp/Pods-PingSampleApp-resources.sh\"\n"; @@ -568,7 +560,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -648,7 +643,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index 9f4615377..e86d4a9f6 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -2465,7 +2465,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingBinding (0.1.0): + - RNPingBinding (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2496,7 +2496,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBrowser (0.1.0): + - RNPingBrowser (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2528,7 +2528,7 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingCore (0.1.0): + - RNPingCore (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2557,7 +2557,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingDeviceClient (0.1.0): + - RNPingDeviceClient (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2588,7 +2588,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId (0.1.0): + - RNPingDeviceId (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2619,7 +2619,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile (0.1.0): + - RNPingDeviceProfile (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2650,7 +2650,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp (0.1.0): + - RNPingExternalIdp (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2681,7 +2681,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido (0.1.0): + - RNPingFido (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2712,7 +2712,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney (0.1.0): + - RNPingJourney (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2750,7 +2750,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger (0.1.0): + - RNPingLogger (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2781,7 +2781,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath (0.1.0): + - RNPingOath (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2812,7 +2812,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc (0.1.0): + - RNPingOidc (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2847,7 +2847,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush (0.1.0): + - RNPingPush (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -2880,7 +2880,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage (0.1.0): + - RNPingStorage (1.0.0-beta.3): - boost - DoubleConversion - fast_float @@ -3589,20 +3589,20 @@ SPEC CHECKSUMS: ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBinding: 64be383c23d1fb0c0899eb650ffb557355111b36 - RNPingBrowser: 68c92341be9aed38dd5a11ecee37b96bf96a6332 - RNPingCore: f41b28de7c94d2e2af7fae663531d0d0341d2775 - RNPingDeviceClient: 6b89f63032ed087e0af22b73312e73a5800cb0ce - RNPingDeviceId: e0d02655613f8e6151496538ce46040f44c414cb - RNPingDeviceProfile: d41184083542e19e85e20ec9938c98f6511931b5 - RNPingExternalIdp: 664978f9d40fdcd80af22fb2da1e2b0ec0059371 - RNPingFido: 22c9c9f44842fb4e42b8c9c193583314ddd8e61f - RNPingJourney: b85d5fef5498aedda4019108230a39a6135935cb - RNPingLogger: 85893024c3d861ac0e01d35f91860e5bdd3cd904 - RNPingOath: f800f54371fa0ee2a849d29fcefdb54eb01eac90 - RNPingOidc: d17f951f9ca06652fd97caaaf7aaced99e4ad9e8 - RNPingPush: 214ca695aa3779c722fb12ef64cd18fba6fd415b - RNPingStorage: e4f9709832992edd2c49e7885d300aca7cacd68e + RNPingBinding: 10541621edbb8b6885f069ee6316843498d6cc93 + RNPingBrowser: 63741144ed5088a03a765a4d7c42be27f18fdd53 + RNPingCore: fe4e99005afb728904b634a2b29bd9f7df0ed7a2 + RNPingDeviceClient: b62cdc274eff19c09fce49b7d7b7e1166a81a044 + RNPingDeviceId: f37f85e0807a5b0e284631c83e28e0dfd4a78712 + RNPingDeviceProfile: 90e4220f1ec456f8dfaeb9a4214214100302292c + RNPingExternalIdp: 7954d281fa3ea15681b5b1c6ce6f8813559e9b48 + RNPingFido: 350763404cdd05b9fe3dbf73eba926e041d4bf63 + RNPingJourney: f3823780322f741fceed6eb6c3c20013d85a44a2 + RNPingLogger: 3bb60c3bc67fe1078a4a4535f98129da4845eebf + RNPingOath: f67ef2fedb4ad9727349be6eac5da988196871eb + RNPingOidc: 889badee38ae3a2a592edb723d2047f3eea603c7 + RNPingPush: 1ac8fd7f91b9489ec088e502bb8a7b96fabacc80 + RNPingStorage: 61024de883275173ac09f95d985f1bb9bef62670 RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5 RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80 RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5 diff --git a/PingSampleApp/src/hooks/useDevices.ts b/PingSampleApp/src/hooks/useDevices.ts index 0b7972074..890cd373f 100644 --- a/PingSampleApp/src/hooks/useDevices.ts +++ b/PingSampleApp/src/hooks/useDevices.ts @@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useJourney } from '@ping-identity/rn-journey'; import { createDeviceClient, + type DeviceByKind, type DeviceClient, type DeviceKind, type DeviceOf, @@ -31,7 +32,7 @@ type Status = 'idle' | 'loading' | 'ready' | 'error'; */ interface UseDevicesState { status: Status; - devices: DeviceOf[]; + devices: DeviceOf[]; /** Human-readable error surfaced from the native bridge, or `null` when healthy. */ error: string | null; } @@ -50,12 +51,15 @@ interface UseDevicesActions { * Renames a device on the server and re-fetches the list on success. * Re-fetches on failure too so local state matches the server. */ - rename: (device: DeviceOf, newName: string) => Promise; + rename: ( + device: DeviceOf, + newName: string, + ) => Promise; /** * Deletes a device from the server and re-fetches the list. * Re-fetches on failure too so local state matches the server. */ - remove: (device: DeviceOf) => Promise; + remove: (device: DeviceOf) => Promise; } /** @@ -175,11 +179,15 @@ export function useDevices( setState(s => ({ ...s, status: 'loading', error: null })); try { const client = await ensureClient(); - const items = await client[deviceType].get(); + const items = await ( + client[deviceType as keyof typeof client] as { + get: () => Promise; + } + ).get(); if (mountedRef.current) { setState({ status: 'ready', - devices: items as DeviceOf[], + devices: items as DeviceOf[], error: null, }); } @@ -217,12 +225,12 @@ export function useDevices( * stays in sync with the server. */ const rename = useCallback( - async (device: DeviceOf, newName: string) => { + async (device: DeviceOf, newName: string) => { const client = await ensureClient(); const updated = { ...device, deviceName: newName }; try { await ( - client[deviceType] as { + client[deviceType as keyof typeof client] as { update: (d: typeof updated) => Promise; } ).update(updated); @@ -240,11 +248,11 @@ export function useDevices( * success and failure paths (the catch is in the consumer). */ const remove = useCallback( - async (device: DeviceOf) => { + async (device: DeviceOf) => { const client = await ensureClient(); try { await ( - client[deviceType] as { + client[deviceType as keyof typeof client] as { delete: (d: typeof device) => Promise; } ).delete(device); diff --git a/PingSampleApp/ui/DevicesScreen.tsx b/PingSampleApp/ui/DevicesScreen.tsx index fc714c7a3..887f311c8 100644 --- a/PingSampleApp/ui/DevicesScreen.tsx +++ b/PingSampleApp/ui/DevicesScreen.tsx @@ -8,7 +8,11 @@ import React, { useState } from 'react'; import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import type { DeviceKind, DeviceOf } from '@ping-identity/rn-device-client'; +import type { + DeviceByKind, + DeviceKind, + DeviceOf, +} from '@ping-identity/rn-device-client'; import { formatError } from './utils/formatError'; import { useDevices } from '../src/hooks/useDevices'; import { commonStyles } from '../src/styles/common'; @@ -62,7 +66,7 @@ export default function DevicesScreen({ null, ); - const handleDelete = (device: DeviceOf) => { + const handleDelete = (device: DeviceOf) => { actions.remove(device).catch((error: unknown) => { console.log( '[devices] delete failed — raw error:', @@ -72,7 +76,7 @@ export default function DevicesScreen({ }); }; - const handleOpenEdit = (device: DeviceOf) => { + const handleOpenEdit = (device: DeviceOf) => { setRenameTarget({ kind: selectedType, device, draft: device.deviceName }); }; diff --git a/PingSampleApp/ui/devices/components/molecules/DeviceRow.tsx b/PingSampleApp/ui/devices/components/molecules/DeviceRow.tsx index 3a1df9c6c..415f961c1 100644 --- a/PingSampleApp/ui/devices/components/molecules/DeviceRow.tsx +++ b/PingSampleApp/ui/devices/components/molecules/DeviceRow.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; -import type { DeviceKind, DeviceOf } from '@ping-identity/rn-device-client'; +import type { DeviceByKind, DeviceOf } from '@ping-identity/rn-device-client'; import { commonStyles } from '../../../../src/styles/common'; import { colors } from '../../../../src/styles/colors'; @@ -19,21 +19,21 @@ type DeviceRowProps = { /** * Device rendered by this row. */ - device: DeviceOf; + device: DeviceOf; /** * Called when edit icon is pressed. * * @param device - Selected device. * @returns Void. */ - onEdit: (device: DeviceOf) => void; + onEdit: (device: DeviceOf) => void; /** * Called when delete icon is pressed. * * @param device - Selected device. * @returns Void. */ - onDelete: (device: DeviceOf) => void; + onDelete: (device: DeviceOf) => void; }; /** diff --git a/PingSampleApp/ui/devices/components/organisms/DeviceListCard.tsx b/PingSampleApp/ui/devices/components/organisms/DeviceListCard.tsx index ad50d68c0..e8605b64a 100644 --- a/PingSampleApp/ui/devices/components/organisms/DeviceListCard.tsx +++ b/PingSampleApp/ui/devices/components/organisms/DeviceListCard.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { ActivityIndicator, Pressable, Text, View } from 'react-native'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; -import type { DeviceKind, DeviceOf } from '@ping-identity/rn-device-client'; +import type { + DeviceByKind, + DeviceKind, + DeviceOf, +} from '@ping-identity/rn-device-client'; import { commonStyles } from '../../../../src/styles/common'; import { colors } from '../../../../src/styles/colors'; import DeviceListSeparator from '../atoms/DeviceListSeparator'; @@ -30,7 +34,7 @@ type DeviceListCardProps = { /** * Devices to render. */ - devices: DeviceOf[]; + devices: DeviceOf[]; /** * Current list status. */ @@ -49,14 +53,14 @@ type DeviceListCardProps = { * @param device - Selected device. * @returns Void. */ - onEdit: (device: DeviceOf) => void; + onEdit: (device: DeviceOf) => void; /** * Trigger delete flow for a device. * * @param device - Selected device. * @returns Void. */ - onDelete: (device: DeviceOf) => void; + onDelete: (device: DeviceOf) => void; }; /** diff --git a/PingSampleApp/ui/devices/types.ts b/PingSampleApp/ui/devices/types.ts index 60453f529..8d643c261 100644 --- a/PingSampleApp/ui/devices/types.ts +++ b/PingSampleApp/ui/devices/types.ts @@ -5,7 +5,11 @@ * of the MIT license. See the LICENSE file for details. */ -import type { DeviceKind, DeviceOf } from '@ping-identity/rn-device-client'; +import type { + DeviceByKind, + DeviceKind, + DeviceOf, +} from '@ping-identity/rn-device-client'; /** * Backing session source used by the devices screen. @@ -51,7 +55,7 @@ export interface DeviceRenameTarget { /** * Device currently being edited. */ - device: DeviceOf; + device: DeviceOf; /** * Current user-entered name draft. */ diff --git a/PingSampleApp/ui/journey/components/organisms/JourneyContinuePanel.tsx b/PingSampleApp/ui/journey/components/organisms/JourneyContinuePanel.tsx index cc378703a..3e259069e 100644 --- a/PingSampleApp/ui/journey/components/organisms/JourneyContinuePanel.tsx +++ b/PingSampleApp/ui/journey/components/organisms/JourneyContinuePanel.tsx @@ -95,28 +95,27 @@ export default function JourneyContinuePanel( () => new Set(fields.map(field => field.ref.type)), [fields], ); - // These flags drive integration UX: - // - DeviceProfile/Suspended/Polling callbacks are handled by panel-level effects. - // - "manual submit" means at least one callback requires user-provided values. - const hasDeviceProfileCallback = callbackTypes.has( - callbackType.DeviceProfileCallback, - ); - const hasSelectIdpCallback = fields.some( - field => field.ref.type === nativeExtensionCallbackType.SelectIdpCallback, - ); + + // Callbacks silently handled by registered integrations (auto-forwarded or + // panel-level effects) — not surfaced as blocking issues in the UI. const isAutoHandledIntegrationCallback = useCallback( (type: JourneyCallbackType): boolean => type === callbackType.DeviceProfileCallback || type === nativeExtensionCallbackType.FidoRegistrationCallback || type === nativeExtensionCallbackType.FidoAuthenticationCallback || type === nativeExtensionCallbackType.IdpCallback || - // SelectIdpCallback (native lowercase-p form) is handled by the external-idp integration - // in the panel controller — provider selection renders inline and submit is managed there. type === nativeExtensionCallbackType.SelectIdpCallback || type === 'DeviceBindingCallback' || type === 'DeviceSigningVerifierCallback', [], ); + + const hasDeviceProfileCallback = callbackTypes.has( + callbackType.DeviceProfileCallback, + ); + const hasSelectIdpCallback = fields.some( + field => field.ref.type === nativeExtensionCallbackType.SelectIdpCallback, + ); const hasSuspendedCallback = callbackTypes.has( callbackType.SuspendedTextOutputCallback, ); @@ -129,43 +128,26 @@ export default function JourneyContinuePanel( !(hasPollingWaitCallback && field.ref.type === 'ConfirmationCallback'), ); - const hasBlockingIntegration = fields.some( - field => - (field.executionMode === 'integration_required' && - !isAutoHandledIntegrationCallback(field.ref.type)) || - (field.executionMode === 'auto_capable' && - !isAutoHandledIntegrationCallback(field.ref.type)), - ); - const blockingIntegrationCallbackTypes = useMemo( + // Derive blocking state and display info from form.issues — single source of truth. + const blockingIntegrationCallbackTypes = useMemo( () => Array.from( new Set( - fields + form.issues .filter( - field => - (field.executionMode === 'integration_required' && - !isAutoHandledIntegrationCallback(field.ref.type)) || - (field.executionMode === 'auto_capable' && - !isAutoHandledIntegrationCallback(field.ref.type)), + issue => + issue.code === 'INTEGRATION_REQUIRED' && + issue.callbackType != null && + !isAutoHandledIntegrationCallback(issue.callbackType), ) - .map(field => field.ref.type), - ), - ), - [fields, isAutoHandledIntegrationCallback], - ); - const hasUnsupportedCallbacks = meta.hasUnsupported; - const unsupportedCallbackTypes = useMemo( - () => - Array.from( - new Set( - fields - .filter(field => field.executionMode === 'unsupported') - .map(field => field.ref.type), + .map(issue => issue.callbackType as JourneyCallbackType), ), ), - [fields], + [form.issues, isAutoHandledIntegrationCallback], ); - const unsupportedIssueCallbackTypes = useMemo( + const hasBlockingIntegration = blockingIntegrationCallbackTypes.length > 0; + + const unsupportedCallbackTypes = useMemo( () => Array.from( new Set( @@ -180,26 +162,8 @@ export default function JourneyContinuePanel( ), [form.issues], ); - const integrationIssueCallbackTypes = useMemo( - () => - Array.from( - new Set( - form.issues - .filter( - issue => - issue.code === 'INTEGRATION_REQUIRED' && - !!issue.callbackType && - !isAutoHandledIntegrationCallback(issue.callbackType), - ) - .map(issue => issue.callbackType) - .filter( - (value): value is JourneyCallbackType => - typeof value === 'string' && value.length > 0, - ), - ), - ), - [form.issues, isAutoHandledIntegrationCallback], - ); + const hasUnsupportedCallbacks = meta.hasUnsupported; + const blockingIssueMessages = useMemo( () => form.issues @@ -207,18 +171,17 @@ export default function JourneyContinuePanel( issue => issue.code === 'UNSUPPORTED_CALLBACK' || (issue.code === 'INTEGRATION_REQUIRED' && - !!issue.callbackType && + issue.callbackType != null && !isAutoHandledIntegrationCallback(issue.callbackType)), ) .map(issue => issue.message), [form.issues, isAutoHandledIntegrationCallback], ); - const hasUnacceptedRequiredAgreements = meta.hasRequiredConsentMissing; - // True when every field is silently handled by a registered integration - // (FIDO authentication, device signing verifier) — the auto-forwarder - // owns submission, so a Continue button would be redundant. - // FIDO registration and device binding render a device-name text field - // the user can edit, so they always keep the Continue button. + + // True when every field is silently handled by a registered integration — + // the auto-forwarder owns submission, so a Continue button would be redundant. + // FIDO registration and device binding render a device-name field so they + // always keep the Continue button. const isAutoForwardedByIntegration = fields.length > 0 && fields.every( @@ -228,6 +191,7 @@ export default function JourneyContinuePanel( field.ref.type !== 'FidoRegistrationCallback' && field.ref.type !== 'DeviceBindingCallback', ); + const canAutoAdvanceWithContinueButton = !hasManualSubmit && !hasBlockingIntegration && @@ -237,13 +201,14 @@ export default function JourneyContinuePanel( !hasSuspendedCallback && !hasPollingWaitCallback && !isAutoForwardedByIntegration; + const shouldShowContinueButton = hasManualSubmit || canAutoAdvanceWithContinueButton; - const submitDisabled = - loading || - hasUnacceptedRequiredAgreements || - hasBlockingIntegration || - hasUnsupportedCallbacks; + + // form.canSubmit is the single source of truth — true when all fields are + // filled and no unhandled integration or unsupported callbacks remain. + const submitDisabled = loading || !form.canSubmit; + const pollingWaitSeconds = Math.max( 1, Math.ceil((pollingWaitMs ?? DEFAULT_AUTO_POLLING_WAIT_MS) / 1000), @@ -271,12 +236,7 @@ export default function JourneyContinuePanel( Integration-required callbacks:{' '} - {[ - ...new Set([ - ...blockingIntegrationCallbackTypes, - ...integrationIssueCallbackTypes, - ]), - ].join(', ') || 'Unknown'} + {blockingIntegrationCallbackTypes.join(', ') || 'Unknown'} ) : null} @@ -295,12 +255,7 @@ export default function JourneyContinuePanel( Unsupported callbacks:{' '} - {[ - ...new Set([ - ...unsupportedCallbackTypes, - ...unsupportedIssueCallbackTypes, - ]), - ].join(', ') || 'Unknown'} + {unsupportedCallbackTypes.join(', ') || 'Unknown'} ) : null} diff --git a/PingSampleApp/ui/journey/hooks/useJourneyClientPanelController.ts b/PingSampleApp/ui/journey/hooks/useJourneyClientPanelController.ts index e70de15fc..d9666c970 100644 --- a/PingSampleApp/ui/journey/hooks/useJourneyClientPanelController.ts +++ b/PingSampleApp/ui/journey/hooks/useJourneyClientPanelController.ts @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useJourney, useJourneyForm, + type JourneyCallbackType, type JourneyClient, type JourneyError, type JourneyFormResult, @@ -47,6 +48,15 @@ const SELECT_IDP_CALLBACK_TYPE: string = 'SelectIdpCallback'; const REDIRECT_CALLBACK_TYPE: string = 'RedirectCallback'; const IDP_CALLBACK_TYPES = new Set(['IdPCallback', 'IdpCallback']); +// Callback types handled by the fido and binding integrations — passed to +// useJourneyForm so canSubmit is not blocked on integration_required fields. +const INTEGRATION_HANDLED_CALLBACK_TYPES = new Set([ + 'FidoRegistrationCallback', + 'FidoAuthenticationCallback', + 'DeviceBindingCallback', + 'DeviceSigningVerifierCallback', +]); + /** * Options for the Journey client panel controller hook. */ @@ -405,7 +415,9 @@ export function useJourneyClientPanelController( const [node, actions] = useJourney(); const { start, next, resume, user, logoutUser, dispose, loading, error } = actions; - const form = useJourneyForm(node); + const form = useJourneyForm(node, { + handledCallbackTypes: INTEGRATION_HANDLED_CALLBACK_TYPES, + }); const formRef = useRef(form); useEffect(() => { formRef.current = form; @@ -671,10 +683,13 @@ export function useJourneyClientPanelController( return true; } - const resumedNode = await resume(browserResult.url); + if (browserResult.type !== 'success' || !('url' in browserResult)) + return true; + + const resumedNode = await resume(browserResult.url as string); hasCompletedExternalIdpRedirectRef.current = true; appendDebug('Journey external IdP browser resume completed', { - url: browserResult.url, + url: browserResult.url as string, callbackTypes: resumedNode.type === 'ContinueNode' ? resumedNode.callbacks?.map(callback => callback.type) diff --git a/PingSampleApp/ui/oath/components/organisms/OathAccountDetailModal.tsx b/PingSampleApp/ui/oath/components/organisms/OathAccountDetailModal.tsx index 7b64d3033..8f487b140 100644 --- a/PingSampleApp/ui/oath/components/organisms/OathAccountDetailModal.tsx +++ b/PingSampleApp/ui/oath/components/organisms/OathAccountDetailModal.tsx @@ -89,11 +89,11 @@ function InfoRow({ */ export default function OathAccountDetailModal( props: OathAccountDetailModalProps, -): React.ReactElement { +): React.ReactElement | null { const { visible, credential, codeInfo, onDismiss, onDelete, onRefreshHotp } = props; - if (!credential) return <>; + if (!credential) return null; const isLocked = credential.isLocked ?? false; const isTotp = credential.type === 'TOTP'; diff --git a/PingTestRunner/scenarios/UseOidcScenario.tsx b/PingTestRunner/scenarios/UseOidcScenario.tsx index 3f482052b..35be13b0c 100644 --- a/PingTestRunner/scenarios/UseOidcScenario.tsx +++ b/PingTestRunner/scenarios/UseOidcScenario.tsx @@ -75,6 +75,7 @@ const mockWebClient: OidcWebClient = { id: 'hook-test-client', authorize: async () => ({ type: 'success' as const }), user: async () => mockUser, + dispose: async () => {}, }; const errorMockWebClient: OidcWebClient = { @@ -83,6 +84,7 @@ const errorMockWebClient: OidcWebClient = { throw new Error('Forced test error'); }, user: async () => null, + dispose: async () => {}, }; // ─── component ─────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 7c5c0452a..3f743232e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The repository packages use: - `compileSdkVersion`: `36` - `targetSdkVersion`: `36` - `minSdkVersion`: `29` -- `kotlinVersion`: `2.2.10` (package default) +- `kotlinVersion`: `2.2.10` ### iOS deployment target diff --git a/packages/binding/LICENSE b/packages/binding/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/binding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/binding/README.md b/packages/binding/README.md index bf7ce747b..1e0f8f56f 100644 --- a/packages/binding/README.md +++ b/packages/binding/README.md @@ -15,7 +15,7 @@ This package provides native-backed device binding and signing-verifier capabili - [Install](#install) - [Journey integration](#journey-integration) -- [Optional `useJourneyForm` integration](#optional-usejourneyform-integration) +- [`useJourneyForm` integration](#usejourneyform-integration) - [Custom UI collectors](#custom-ui-collectors) - [User key storage](#user-key-storage) - [Managing stored keys](#managing-stored-keys) @@ -81,33 +81,39 @@ const binding = createBindingClient({ }); ``` -## Optional `useJourneyForm` integration +## `useJourneyForm` integration -When using `useJourneyForm`, binding fields are marked with `executionMode: 'integration_required'`. -This indicates app code must run binding integration explicitly. +When using `useJourneyForm`, pass `handledCallbackTypes` so binding fields are excluded from +blocking submit issues. Run each integration, then submit when `form.canSubmit` is true. ```ts -import { useJourneyForm } from '@ping-identity/rn-journey'; +import { useJourney, useJourneyForm } from '@ping-identity/rn-journey'; import { createBindingClient } from '@ping-identity/rn-binding'; - -const form = useJourneyForm(node); +import { nativeExtensionCallbackType } from '@ping-identity/rn-types'; + +const [node, actions] = useJourney(client); +const form = useJourneyForm(node, { + handledCallbackTypes: new Set([ + nativeExtensionCallbackType.DeviceBindingCallback, + nativeExtensionCallbackType.DeviceSigningVerifierCallback, + ]), +}); const binding = createBindingClient(); for (const field of form.fields) { - if (field.ref.type === 'DeviceBindingCallback') { - await binding.bindForJourney(journey, { - index: field.ref.typeIndex, - }); + if (field.ref.type === nativeExtensionCallbackType.DeviceBindingCallback) { + await binding.bindForJourney(journey, { index: field.ref.typeIndex }); } - - if (field.ref.type === 'DeviceSigningVerifierCallback') { - await binding.signForJourney(journey, { - index: field.ref.typeIndex, - }); + if ( + field.ref.type === nativeExtensionCallbackType.DeviceSigningVerifierCallback + ) { + await binding.signForJourney(journey, { index: field.ref.typeIndex }); } } -await journey.next({}); +if (form.canSubmit) { + await actions.next(form.input); +} ``` ## Custom UI collectors @@ -492,4 +498,4 @@ The authenticator type is configured on the AM Journey node, not from JavaScript ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/binding/TODOS.md b/packages/binding/TODOS.md deleted file mode 100644 index 7ba2de057..000000000 --- a/packages/binding/TODOS.md +++ /dev/null @@ -1,64 +0,0 @@ - - -# Open TODOs - -## Concurrency (SDKS-concurrency) - -Apply the static-methods + actor pattern used in the `binding` package to the remaining packages. - -**Packages with mutable state — requires actor extraction:** - -| Package | Mutable state | -| --------------- | ------------------------------------------------------------- | -| `logger` | `DynamicLogger.level`, `JsLoggerIdStore.map` (NSLock-guarded) | -| `device-client` | `registry: [String: DeviceClient]` (NSLock-guarded) | -| `journey` | `JourneyStateStore.nodeMap/continueNodeMap`, `Ref.value` | - -**Packages with no mutable state — static methods + `.mm` update only:** - -`storage`, `oidc`, `device-profile`, `fido`, `browser`, `device-id` - ---- - -## Semver safety (SDKS-semver) - -Before 1.0, all exported string union types that consumers branch on must be widened so -that adding a new member is a non-breaking minor release rather than a major. - -**Fix for plain string unions** — append `| (string & {})`: - -```ts -export type FooErrorCode = 'FOO_KNOWN_CODE' | (string & {}); // allows unknown future codes, preserves autocomplete -``` - -**Fix for discriminated object unions** — widen the `type` discriminant field: - -```ts -export type FooResult = - | { type: 'success'; value: string } - | { type: 'cancel' } - | { type: string & {}; [key: string]: unknown }; // absorbs future variants -``` - -Consumers must always have a `default` branch in any switch over these types. - -### Affected types - -| Type | Package | File | -| ------------------------ | ---------------- | ---------------------------------- | -| `BindingErrorCode` | `binding` | `src/types/binding.types.ts` | -| `JourneyErrorCode` | `journey` | `src/types/error.types.ts` | -| `JourneyExecutionMode` | `journey` | `src/types/form.types.ts` | -| `JourneySubmitIssueCode` | `journey` | `src/types/form.types.ts` | -| `FidoErrorCode` | `fido` | `src/types/fido.types.ts` | -| `OidcErrorCode` | `oidc` | `src/types/oidc.types.ts` | -| `OidcAuthorizeResult` | `oidc` | `src/types/oidc.types.ts` | -| `DeviceClientErrorCode` | `device-client` | `src/types/error.types.ts` | -| `DeviceKind` | `device-client` | `src/types/device.types.ts` | -| `DeviceProfileErrorCode` | `device-profile` | `src/types/deviceProfile.types.ts` | -| `BrowserResult` | `browser` | `src/types/browser.types.ts` | diff --git a/packages/binding/android/build.gradle b/packages/binding/android/build.gradle index 42b216758..664f00594 100644 --- a/packages/binding/android/build.gradle +++ b/packages/binding/android/build.gradle @@ -79,14 +79,14 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:journey:2.0.0") - implementation("com.pingidentity.sdks:binding:2.0.0") - implementation("com.pingidentity.sdks:binding-ui:2.0.0") + implementation("com.pingidentity.sdks:journey:2.0.1") + implementation("com.pingidentity.sdks:binding:2.0.1") + implementation("com.pingidentity.sdks:binding-ui:2.0.1") implementation("androidx.biometric:biometric-ktx:1.4.0-alpha02") // Required by Application PIN authenticator (X.509 / PKIX support). implementation("org.bouncycastle:bcpkix-jdk18on:1.82") - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" diff --git a/packages/binding/ios/RNPingBindingImpl.swift b/packages/binding/ios/RNPingBindingImpl.swift index 774e8c0d2..aea17ac6b 100644 --- a/packages/binding/ios/RNPingBindingImpl.swift +++ b/packages/binding/ios/RNPingBindingImpl.swift @@ -11,8 +11,10 @@ import React /// Swift entry point for the Binding native module. /// /// Delegates all operations to `RNPingBindingCommon`. -/// TODO(SDKS-concurrency): Apply the same static-methods + actor pattern to the remaining +/// TODO(SDKS-concurrency)(TODO-SEPARATE-TICKET): Apply the same static-methods + actor pattern to the remaining /// packages. See packages/binding/TODOS.md for the full list. +/// Note: The remaining packages guard mutable state with NSLock — correct and crash-safe at runtime. +/// This is a Swift 6 strict-concurrency compliance improvement, not a safety fix. Safe to defer past 1.0. @objcMembers public class RNPingBindingImpl: NSObject { diff --git a/packages/binding/src/NativeRNPingBinding.ts b/packages/binding/src/NativeRNPingBinding.ts index 0db7c4b65..67be9e283 100644 --- a/packages/binding/src/NativeRNPingBinding.ts +++ b/packages/binding/src/NativeRNPingBinding.ts @@ -120,31 +120,35 @@ export type NativeBindingConfig = { userKeyStorageId?: string; }; -// TODO: Cache the resolved module instance — probing on every call is unnecessary since -// the native module does not change at runtime. Apply the same pattern across all modules. -// TODO: Add no-duplicate-imports ESLint rule to eslint.config.mjs once all split imports across packages are consolidated. /** * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. * * @returns The resolved native binding module. * @throws Error when the native module is unavailable. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingBinding'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingBindingClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-binding] Native module RNPingBinding not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/binding/src/types/binding.types.ts b/packages/binding/src/types/binding.types.ts index fa44aa3dd..8ce49a3bd 100644 --- a/packages/binding/src/types/binding.types.ts +++ b/packages/binding/src/types/binding.types.ts @@ -314,7 +314,6 @@ export class BindingError extends PingError { * @remarks * Keep these in sync with native error constants. * - * TODO(SDKS-semver): See packages/binding/TODOS.md for the full semver widening plan. */ export type BindingErrorCode = | 'BINDING_ERROR' @@ -329,6 +328,7 @@ export type BindingErrorCode = | 'BINDING_KEY_READ_ERROR' | 'BINDING_KEY_DELETE_ERROR' | 'BINDING_KEY_INVALIDATED' - | 'BINDING_AUTH_FAILED'; + | 'BINDING_AUTH_FAILED' + | (string & {}); export type { JourneyInstance }; diff --git a/packages/browser/LICENSE b/packages/browser/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/browser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/browser/README.md b/packages/browser/README.md index d111e86de..902a43afc 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -156,10 +156,6 @@ try { } ``` -## TODO - -- Add an iOS test runner target to execute module unit tests. - ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/browser/RNPingBrowser.podspec b/packages/browser/RNPingBrowser.podspec index 991f4cb46..6029162e0 100644 --- a/packages/browser/RNPingBrowser.podspec +++ b/packages/browser/RNPingBrowser.podspec @@ -41,15 +41,14 @@ Pod::Spec.new do |s| # Native Ping SDK dependency s.dependency 'PingBrowser', '2.0.0' + s.dependency 'PingLogger', '2.0.0' s.dependency 'RNPingCore' - # TODO: Remove RNPingLogger once PingBrowser exposes BrowserLauncher.logger as public. - # At that point, logger resolution can be wired through CoreRuntime.loggerRegistry - # (see RNPingBrowserCommon.swift TODO) and this direct dependency is no longer needed. s.dependency 'RNPingLogger' s.test_spec "Tests" do |test_spec| test_spec.source_files = "ios/Tests/**/*.{swift}" test_spec.dependency 'PingBrowser', '2.0.0' + test_spec.dependency 'PingLogger', '2.0.0' test_spec.dependency 'RNPingCore' test_spec.dependency 'RNPingLogger' end diff --git a/packages/browser/android/build.gradle b/packages/browser/android/build.gradle index 6fe0c67b8..f80dc3e3e 100644 --- a/packages/browser/android/build.gradle +++ b/packages/browser/android/build.gradle @@ -89,8 +89,8 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation("com.pingidentity.sdks:browser:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:browser:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" diff --git a/packages/browser/ios/BrowserLaunching.swift b/packages/browser/ios/BrowserLaunching.swift index 12a3e7e45..31ea0ff8e 100644 --- a/packages/browser/ios/BrowserLaunching.swift +++ b/packages/browser/ios/BrowserLaunching.swift @@ -10,6 +10,7 @@ import Foundation import PingBrowser +import PingLogger /// Abstraction over the Ping Browser launcher for testability. @MainActor @@ -19,7 +20,8 @@ public protocol BrowserLaunching { customParams: [String: String]?, browserType: BrowserType, browserMode: BrowserMode, - callbackURLScheme: String + callbackURLScheme: String, + logger: Logger ) async throws -> URL func reset() diff --git a/packages/browser/ios/DefaultBrowserLauncherAdapter.swift b/packages/browser/ios/DefaultBrowserLauncherAdapter.swift index 0cf1b1ab0..74a5d9e55 100644 --- a/packages/browser/ios/DefaultBrowserLauncherAdapter.swift +++ b/packages/browser/ios/DefaultBrowserLauncherAdapter.swift @@ -16,15 +16,13 @@ import PingLogger public struct DefaultBrowserLauncherAdapter: BrowserLaunching { public init() {} - // TODO: Accept a `logger:` parameter so the JS-resolved logger can be - // forwarded to BrowserLauncher.launch() instead of the global LogManager - // singleton. Requires extending the BrowserLaunching protocol too. public func launch( url: URL, customParams: [String: String]?, browserType: BrowserType, browserMode: BrowserMode, - callbackURLScheme: String + callbackURLScheme: String, + logger: Logger ) async throws -> URL { return try await BrowserLauncher.currentBrowser.launch( url: url, @@ -32,7 +30,7 @@ public struct DefaultBrowserLauncherAdapter: BrowserLaunching { browserType: browserType, browserMode: browserMode, callbackURLScheme: callbackURLScheme, - logger: LogManager.logger + logger: logger ) } diff --git a/packages/browser/ios/RNPingBrowserCommon.swift b/packages/browser/ios/RNPingBrowserCommon.swift index 1d3849496..433ec8542 100644 --- a/packages/browser/ios/RNPingBrowserCommon.swift +++ b/packages/browser/ios/RNPingBrowserCommon.swift @@ -43,6 +43,20 @@ public class RNPingBrowserCommon: NSObject { } #endif + /// Resolve a native logger from the shared Core logger registry. + /// + /// - Parameter loggerId: Logger handle identifier from JS. + /// - Returns: Native logger instance, or nil when missing/invalid. + private static func resolveLogger(_ loggerId: String?) async -> Logger? { + guard let loggerId, !loggerId.isEmpty else { + return nil + } + guard let handle = await CoreRuntime.loggerRegistry.resolve(loggerId) as? LoggerHandleContract else { + return nil + } + return handle.nativeLogger as? Logger + } + /// Accepts configuration from JavaScript (currently a no-op on iOS). /// /// - Parameter config: Optional configuration payload from the JS layer. @@ -135,11 +149,7 @@ public class RNPingBrowserCommon: NSObject { } Task { @MainActor in - // TODO: Pass the JS-provided logger through to BrowserLauncher.launch(). - // The installed PingBrowser accepts a `logger:` parameter on launch() — - // resolve the JS loggerId via CoreRuntime.loggerRegistry and forward it - // through BrowserLaunching + DefaultBrowserLauncherAdapter. Android - // already does this correctly. + let nativeLogger = await resolveLogger(loggerId) ?? LogManager.none do { let result = try await browserLauncher.launch( @@ -147,7 +157,8 @@ public class RNPingBrowserCommon: NSObject { customParams: nil, browserType: browserType, browserMode: browserMode, - callbackURLScheme: callbackScheme + callbackURLScheme: callbackScheme, + logger: nativeLogger ) handlers.resolve([ diff --git a/packages/browser/ios/Tests/RNPingBrowserCommonTests.swift b/packages/browser/ios/Tests/RNPingBrowserCommonTests.swift index d2b34a29c..181be9bff 100644 --- a/packages/browser/ios/Tests/RNPingBrowserCommonTests.swift +++ b/packages/browser/ios/Tests/RNPingBrowserCommonTests.swift @@ -6,6 +6,7 @@ import XCTest import AuthenticationServices import PingBrowser +import PingLogger import RNPingBrowser import RNPingLogger @@ -192,6 +193,49 @@ final class RNPingBrowserCommonTests: XCTestCase { wait(for: [expectation], timeout: 1) } + func testOpenForwardsResolvedLoggerWhenLoggerIdProvided() { + let expectation = expectation(description: "resolver called") + let options: NSDictionary = [ + "callbackUrlScheme": "com.example.app", + "loggerId": loggerId ?? "" + ] + let launcher = FakeBrowserLauncher() + launcher.result = .success(URL(string: "com.example.app://callback")!) + + RNPingBrowserCommon._setBrowserLauncherForTesting(launcher) + RNPingBrowserCommon.open( + testUrl, + options: options, + resolver: { _ in + XCTAssertNotNil(launcher.lastLogger, "resolved logger should be forwarded to launch()") + expectation.fulfill() + }, + rejecter: { _, _, _ in XCTFail("rejecter should not be called") } + ) + + wait(for: [expectation], timeout: 1) + } + + func testOpenForwardsNoneLoggerWhenNoLoggerIdProvided() { + let expectation = expectation(description: "resolver called") + let options: NSDictionary = ["callbackUrlScheme": "com.example.app"] + let launcher = FakeBrowserLauncher() + launcher.result = .success(URL(string: "com.example.app://callback")!) + + RNPingBrowserCommon._setBrowserLauncherForTesting(launcher) + RNPingBrowserCommon.open( + testUrl, + options: options, + resolver: { _ in + XCTAssertNotNil(launcher.lastLogger, "LogManager.none fallback should still be forwarded to launch()") + expectation.fulfill() + }, + rejecter: { _, _, _ in XCTFail("rejecter should not be called") } + ) + + wait(for: [expectation], timeout: 1) + } + func testResetDelegatesToLauncher() { let expectation = expectation(description: "reset delegated") let launcher = FakeBrowserLauncher() @@ -213,17 +257,20 @@ private final class FakeBrowserLauncher: BrowserLaunching { var lastBrowserType: BrowserType? var lastBrowserMode: BrowserMode? var lastCallbackScheme: String? + var lastLogger: Logger? func launch( url: URL, customParams: [String : String]?, browserType: BrowserType, browserMode: BrowserMode, - callbackURLScheme: String + callbackURLScheme: String, + logger: Logger ) async throws -> URL { lastBrowserType = browserType lastBrowserMode = browserMode lastCallbackScheme = callbackURLScheme + lastLogger = logger return try result.get() } diff --git a/packages/browser/src/NativeRNPingBrowser.ts b/packages/browser/src/NativeRNPingBrowser.ts index 0bfec6597..f3117f93c 100644 --- a/packages/browser/src/NativeRNPingBrowser.ts +++ b/packages/browser/src/NativeRNPingBrowser.ts @@ -56,23 +56,30 @@ export interface Spec extends TurboModule { } /** - * Resolve by probing TurboModule first, then falling back to the classic bridge module. + * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingBrowser'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingBrowserClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-browser] Native module RNPingBrowser not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/browser/src/index.tsx b/packages/browser/src/index.tsx index 8d2547b1e..14423ad6b 100644 --- a/packages/browser/src/index.tsx +++ b/packages/browser/src/index.tsx @@ -7,6 +7,7 @@ import { Platform } from 'react-native'; import { getNativeModule } from './NativeRNPingBrowser'; +import { noopLogger } from '@ping-identity/rn-types'; import type { LoggerInstance } from '@ping-identity/rn-types'; import type { @@ -17,18 +18,6 @@ import type { } from './types'; import { BrowserError } from './types'; -/** - * No-op logger used when callers do not provide one. - */ -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Resolve JS logger instance and native logger identifier for bridge calls. */ diff --git a/packages/browser/src/types/browser.types.ts b/packages/browser/src/types/browser.types.ts index f0005303b..b2c17eb14 100644 --- a/packages/browser/src/types/browser.types.ts +++ b/packages/browser/src/types/browser.types.ts @@ -16,7 +16,8 @@ import type { */ export type BrowserResult = | { type: 'success'; url: string } - | { type: 'cancel' }; + | { type: 'cancel' } + | { type: string & {}; [key: string]: unknown }; /** * Error thrown when browser operations fail. diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md index 5f2bb5e55..8bbe449ef 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,104 +9,17 @@ of the MIT license. See the LICENSE file for details. # Ping Identity React Native Core -The Ping Identity React Native Core module hosts shared runtime utilities for Ping RN SDKs. It -provides process-wide registries for native handles, plus native error contracts used to keep -promise rejections consistent across modules. +The Core module provides shared runtime infrastructure required by all other Ping Identity React Native SDK packages. It does not expose a consumer API — install it once and the other packages handle the rest. -## Table of contents +## Installation -- [Integrating the SDK into your project](#integrating-the-sdk-into-your-project) -- [How to Use the SDK](#how-to-use-the-sdk) -- [License](#license) - -## Integrating the SDK into your project - -> **Note:** This module is required by all other Ping Identity React Native SDK packages. Set it up and install it first. - -Add the package and let autolinking wire the native code: +All other Ping Identity RN SDK packages depend on this module. Install it first: ```bash yarn add @ping-identity/rn-core cd ios && pod install ``` -## How to Use the SDK - -### Register native handles (Android) - -Use the shared registry to keep native objects alive and retrievable by id: - -```kotlin -import com.pingidentity.rncore.CoreRuntime -import com.pingidentity.rncore.registry.NativeHandle - -class MyHandle : NativeHandle - -val id = CoreRuntime.storageRegistry.register(MyHandle()) -val handle = CoreRuntime.storageRegistry.resolve(id) -CoreRuntime.storageRegistry.remove(id) -``` - -### Register native handles (iOS) - -```swift -import RNPingCore - -final class MyHandle: NativeHandle {} - -let id = await CoreRuntime.storageRegistry.register(MyHandle()) -let handle = await CoreRuntime.storageRegistry.resolve(id) -await CoreRuntime.storageRegistry.remove(id) -``` - -Note: Android registry calls are synchronous. iOS uses async/await because the registry is -actor-isolated. - -### Clearing registries - -Both platforms support clearing all tracked handles: - -```kotlin -CoreRuntime.storageRegistry.removeAll() -``` - -```swift -await CoreRuntime.storageRegistry.removeAll() -``` - -### Rejecting promises with shared error contracts - -Core defines a shared error payload so native modules can reject with consistent, serializable -data. The shape mirrors `@ping-identity/rn-types` (type, error, message, code, status). - -#### Android - -```kotlin -import com.pingidentity.rncore.error.ErrorType -import com.pingidentity.rncore.error.GenericError -import com.pingidentity.rncore.error.reject - -val error = GenericError( - type = ErrorType.ARGUMENT_ERROR, - error = "BROWSER_OPEN_ERROR", - message = "Invalid URL" -) -promise.reject(error) -``` - -#### iOS - -```swift -import RNPingCore - -let error = GenericError( - type: .argumentError, - error: "BROWSER_OPEN_ERROR", - message: "Invalid URL" -) -reject(error, rejecter: rejecter) -``` - ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 8c03748ca..d1fd77c5e 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -84,6 +84,6 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" testImplementation "junit:junit:4.13.2" } diff --git a/packages/core/android/src/main/java/com/pingidentity/rncore/CoreRuntime.kt b/packages/core/android/src/main/java/com/pingidentity/rncore/CoreRuntime.kt index 707850491..758af76f2 100644 --- a/packages/core/android/src/main/java/com/pingidentity/rncore/CoreRuntime.kt +++ b/packages/core/android/src/main/java/com/pingidentity/rncore/CoreRuntime.kt @@ -48,8 +48,10 @@ object CoreRuntime { /** * Resolves callbacks for the provided Journey id via the registered resolver. - * TODO: Revisit this global resolver pattern with a synchronous Journey handle contract resolved - * through journeyRegistry; callback access is an in-memory ContinueNode lookup. + * + * Packages that need Journey callbacks (binding, fido, device-profile) cannot depend + * on rn-journey directly — this indirection lets Journey inject its lookup at init + * time without creating a circular dependency. */ suspend fun resolveJourneyCallbacks(journeyId: String): List? = journeyCallbackResolver?.invoke(journeyId) diff --git a/packages/core/ios/CoreRuntime.swift b/packages/core/ios/CoreRuntime.swift index d682d2b21..4ca11ec78 100644 --- a/packages/core/ios/CoreRuntime.swift +++ b/packages/core/ios/CoreRuntime.swift @@ -72,18 +72,18 @@ public enum CoreRuntime { /// Internal resolver store used to avoid shared mutable global state. private static let journeyCallbackResolverStore = JourneyCallbackResolverStore() - /// Registers or clears the resolver that exposes Journey callbacks. - /// TODO: Remove once journey module matures and types package is available. + /// Registers or clears the resolver that exposes Journey callbacks to other packages. + /// + /// Packages that need Journey callbacks (binding, fido, device-profile) cannot depend + /// on `rn-journey` directly — this indirection lets Journey inject its lookup at init + /// time without creating a circular dependency. /// /// - Parameter resolver: Resolver closure to register, or `nil` to clear. public static func setJourneyCallbackResolver(_ resolver: JourneyCallbackResolver?) { journeyCallbackResolverStore.set(resolver) } - /// Convenience helper for resolving callbacks via the registered resolver. - /// TODO: Replace this global resolver with a synchronous Journey handle contract resolved - /// through `journeyRegistry`; callback access is an in-memory ContinueNode lookup. - /// Resolving the handle from the actor-isolated registry may still require `async`. + /// Resolves Journey callbacks for the given journey instance via the registered resolver. public static func resolveJourneyCallbacks( _ journeyId: String ) async -> [Any]? { diff --git a/packages/device-client/LICENSE b/packages/device-client/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/device-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/device-client/README.md b/packages/device-client/README.md index fa82f1d6c..13175b119 100644 --- a/packages/device-client/README.md +++ b/packages/device-client/README.md @@ -184,7 +184,4 @@ Stable error codes: ## License -MIT - - +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/device-client/android/build.gradle b/packages/device-client/android/build.gradle index 050f88a92..11d97e6b2 100644 --- a/packages/device-client/android/build.gradle +++ b/packages/device-client/android/build.gradle @@ -84,15 +84,15 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:device-client:2.0.0") - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:device-client:2.0.1") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") implementation "io.ktor:ktor-client-core:2.3.12" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.12" testImplementation "org.robolectric:robolectric:4.11.1" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0" } diff --git a/packages/device-client/src/NativeRNPingDeviceClient.ts b/packages/device-client/src/NativeRNPingDeviceClient.ts index c018ec105..6a36215ec 100644 --- a/packages/device-client/src/NativeRNPingDeviceClient.ts +++ b/packages/device-client/src/NativeRNPingDeviceClient.ts @@ -116,21 +116,27 @@ export interface Spec extends TurboModule { * is available. The error message includes the list of available * `NativeModules` keys for debugging. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingDeviceClient'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingDeviceClientClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-device-client] Native module RNPingDeviceClient not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/device-client/src/createDeviceClient.ts b/packages/device-client/src/createDeviceClient.ts index 9902f1f05..389ffd2de 100644 --- a/packages/device-client/src/createDeviceClient.ts +++ b/packages/device-client/src/createDeviceClient.ts @@ -5,30 +5,17 @@ * of the MIT license. See the LICENSE file for details. */ -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; import { getNativeModule } from './NativeRNPingDeviceClient'; import type { + DeviceByKind, DeviceClient, DeviceClientConfig, - DeviceKind, DeviceOf, DeviceRepository, } from './types'; import { DeviceClientError } from './types'; -/** - * No-op logger used when the caller does not provide a logger instance. - * Prevents null-checks on every log call inside repository operations. - */ -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Shape of the wrapper object returned by native bridge calls that * contain a `result` property. @@ -161,7 +148,7 @@ export function createDeviceClient(config: DeviceClientConfig): DeviceClient { * @param kind - The {@link DeviceKind} string. * @returns A repository with `get`, `update`, and `delete` methods. */ - const repo = ( + const repo = ( kind: K, ): DeviceRepository> => ({ async get() { diff --git a/packages/device-client/src/types/device.types.ts b/packages/device-client/src/types/device.types.ts index 0d462a0b7..fc6cd75dd 100644 --- a/packages/device-client/src/types/device.types.ts +++ b/packages/device-client/src/types/device.types.ts @@ -24,7 +24,13 @@ * const devices = await client[kind].get(); * ``` */ -export type DeviceKind = 'oath' | 'push' | 'bound' | 'profile' | 'webAuthn'; +export type DeviceKind = + | 'oath' + | 'push' + | 'bound' + | 'profile' + | 'webAuthn' + | (string & {}); /** * Common fields shared by every device kind. @@ -190,4 +196,4 @@ export interface DeviceByKind { * type T = DeviceOf<'bound'>; // BoundDevice * ``` */ -export type DeviceOf = DeviceByKind[K]; +export type DeviceOf = DeviceByKind[K]; diff --git a/packages/device-client/src/types/error.types.ts b/packages/device-client/src/types/error.types.ts index 27f890636..686ce16b5 100644 --- a/packages/device-client/src/types/error.types.ts +++ b/packages/device-client/src/types/error.types.ts @@ -60,4 +60,5 @@ export type DeviceClientErrorCode = | 'DEVICE_CLIENT_DECODING_FAILED' | 'DEVICE_CLIENT_MISSING_CONFIG' | 'DEVICE_CLIENT_NOT_FOUND' - | 'DEVICE_CLIENT_HANDLE_NOT_FOUND'; + | 'DEVICE_CLIENT_HANDLE_NOT_FOUND' + | (string & {}); diff --git a/packages/device-id/LICENSE b/packages/device-id/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/device-id/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/device-id/README.md b/packages/device-id/README.md index b06b90cd0..10149b0b9 100644 --- a/packages/device-id/README.md +++ b/packages/device-id/README.md @@ -91,4 +91,4 @@ try { ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/device-id/android/build.gradle b/packages/device-id/android/build.gradle index 11314ae2d..11b58fd3c 100644 --- a/packages/device-id/android/build.gradle +++ b/packages/device-id/android/build.gradle @@ -87,8 +87,8 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:device-id:2.0.0") - implementation("com.pingidentity.sdks:android:2.0.0") + implementation("com.pingidentity.sdks:device-id:2.0.1") + implementation("com.pingidentity.sdks:android:2.0.1") implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.12" diff --git a/packages/device-id/src/NativeRNPingDeviceId.ts b/packages/device-id/src/NativeRNPingDeviceId.ts index c29caa6d0..67d3540e2 100644 --- a/packages/device-id/src/NativeRNPingDeviceId.ts +++ b/packages/device-id/src/NativeRNPingDeviceId.ts @@ -42,23 +42,34 @@ export interface Spec extends TurboModule { } /** - * Resolve by probing TurboModule first, then falling back to the classic bridge module. + * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. */ +let _nativeModule: Spec | null = null; +/** @internal — resets the module cache for testing only. */ +export function _resetNativeModuleForTesting(): void { + _nativeModule = null; +} export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingDeviceId'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingDeviceIdClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-device-id] Native module RNPingDeviceId not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/device-profile/LICENSE b/packages/device-profile/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/device-profile/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/device-profile/README.md b/packages/device-profile/README.md index ef5d41f6d..fba07ea80 100644 --- a/packages/device-profile/README.md +++ b/packages/device-profile/README.md @@ -178,8 +178,6 @@ app's `Info.plist` so iOS can prompt the user for permission. This app uses your location to complete device profiling. ``` -TODO: Re-check `@MainActor` usage in Device Profile iOS paths for potential UI-thread bottlenecks. - ## Configure logging (optional) If you install the logger package, pass a JS logger instance per call via `DeviceProfileLoggerOptions`. @@ -228,6 +226,26 @@ active callback, applies server-driven configuration, executes the requested collectors, submits the resulting metadata automatically, and resolves with a result object describing success. Failures reject with a shared `GenericError`. +### With `useJourneyForm` + +Pass `handledCallbackTypes` so device profile fields are excluded from blocking submit issues: + +```ts +import { useJourney, useJourneyForm } from '@ping-identity/rn-journey'; +import { callbackType } from '@ping-identity/rn-types'; + +const [node, actions] = useJourney(client); +const form = useJourneyForm(node, { + handledCallbackTypes: new Set([callbackType.DeviceProfileCallback]), +}); + +await collectDeviceProfileForJourney(journey, ['platform', 'hardware']); + +if (form.canSubmit) { + await actions.next(form.input); +} +``` + ## API reference ```ts @@ -276,4 +294,4 @@ Common error codes surfaced via `error` in rejection payloads: ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/device-profile/android/build.gradle b/packages/device-profile/android/build.gradle index 8e3c4afa2..fe3dc479d 100644 --- a/packages/device-profile/android/build.gradle +++ b/packages/device-profile/android/build.gradle @@ -88,10 +88,10 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:device-profile:2.0.0") - implementation("com.pingidentity.sdks:journey-plugin:2.0.0") - implementation("com.pingidentity.sdks:device-root:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:device-profile:2.0.1") + implementation("com.pingidentity.sdks:journey-plugin:2.0.1") + implementation("com.pingidentity.sdks:device-root:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) @@ -104,5 +104,5 @@ dependencies { testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.9" testImplementation "org.robolectric:robolectric:4.11.1" - testImplementation("com.pingidentity.sdks:device-profile:2.0.0") + testImplementation("com.pingidentity.sdks:device-profile:2.0.1") } diff --git a/packages/device-profile/src/NativeRNPingDeviceProfile.ts b/packages/device-profile/src/NativeRNPingDeviceProfile.ts index 50fbd6edf..1698ab74e 100644 --- a/packages/device-profile/src/NativeRNPingDeviceProfile.ts +++ b/packages/device-profile/src/NativeRNPingDeviceProfile.ts @@ -60,11 +60,12 @@ export function getNativeModule(): Spec { return classic; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-device-profile] Native module RNPingDeviceProfile not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/device-profile/src/index.tsx b/packages/device-profile/src/index.tsx index 1f12c074f..eb672ef78 100644 --- a/packages/device-profile/src/index.tsx +++ b/packages/device-profile/src/index.tsx @@ -5,6 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ +import { noopLogger } from '@ping-identity/rn-types'; import type { JourneyInstance, LoggerInstance } from '@ping-identity/rn-types'; import { getNativeModule } from './NativeRNPingDeviceProfile'; import { DeviceProfileError } from './types'; @@ -15,18 +16,6 @@ import type { DeviceProfileJourneyResult, } from './types'; -/** - * No-op logger used when callers do not provide one. - */ -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Resolve JS logger instance and native logger identifier for bridge calls. */ diff --git a/packages/device-profile/src/types/deviceProfile.types.ts b/packages/device-profile/src/types/deviceProfile.types.ts index e231d5ff6..3bcf90a58 100644 --- a/packages/device-profile/src/types/deviceProfile.types.ts +++ b/packages/device-profile/src/types/deviceProfile.types.ts @@ -59,7 +59,8 @@ export class DeviceProfileError extends PingError { export type DeviceProfileErrorCode = | 'DEVICE_PROFILE_LOCATION_UNAVAILABLE' | 'DEVICE_PROFILE_CALLBACK_NOT_FOUND' - | 'DEVICE_PROFILE_COLLECT_ERROR'; + | 'DEVICE_PROFILE_COLLECT_ERROR' + | (string & {}); /** * Represents a device profile structured for PingOne AIC consumption. diff --git a/packages/external-idp/LICENSE b/packages/external-idp/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/external-idp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/external-idp/README.md b/packages/external-idp/README.md index 5f3909d84..8747223f6 100644 --- a/packages/external-idp/README.md +++ b/packages/external-idp/README.md @@ -340,6 +340,29 @@ async function handleExternalIdpNode( } ``` +### With `useJourneyForm` + +Pass `handledCallbackTypes` so IdP fields are excluded from blocking submit issues: + +```ts +import { useJourney, useJourneyForm } from '@ping-identity/rn-journey'; +import { nativeExtensionCallbackType } from '@ping-identity/rn-types'; + +const [node, actions] = useJourney(client); +const form = useJourneyForm(node, { + handledCallbackTypes: new Set([ + nativeExtensionCallbackType.IdPCallback, + nativeExtensionCallbackType.IdpCallback, + ]), +}); + +await externalIdp.authorizeForJourney(journey); + +if (form.canSubmit) { + await actions.next(form.input); +} +``` + --- ## API reference @@ -407,4 +430,6 @@ Stable error codes: --- -© Copyright 2025-2026 Ping Identity Corporation. All Rights Reserved +## License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/external-idp/android/build.gradle b/packages/external-idp/android/build.gradle index 1e5b4412a..f5aca5e8f 100644 --- a/packages/external-idp/android/build.gradle +++ b/packages/external-idp/android/build.gradle @@ -95,12 +95,12 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") - implementation("com.pingidentity.sdks:external-idp:2.0.0") - implementation("com.pingidentity.sdks:journey-plugin:2.0.0") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") + implementation("com.pingidentity.sdks:external-idp:2.0.1") + implementation("com.pingidentity.sdks:journey-plugin:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" diff --git a/packages/external-idp/src/NativeRNPingExternalIdp.ts b/packages/external-idp/src/NativeRNPingExternalIdp.ts index 9e3a285d4..39dd61736 100644 --- a/packages/external-idp/src/NativeRNPingExternalIdp.ts +++ b/packages/external-idp/src/NativeRNPingExternalIdp.ts @@ -68,29 +68,34 @@ export interface Spec extends TurboModule { /* eslint-enable @typescript-eslint/no-wrapper-object-types */ /** - * Resolve by probing TurboModule first, then falling back to the classic bridge module. + * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. * * @returns Native module implementation for the current architecture. * @throws Error when no native module is registered. */ +let _nativeModule: Spec | null = null; +/** @internal — resets the module cache for testing only. */ +export function _resetNativeModuleForTesting(): void { + _nativeModule = null; +} export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingExternalIdp'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingExternalIdpClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } - // TODO: apply this __DEV__ guard to the other Native* modules - // (logger, oidc, journey, fido, storage, browser, device-profile, device-id) - // so the registered module list is never embedded in production error telemetry. - const availableModules = __DEV__ - ? '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)) - : ''; - + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-external-idp] Native module RNPingExternalIdp not found.\n' + 'Ensure the library is linked correctly and the app has been rebuilt.' + diff --git a/packages/external-idp/src/__tests__/native-module.test.tsx b/packages/external-idp/src/__tests__/native-module.test.tsx index c0784b856..209c73602 100644 --- a/packages/external-idp/src/__tests__/native-module.test.tsx +++ b/packages/external-idp/src/__tests__/native-module.test.tsx @@ -13,6 +13,7 @@ jest.mock('react-native', () => ({ })); import { + _resetNativeModuleForTesting, fromNativeAuthorizeResult, getNativeModule, toNativeAuthorizeOptions, @@ -23,6 +24,7 @@ import { describe('getNativeModule', () => { beforeEach(() => { jest.resetModules(); + _resetNativeModuleForTesting(); (TurboModuleRegistry.get as jest.Mock).mockReset(); // Reset NativeModules to empty by default Object.keys(NativeModules).forEach((key) => { diff --git a/packages/external-idp/src/externalIdp.ts b/packages/external-idp/src/externalIdp.ts index 330efeb70..8d2f171ce 100644 --- a/packages/external-idp/src/externalIdp.ts +++ b/packages/external-idp/src/externalIdp.ts @@ -12,7 +12,7 @@ import { toNativeConfig, toNativeSelectOptions, } from './NativeRNPingExternalIdp'; -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; import type { ExternalIdpAuthorizeOptions, ExternalIdpClient, @@ -24,15 +24,6 @@ import type { import { ExternalIdpError } from './types/externalIdp.types'; import type { ExternalIdpClientConfig } from './types/externalIdp.types'; -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Resolve an optional redirect URI and reject values that cannot be used by Auth Tabs. * diff --git a/packages/fido/LICENSE b/packages/fido/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/fido/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/fido/README.md b/packages/fido/README.md index 3adff4419..0f593a3cd 100644 --- a/packages/fido/README.md +++ b/packages/fido/README.md @@ -17,7 +17,7 @@ This package provides a native-backed FIDO bridge for React Native. - [FIDO prerequisites](#fido-prerequisites) - [Client-first usage](#client-first-usage) - [Journey integration](#journey-integration) -- [Optional `useJourneyForm` integration](#optional-usejourneyform-integration) +- [`useJourneyForm` integration](#usejourneyform-integration) - [API reference](#api-reference) - [Errors](#errors) - [Platform notes](#platform-notes) @@ -157,34 +157,39 @@ if (node.type === 'ContinueNode') { } ``` -## Optional `useJourneyForm` integration +## `useJourneyForm` integration -When using `useJourneyForm`, FIDO fields are marked with `executionMode: 'integration_required'`. -This indicates app code must run FIDO integration explicitly. +When using `useJourneyForm`, pass `handledCallbackTypes` so FIDO fields are excluded from +blocking submit issues. Run each integration, then submit when `form.canSubmit` is true. ```ts -import { useJourneyForm } from '@ping-identity/rn-journey'; +import { useJourney, useJourneyForm } from '@ping-identity/rn-journey'; import { createFidoClient } from '@ping-identity/rn-fido'; - -const form = useJourneyForm(node); +import { nativeExtensionCallbackType } from '@ping-identity/rn-types'; + +const [node, actions] = useJourney(client); +const form = useJourneyForm(node, { + handledCallbackTypes: new Set([ + nativeExtensionCallbackType.FidoRegistrationCallback, + nativeExtensionCallbackType.FidoAuthenticationCallback, + ]), +}); const fido = createFidoClient(); for (const field of form.fields) { - if (field.ref.type === 'FidoRegistrationCallback') { - await fido.registerForJourney(journey, { - index: field.ref.typeIndex, - deviceName: 'My Device', - }); + if (field.ref.type === nativeExtensionCallbackType.FidoRegistrationCallback) { + await fido.registerForJourney(journey, { index: field.ref.typeIndex }); } - - if (field.ref.type === 'FidoAuthenticationCallback') { - await fido.authenticateForJourney(journey, { - index: field.ref.typeIndex, - }); + if ( + field.ref.type === nativeExtensionCallbackType.FidoAuthenticationCallback + ) { + await fido.authenticateForJourney(journey, { index: field.ref.typeIndex }); } } -await journey.next({}); +if (form.canSubmit) { + await actions.next(form.input); +} ``` ## API reference @@ -253,4 +258,4 @@ Full passkey E2E strategy (including OS-level credential surfaces outside app UI ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/fido/android/build.gradle b/packages/fido/android/build.gradle index f377c837f..be30b457f 100644 --- a/packages/fido/android/build.gradle +++ b/packages/fido/android/build.gradle @@ -84,10 +84,10 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:journey:2.0.0") - implementation("com.pingidentity.sdks:fido:2.0.0") - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:journey:2.0.1") + implementation("com.pingidentity.sdks:fido:2.0.1") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" diff --git a/packages/fido/src/NativeRNPingFido.ts b/packages/fido/src/NativeRNPingFido.ts index ffe3df729..318cf03a9 100644 --- a/packages/fido/src/NativeRNPingFido.ts +++ b/packages/fido/src/NativeRNPingFido.ts @@ -89,24 +89,35 @@ export type NativeFidoConfig = { }; /** - * Resolve by probing TurboModule first, then falling back to the classic bridge module. + * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. */ +let _nativeModule: Spec | null = null; +/** @internal — resets the module cache for testing only. */ +export function _resetNativeModuleForTesting(): void { + _nativeModule = null; +} export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingFido'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingFidoClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-fido] Native module RNPingFido not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/fido/src/index.tsx b/packages/fido/src/index.tsx index 913aa8e5c..6b053025d 100644 --- a/packages/fido/src/index.tsx +++ b/packages/fido/src/index.tsx @@ -15,7 +15,7 @@ import { toNativeJourneyRegistrationOptions, toNativeRegistrationOptions, } from './NativeRNPingFido'; -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; import type { FidoClient, FidoClientConfig, @@ -31,15 +31,6 @@ import type { } from './types'; import { FidoError } from './types'; -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Creates a reusable FIDO client instance. * diff --git a/packages/fido/src/types/fido.types.ts b/packages/fido/src/types/fido.types.ts index 4a0322e17..8ea841ee6 100644 --- a/packages/fido/src/types/fido.types.ts +++ b/packages/fido/src/types/fido.types.ts @@ -208,6 +208,7 @@ export type FidoErrorCode = | 'FIDO_AUTHENTICATE_CANCELLED' | 'FIDO_ACTIVITY_UNAVAILABLE' | 'FIDO_WINDOW_UNAVAILABLE' - | 'FIDO_CALLBACK_NOT_FOUND'; + | 'FIDO_CALLBACK_NOT_FOUND' + | (string & {}); export type { JourneyInstance }; diff --git a/packages/journey/LICENSE b/packages/journey/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/journey/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/journey/README.md b/packages/journey/README.md index 7b9ecb8f2..c37607b39 100644 --- a/packages/journey/README.md +++ b/packages/journey/README.md @@ -295,7 +295,7 @@ Each normalized field includes `executionMode` and `requiresUserInput`. | --------------------- | --------------- | ------------------- | ---------------------------------------------------------------------------------------- | | `HiddenValueCallback` | `manual` | `false` | Hidden payload should pass through submit planning without forcing a visible input step. | -> TODO(test-runner app): add Journey integration and E2E tests (including `SuspendedTextOutputCallback` deep link/email resume flow) once the test-runner app is set up. +> TODO(test-runner app)(TODO-SEPARATE-TICKET): add Journey integration and E2E tests (including `SuspendedTextOutputCallback` deep link/email resume flow) once the test-runner app is set up. ### Core callback support @@ -367,3 +367,7 @@ Stable Journey error codes: - `JOURNEY_CALLBACK_APPLY_ERROR` - `JOURNEY_UNSUPPORTED_CALLBACK_ERROR` - `JOURNEY_MISSING_INTEGRATION_ERROR` + +## License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/journey/android/build.gradle b/packages/journey/android/build.gradle index 8968621fc..a5723e1a2 100644 --- a/packages/journey/android/build.gradle +++ b/packages/journey/android/build.gradle @@ -88,8 +88,8 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.pingidentity.sdks:journey:2.0.0" - implementation "com.pingidentity.sdks:android:2.0.0" + implementation "com.pingidentity.sdks:journey:2.0.1" + implementation "com.pingidentity.sdks:android:2.0.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation(project(":ping-identity_rn-core")) diff --git a/packages/journey/src/NativeRNPingJourney.ts b/packages/journey/src/NativeRNPingJourney.ts index b45b80a07..05116671c 100644 --- a/packages/journey/src/NativeRNPingJourney.ts +++ b/packages/journey/src/NativeRNPingJourney.ts @@ -5,6 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ +/* eslint-disable @typescript-eslint/no-wrapper-object-types -- TurboModule spec uses Object for bridge-mapped types (ReadableMap/NSDictionary) */ import { NativeModules, TurboModuleRegistry, @@ -245,22 +246,28 @@ export interface Spec extends TurboModule { * @returns Native module implementation for Journey APIs. * @throws Error when no matching native module can be found. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingJourney'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingJourneyClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-journey] Native module RNPingJourney not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/journey/src/callbackHelpers.ts b/packages/journey/src/callbackHelpers.ts index 04e47cfb8..5d2bc6d44 100644 --- a/packages/journey/src/callbackHelpers.ts +++ b/packages/journey/src/callbackHelpers.ts @@ -444,11 +444,15 @@ export function normalizeCallbacks( * * @param node - Journey node from `useJourney`. * @param values - Form values keyed by normalized field id. + * @param handledCallbackTypes - Callback types already handled by the app via + * native integration. Matching `integration_required` fields are excluded + * from submit issues so `canSubmit` reflects true readiness. * @returns Submit planning result with payload and issues. */ export function buildNextInput( node: JourneyNode | null | undefined, values: JourneyFormValues, + handledCallbackTypes?: ReadonlySet, ): JourneyBuildNextInputResult { if (!node || node.type !== 'ContinueNode') { return { @@ -476,22 +480,26 @@ export function buildNextInput( } if (field.executionMode === 'auto_capable') { - issues.push({ - code: 'INTEGRATION_REQUIRED', - message: `Callback "${callbackType}" requires additional integration.`, - fieldId: field.id, - callbackType, - }); + if (!handledCallbackTypes?.has(callbackType)) { + issues.push({ + code: 'INTEGRATION_REQUIRED', + message: `Callback "${callbackType}" requires additional integration.`, + fieldId: field.id, + callbackType, + }); + } return; } if (field.executionMode === 'integration_required') { - issues.push({ - code: 'INTEGRATION_REQUIRED', - message: `Callback "${callbackType}" requires additional integration.`, - fieldId: field.id, - callbackType, - }); + if (!handledCallbackTypes?.has(callbackType)) { + issues.push({ + code: 'INTEGRATION_REQUIRED', + message: `Callback "${callbackType}" requires additional integration.`, + fieldId: field.id, + callbackType, + }); + } return; } diff --git a/packages/journey/src/journey.ts b/packages/journey/src/journey.ts index 19dd7ee9b..e85ce672a 100644 --- a/packages/journey/src/journey.ts +++ b/packages/journey/src/journey.ts @@ -26,19 +26,10 @@ import type { } from './types'; import type { NativeJourneyConfig } from './NativeRNPingJourney'; import { JourneyError } from './types/error.types'; -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; type StorageHandleKind = 'session' | 'oidc'; -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Resolves and validates a storage handle id for Journey module config. * diff --git a/packages/journey/src/types/error.types.ts b/packages/journey/src/types/error.types.ts index ceec5a1f0..ff4bdf485 100644 --- a/packages/journey/src/types/error.types.ts +++ b/packages/journey/src/types/error.types.ts @@ -39,4 +39,5 @@ export type JourneyErrorCode = | 'JOURNEY_STATE_ERROR' | 'JOURNEY_CALLBACK_APPLY_ERROR' | 'JOURNEY_UNSUPPORTED_CALLBACK_ERROR' - | 'JOURNEY_MISSING_INTEGRATION_ERROR'; + | 'JOURNEY_MISSING_INTEGRATION_ERROR' + | (string & {}); diff --git a/packages/journey/src/types/form.types.ts b/packages/journey/src/types/form.types.ts index e7b9add6c..d624418f2 100644 --- a/packages/journey/src/types/form.types.ts +++ b/packages/journey/src/types/form.types.ts @@ -225,6 +225,20 @@ export type JourneyFormMeta = { hasRequiredConsentMissing: boolean; }; +/** + * Options accepted by {@link useJourneyForm}. + */ +export type JourneyFormOptions = { + /** + * Callback types that the app has already handled via native integration + * (for example FIDO, binding, or external IdP). When provided, matching + * `integration_required` fields are excluded from submit issues so that + * `canSubmit` reflects true readiness rather than blocking on already-handled + * integrations. + */ + handledCallbackTypes?: ReadonlySet; +}; + /** * Updater argument accepted by `setValues` from {@link useJourneyForm}. */ diff --git a/packages/journey/src/useJourneyForm.ts b/packages/journey/src/useJourneyForm.ts index 1067a423a..5f80c637c 100644 --- a/packages/journey/src/useJourneyForm.ts +++ b/packages/journey/src/useJourneyForm.ts @@ -10,6 +10,7 @@ import { buildNextInput, normalizeCallbacks } from './callbackHelpers'; import type { JourneyBuildNextInputResult, JourneyFormMeta, + JourneyFormOptions, JourneyFormResult, JourneyFormValue, JourneyFormValues, @@ -161,10 +162,12 @@ function deriveMeta( * ``` * * @param node - Current Journey node from `useJourney`. + * @param options - Optional form options. * @returns Normalized fields, managed values, and submit planning helpers. */ export function useJourneyForm( node: JourneyNode | null | undefined, + options: JourneyFormOptions = {}, ): JourneyFormResult { const fields = useMemo( () => getNormalizedFields(node), @@ -201,8 +204,8 @@ export function useJourneyForm( } const submitPlan = useMemo( - () => buildNextInput(node, values), - [node, values], + () => buildNextInput(node, values, options.handledCallbackTypes), + [node, values, options.handledCallbackTypes], ); const meta = useMemo( diff --git a/packages/logger/LICENSE b/packages/logger/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/logger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/logger/README.md b/packages/logger/README.md index b37623076..d51513628 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -147,4 +147,4 @@ try { ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/logger/android/build.gradle b/packages/logger/android/build.gradle index 13d9ced11..705108945 100644 --- a/packages/logger/android/build.gradle +++ b/packages/logger/android/build.gradle @@ -93,7 +93,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.pingidentity.sdks:logger:2.0.0" + implementation "com.pingidentity.sdks:logger:2.0.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation(project(":ping-identity_rn-core")) diff --git a/packages/logger/src/NativeRNPingLogger.ts b/packages/logger/src/NativeRNPingLogger.ts index 3d657b5cf..3d6662772 100644 --- a/packages/logger/src/NativeRNPingLogger.ts +++ b/packages/logger/src/NativeRNPingLogger.ts @@ -65,11 +65,12 @@ export function getNativeModule(): Spec { return classic; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-logger] Native module Logger not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/oath/LICENSE b/packages/oath/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/oath/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/oath/README.md b/packages/oath/README.md index 46f36f3f0..afeb410e9 100644 --- a/packages/oath/README.md +++ b/packages/oath/README.md @@ -238,18 +238,22 @@ interface OathCodeInfo { ## Error handling -All rejected promises throw an `OathError` instance, which extends `PingError extends Error`. +All rejected promises throw an `OathError` instance, which extends `PingError` (from `@ping-identity/rn-types`), which extends `Error`. Use `instanceof` to narrow the type: ```ts import { OathError } from '@ping-identity/rn-oath'; +import type { PingError } from '@ping-identity/rn-types'; // base type if needed try { const client = await createOathClient(); const code = await client.generateCode('my-credential-id'); } catch (err) { if (err instanceof OathError) { - console.log(err.code, err.type, err.message); + console.log(err.code); // OathErrorCode string — use for programmatic handling + console.log(err.type); // error category string from native layer + console.log(err.message); // human-readable description + console.log(err.status); // HTTP status code if applicable, otherwise undefined } } ``` @@ -276,4 +280,6 @@ try { --- -© Copyright 2025-2026 Ping Identity Corporation. All Rights Reserved +## License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/oath/android/build.gradle b/packages/oath/android/build.gradle index d793ee9a2..26d8f9e6e 100644 --- a/packages/oath/android/build.gradle +++ b/packages/oath/android/build.gradle @@ -87,8 +87,8 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:oath:2.0.0") - implementation("com.pingidentity.sdks:android:2.0.0") + implementation("com.pingidentity.sdks:oath:2.0.1") + implementation("com.pingidentity.sdks:android:2.0.1") implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" diff --git a/packages/oath/src/NativeRNPingOath.ts b/packages/oath/src/NativeRNPingOath.ts index cebf8d483..df291a89b 100644 --- a/packages/oath/src/NativeRNPingOath.ts +++ b/packages/oath/src/NativeRNPingOath.ts @@ -135,21 +135,31 @@ export interface Spec extends TurboModule { * @returns Native OATH module implementation for the current architecture. * @throws Error when no native OATH module is registered. */ +let _nativeModule: Spec | null = null; +/** @internal — resets the module cache for testing only. */ +export function _resetNativeModuleForTesting(): void { + _nativeModule = null; +} export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingOath'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingOathClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-oath] Native module RNPingOath not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/oath/src/__tests__/native-module.test.tsx b/packages/oath/src/__tests__/native-module.test.tsx index 23d521700..f8563fd79 100644 --- a/packages/oath/src/__tests__/native-module.test.tsx +++ b/packages/oath/src/__tests__/native-module.test.tsx @@ -12,10 +12,14 @@ jest.mock('react-native', () => ({ TurboModuleRegistry: { get: jest.fn() }, })); -import { getNativeModule } from '../NativeRNPingOath'; +import { + _resetNativeModuleForTesting, + getNativeModule, +} from '../NativeRNPingOath'; describe('getNativeModule', () => { beforeEach(() => { + _resetNativeModuleForTesting(); (TurboModuleRegistry.get as jest.Mock).mockReset(); Object.keys(NativeModules).forEach((key) => { delete (NativeModules as Record)[key]; diff --git a/packages/oath/src/oath.ts b/packages/oath/src/oath.ts index f92fc6782..de6158603 100644 --- a/packages/oath/src/oath.ts +++ b/packages/oath/src/oath.ts @@ -6,7 +6,7 @@ */ import { Platform } from 'react-native'; -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; import { getNativeModule } from './NativeRNPingOath'; import { OathError } from './types'; import type { @@ -16,15 +16,6 @@ import type { OathCredential, } from './types'; -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Assert that the client has not been closed. * diff --git a/packages/oath/src/types/oath.types.ts b/packages/oath/src/types/oath.types.ts index e8fb4db74..21e22a539 100644 --- a/packages/oath/src/types/oath.types.ts +++ b/packages/oath/src/types/oath.types.ts @@ -467,4 +467,5 @@ export type OathErrorCode = | 'OATH_MISSING_PARAMETER' | 'OATH_CLEANUP_FAILED' | 'OATH_STORAGE_CORRUPTED' - | 'OATH_STORAGE_ACCESS_DENIED'; + | 'OATH_STORAGE_ACCESS_DENIED' + | (string & {}); diff --git a/packages/oidc/LICENSE b/packages/oidc/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/oidc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/oidc/README.md b/packages/oidc/README.md index 4948ebb7a..932d53777 100644 --- a/packages/oidc/README.md +++ b/packages/oidc/README.md @@ -62,7 +62,7 @@ const oidcClient = createOidcClient({ }); ``` -> TODO(Android): `tokenExpiry` will be reintroduced once the native Android SDK exposes it. +> **Note:** `tokenExpiry` is excluded from token responses — the Android SDK does not expose it publicly yet. It will be added once both platforms support it. ### Configure token storage (optional) @@ -153,8 +153,6 @@ const oidcClient = createOidcClient({ }); ``` -> TODO(iOS SDK 2.x): enforce full OpenID override requirements to match the native iOS behavior. - ### Create the web-capable client and authorize ```ts @@ -333,3 +331,7 @@ Stable OIDC error codes: - `OIDC_LOGOUT_ERROR` `OIDC_STATE_ERROR` is used by JS hook/provider guardrails (for example, missing OIDC client context). + +## License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/oidc/android/build.gradle b/packages/oidc/android/build.gradle index 28a05162f..6fb277846 100644 --- a/packages/oidc/android/build.gradle +++ b/packages/oidc/android/build.gradle @@ -7,7 +7,7 @@ buildscript { ext.RNPingOidc = [ - kotlinVersion: "2.0.21", + kotlinVersion: "2.2.10", minSdkVersion: 24, compileSdkVersion: 36, targetSdkVersion: 36 @@ -95,13 +95,13 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:oidc:2.0.0") - implementation("com.pingidentity.sdks:browser:2.0.0") - implementation("com.pingidentity.sdks:orchestrate:2.0.0") - implementation("com.pingidentity.sdks:storage:2.0.0") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:oidc:2.0.1") + implementation("com.pingidentity.sdks:browser:2.0.1") + implementation("com.pingidentity.sdks:orchestrate:2.0.1") + implementation("com.pingidentity.sdks:storage:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" testImplementation "androidx.test:core:1.6.1" diff --git a/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt b/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt index cfa162056..1ab7134ec 100644 --- a/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt +++ b/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt @@ -685,4 +685,14 @@ object RNPingOidcCommon { } // Intentionally no Activity sync here; Ping SDK manages its own context. + + fun disposeClient(clientId: String, promise: Promise) { + clientRegistry.remove(clientId) + promise.resolve(null) + } + + fun disposeWebClient(webClientId: String, promise: Promise) { + webRegistry.remove(webClientId) + promise.resolve(null) + } } diff --git a/packages/oidc/android/src/newarch/java/com/pingidentity/rnoidc/RNPingOidcModule.kt b/packages/oidc/android/src/newarch/java/com/pingidentity/rnoidc/RNPingOidcModule.kt index cb361db74..15e5f5921 100644 --- a/packages/oidc/android/src/newarch/java/com/pingidentity/rnoidc/RNPingOidcModule.kt +++ b/packages/oidc/android/src/newarch/java/com/pingidentity/rnoidc/RNPingOidcModule.kt @@ -186,6 +186,14 @@ class RNPingOidcModule(reactContext: ReactApplicationContext) : RNPingOidcCommon.logout(webClientId, promise) } + override fun disposeClient(clientId: String, promise: Promise) { + RNPingOidcCommon.disposeClient(clientId, promise) + } + + override fun disposeWebClient(webClientId: String, promise: Promise) { + RNPingOidcCommon.disposeWebClient(webClientId, promise) + } + companion object { /** Name used for React Native module registration. */ const val NAME = "RNPingOidc" diff --git a/packages/oidc/android/src/oldarch/java/com/pingidentity/rnoidc/RNPingOidcClassicModule.kt b/packages/oidc/android/src/oldarch/java/com/pingidentity/rnoidc/RNPingOidcClassicModule.kt index 3a177db0f..d8c1f43e3 100644 --- a/packages/oidc/android/src/oldarch/java/com/pingidentity/rnoidc/RNPingOidcClassicModule.kt +++ b/packages/oidc/android/src/oldarch/java/com/pingidentity/rnoidc/RNPingOidcClassicModule.kt @@ -201,4 +201,14 @@ class RNPingOidcClassicModule( fun logout(webClientId: String, promise: Promise) { RNPingOidcCommon.logout(webClientId, promise) } + + @ReactMethod + fun disposeClient(clientId: String, promise: Promise) { + RNPingOidcCommon.disposeClient(clientId, promise) + } + + @ReactMethod + fun disposeWebClient(webClientId: String, promise: Promise) { + RNPingOidcCommon.disposeWebClient(webClientId, promise) + } } diff --git a/packages/oidc/ios/RNPingOidcCommon.swift b/packages/oidc/ios/RNPingOidcCommon.swift index 81dda075c..2e7960beb 100644 --- a/packages/oidc/ios/RNPingOidcCommon.swift +++ b/packages/oidc/ios/RNPingOidcCommon.swift @@ -680,4 +680,42 @@ public class RNPingOidcCommon: NSObject { } } + // MARK: - Dispose + + /// Remove an OIDC client from CoreRuntime registries. + /// + /// - Parameters: + /// - clientId: Identifier returned by `createClient`. + /// - resolver: Called on success. + /// - rejecter: Called on failure. + @objc + public static func disposeClient( + _ clientId: String, + resolver: @escaping @Sendable () -> Void, + rejecter: @escaping @Sendable (String, String, NSError?) -> Void + ) { + Task { + await clientRegistry.remove(clientId) + resolver() + } + } + + /// Remove an OIDC web client from CoreRuntime registries. + /// + /// - Parameters: + /// - webClientId: Identifier returned by `createWebClient`. + /// - resolver: Called on success. + /// - rejecter: Called on failure. + @objc + public static func disposeWebClient( + _ webClientId: String, + resolver: @escaping @Sendable () -> Void, + rejecter: @escaping @Sendable (String, String, NSError?) -> Void + ) { + Task { + await webRegistry.remove(webClientId) + resolver() + } + } + } diff --git a/packages/oidc/ios/RNPingOidcImpl.swift b/packages/oidc/ios/RNPingOidcImpl.swift index 577345323..3466d7408 100644 --- a/packages/oidc/ios/RNPingOidcImpl.swift +++ b/packages/oidc/ios/RNPingOidcImpl.swift @@ -215,4 +215,22 @@ public class RNPingOidcImpl: NSObject, @unchecked Sendable { ) { RNPingOidcCommon.logout(webClientId, resolver: resolver, rejecter: rejecter) } + + /// Deregister an OIDC client from CoreRuntime registries. + public func disposeClient( + _ clientId: String, + resolver: @escaping @Sendable () -> Void, + rejecter: @escaping @Sendable (String, String, NSError?) -> Void + ) { + RNPingOidcCommon.disposeClient(clientId, resolver: resolver, rejecter: rejecter) + } + + /// Deregister an OIDC web client from CoreRuntime registries. + public func disposeWebClient( + _ webClientId: String, + resolver: @escaping @Sendable () -> Void, + rejecter: @escaping @Sendable (String, String, NSError?) -> Void + ) { + RNPingOidcCommon.disposeWebClient(webClientId, resolver: resolver, rejecter: rejecter) + } } diff --git a/packages/oidc/src/NativeRNPingOidc.ts b/packages/oidc/src/NativeRNPingOidc.ts index cac44037b..2cccccf6f 100644 --- a/packages/oidc/src/NativeRNPingOidc.ts +++ b/packages/oidc/src/NativeRNPingOidc.ts @@ -108,6 +108,8 @@ export interface Spec extends TurboModule { ): Promise>; revoke(webClientId: string): Promise; logout(webClientId: string): Promise; + disposeClient(clientId: string): Promise; + disposeWebClient(webClientId: string): Promise; } /** @@ -116,21 +118,27 @@ export interface Spec extends TurboModule { * @returns Native module implementation for the current architecture. * @throws Error when no native module is registered. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingOidc'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingOidcClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-oidc] Native module RNPingOidc not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/oidc/src/index.tsx b/packages/oidc/src/index.tsx index d169c68a6..e0d736fa2 100644 --- a/packages/oidc/src/index.tsx +++ b/packages/oidc/src/index.tsx @@ -15,6 +15,7 @@ import type { OidcWebClient, } from './types'; import { OidcError } from './types'; +import { noopLogger } from '@ping-identity/rn-types'; import type { LoggerInstance, Tokens } from '@ping-identity/rn-types'; export { OidcProvider, useOidc } from './useOidc'; export type { @@ -29,15 +30,6 @@ export type { */ const loggerRegistry = new Map(); -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Strip internal token expiry fields before returning tokens to consumers. */ @@ -59,10 +51,6 @@ const sanitizeTokens = ( * @returns OIDC client handle that wraps the native instance. * @throws Error when required configuration is missing or invalid. */ -// TODO(DX): Expose `dispose()` on OidcClient/OidcWebClient to deregister from -// CoreRuntime.oidcClientRegistry / oidcWebClientRegistry. Other client-based -// packages (journey, device-client have dispose()) follow this -// pattern; OIDC is the outlier. Handles currently live until app kill. export function createOidcClient(config: OidcClientConfig): OidcClient { if (!config.discoveryEndpoint && !config.openId) { throw new OidcError( @@ -71,7 +59,6 @@ export function createOidcClient(config: OidcClientConfig): OidcClient { 'state_error', ); } - // TODO(iOS SDK 2.x): enforce full OpenID override requirements to match the native iOS behavior. const jsLogger = config.logger ?? noopLogger; const rawLoggerId = jsLogger.nativeHandle?.id; const loggerId = rawLoggerId?.trim() ? rawLoggerId : undefined; @@ -145,6 +132,15 @@ export function createOidcClient(config: OidcClientConfig): OidcClient { throw OidcError.from(error); } }, + dispose: async () => { + jsLogger.debug('OIDC client dispose requested'); + loggerRegistry.delete(clientId); + try { + await getNativeModule().disposeClient(clientId); + } catch (error) { + jsLogger.warn(`OIDC client dispose failed: ${error}`); + } + }, }; } @@ -277,6 +273,14 @@ export function createOidcWebClient(client: OidcClient): OidcWebClient { loggerInstance.debug('OIDC hasUser false'); return null; }, + dispose: async () => { + loggerInstance.debug('OIDC web client dispose requested'); + try { + await getNativeModule().disposeWebClient(webClientId); + } catch (error) { + loggerInstance.warn(`OIDC web client dispose failed: ${error}`); + } + }, }; } diff --git a/packages/oidc/src/types/oidc.types.ts b/packages/oidc/src/types/oidc.types.ts index 5ec74be00..a619cf1ea 100644 --- a/packages/oidc/src/types/oidc.types.ts +++ b/packages/oidc/src/types/oidc.types.ts @@ -129,7 +129,8 @@ export type OidcAuthorizeResult = } | { type: 'cancel'; - }; + } + | { type: string & {}; [key: string]: unknown }; /** * Error thrown when OIDC operations fail. @@ -162,7 +163,8 @@ export type OidcErrorCode = | 'OIDC_REFRESH_ERROR' | 'OIDC_USERINFO_ERROR' | 'OIDC_REVOKE_ERROR' - | 'OIDC_LOGOUT_ERROR'; + | 'OIDC_LOGOUT_ERROR' + | (string & {}); /** * Native-backed OIDC client handle. @@ -201,6 +203,15 @@ export type OidcClient = { * @returns Whether the end-session flow completed successfully. */ endSession(): Promise; + + /** + * Deregister this client from CoreRuntime registries and release associated resources. + * + * @remarks + * Handles accumulate until app kill if `dispose` is not called. Invoke when the + * client is no longer needed (e.g. on profile switch or component unmount). + */ + dispose(): Promise; }; /** @@ -268,4 +279,12 @@ export type OidcWebClient = { * Resolve the current user handle, if present. */ user(): Promise; + + /** + * Deregister this web client from CoreRuntime registries and release associated resources. + * + * @remarks + * Call when the web client is no longer needed to avoid handle accumulation. + */ + dispose(): Promise; }; diff --git a/packages/oidc/src/useOidc.tsx b/packages/oidc/src/useOidc.tsx index 02b94abdb..8f5767447 100644 --- a/packages/oidc/src/useOidc.tsx +++ b/packages/oidc/src/useOidc.tsx @@ -156,6 +156,7 @@ const missingOidcWebClient: OidcWebClient = { async user() { throw missingOidcClientError; }, + async dispose() {}, }; /** diff --git a/packages/push/LICENSE b/packages/push/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/push/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/push/README.md b/packages/push/README.md index aba1b8bd3..d25e967c9 100644 --- a/packages/push/README.md +++ b/packages/push/README.md @@ -28,13 +28,11 @@ Push MFA for React Native — enrollment, credential management, notification pr ## Installation -> **Note:** This module requires that the `@ping-identity/rn-core` and `@ping-identity/rn-storage` modules are already set up and installed. +> **Note:** This module requires that the `@ping-identity/rn-core` module is already set up and installed. ```bash # Install & setup the core module yarn add @ping-identity/rn-core -# Install the rn-storage module -yarn add @ping-identity/rn-storage # Install the rn-push module yarn add @ping-identity/rn-push # If you are developing your app using iOS, run this command @@ -44,7 +42,8 @@ cd ios && pod install Optional integration packages: ```bash -yarn add @ping-identity/rn-logger +yarn add @ping-identity/rn-logger # JS/native log channel +yarn add @ping-identity/rn-storage # custom push credential storage backend ``` **FCM peer dependency (Android)** — add to your app's `build.gradle`: @@ -331,17 +330,21 @@ the sample app for a working example. ## Error handling -All methods reject with a `PushError` instance, which extends `PingError extends Error`. +All methods reject with a `PushError` instance, which extends `PingError` (from `@ping-identity/rn-types`), which extends `Error`. Use `instanceof` to narrow the type: ```ts import { PushError } from '@ping-identity/rn-push'; +import type { PingError } from '@ping-identity/rn-types'; // base type if needed try { await client.addCredentialFromUri(uri); } catch (err) { if (err instanceof PushError) { - console.log(err.code, err.type, err.message); + console.log(err.code); // PushErrorCode string — use for programmatic handling + console.log(err.type); // error category string from native layer + console.log(err.message); // human-readable description + console.log(err.status); // HTTP status code if applicable, otherwise undefined } } ``` @@ -361,4 +364,4 @@ See `PushErrorCode` in the package types for the full list. ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/push/android/build.gradle b/packages/push/android/build.gradle index cb2c9b3d8..b118ada5d 100644 --- a/packages/push/android/build.gradle +++ b/packages/push/android/build.gradle @@ -84,10 +84,10 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:push:2.0.0") - implementation("com.pingidentity.sdks:commons:2.0.0") - implementation("com.pingidentity.sdks:android:2.0.0") - implementation("com.pingidentity.sdks:logger:2.0.0") + implementation("com.pingidentity.sdks:push:2.0.1") + implementation("com.pingidentity.sdks:commons:2.0.1") + implementation("com.pingidentity.sdks:android:2.0.1") + implementation("com.pingidentity.sdks:logger:2.0.1") // FCM is a peer dependency — host app adds the version via its own BOM implementation("com.google.firebase:firebase-messaging") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" diff --git a/packages/push/package.json b/packages/push/package.json index 800d0cde1..e501f9a3a 100644 --- a/packages/push/package.json +++ b/packages/push/package.json @@ -27,13 +27,11 @@ "prettier": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"" }, "dependencies": { - "@ping-identity/rn-storage": "workspace:*", "@ping-identity/rn-types": "workspace:*" }, "license": "MIT", "peerDependencies": { "@ping-identity/rn-core": "workspace:*", - "@ping-identity/rn-storage": "workspace:*", "react": "*", "react-native": "*" }, diff --git a/packages/push/src/NativeRNPingPush.ts b/packages/push/src/NativeRNPingPush.ts index 0e2c312e8..fc659cb29 100644 --- a/packages/push/src/NativeRNPingPush.ts +++ b/packages/push/src/NativeRNPingPush.ts @@ -237,30 +237,35 @@ export interface Spec extends TurboModule { } /* eslint-enable @typescript-eslint/no-wrapper-object-types */ -// TODO: Cache the resolved module instance — probing on every call is unnecessary since -// the native module does not change at runtime. Apply the same pattern across all modules. /** * Resolves the native module by probing TurboModule first, then falling back to the classic bridge module. + * Result is cached — the native module does not change at runtime. * * @returns The resolved native push module. * @throws Error when the native module is unavailable. */ +let _nativeModule: Spec | null = null; export function getNativeModule(): Spec { + if (_nativeModule) return _nativeModule; + const turbo = TurboModuleRegistry.get('RNPingPush'); if (turbo) { - return turbo; + _nativeModule = turbo; + return _nativeModule; } const classic = NativeModules.RNPingPushClassic as Spec | undefined; if (classic) { - return classic; + _nativeModule = classic; + return _nativeModule; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-push] Native module RNPingPush not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/push/src/push.ts b/packages/push/src/push.ts index 02ab0359f..72f5a9a0c 100644 --- a/packages/push/src/push.ts +++ b/packages/push/src/push.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { LoggerInstance } from '@ping-identity/rn-types'; +import { noopLogger } from '@ping-identity/rn-types'; import { DeviceEventEmitter, Platform } from 'react-native'; import { PushEvents } from './events'; import { @@ -55,16 +55,6 @@ DeviceEventEmitter.addListener( }, ); -// TODO: noopLogger is duplicated across all SDK packages — extract to @ping-identity/rn-types -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Creates a reusable Push MFA client instance. * @@ -316,10 +306,6 @@ export async function createPushClient( async saveCredential(credential: PushCredential): Promise { logger.debug('Push saveCredential requested'); try { - // TODO-REVISIT: sharedSecret excluded from round-trip — the JS credential - // object never carries sharedSecret (native-only field). The native side - // must look up the credential by id and merge JS-editable fields rather - // than accepting a full credential object. See requirements open question §2. const result = await getNativeModule().saveCredential( clientId, credential as unknown as object, diff --git a/packages/push/src/types/config.types.ts b/packages/push/src/types/config.types.ts index fe7bd4896..725b04a99 100644 --- a/packages/push/src/types/config.types.ts +++ b/packages/push/src/types/config.types.ts @@ -5,8 +5,10 @@ * of the MIT license. See the LICENSE file for details. */ -import type { LoggerInstance } from '@ping-identity/rn-types'; -import type { PushStorage } from '@ping-identity/rn-storage'; +import type { + LoggerInstance, + PushStorageHandle, +} from '@ping-identity/rn-types'; import type { PushNotificationCleanupConfig } from './notification.types'; /** @@ -41,7 +43,7 @@ export type PushConfig = { * Obtained from `configurePushStorage()` in `@ping-identity/rn-storage`. * When omitted, the native default SQLite/Keychain storage is used. */ - storage?: PushStorage; + storage?: PushStorageHandle; /** * Optional notification cleanup configuration controlling when stored * notifications are purged from the local database. diff --git a/packages/storage/LICENSE b/packages/storage/LICENSE new file mode 100644 index 000000000..9b2d5b354 --- /dev/null +++ b/packages/storage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ping Identity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/storage/README.md b/packages/storage/README.md index 993614c7b..f7cca7762 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -16,7 +16,12 @@ storage solutions for the Ping SDKs, serving React Native applications. - [Integrating the SDK into your project](#integrating-the-sdk-into-your-project) - [How to use the SDK](#how-to-use-the-sdk) -- [Error handling](#error-model) + - [Session and OIDC helpers](#session-and-oidc-helpers) + - [Binding user key storage](#binding-user-key-storage) + - [Push storage](#push-storage) + - [OATH storage](#oath-storage) + - [Journey module usage](#journey-module-usage) +- [Error handling](#error-handling) - [License](#license) ## Integrating the SDK into your project @@ -93,8 +98,7 @@ Notes: and `ios.encryptor` (true uses an Encryptor, false uses NoEncryptor). - `android.cacheStrategy` controls how the SDK caches data when native storage is unavailable. -- `StorageLoggerOptions` / `loggerId` are currently bridge-only for storage registration. - Native storage logger application is planned and tracked as a TODO in both iOS and Android implementations. +- `StorageLoggerOptions` is optional. The `logger` field provides JS-side logging only — storage registration is a synchronous in-memory operation with no native log output. ### StorageConfig type @@ -118,6 +122,81 @@ const oidcStorage: OidcStorage = configureOidcStorage(oidcCfg); // createOidcClient({ storage: oidcStorage, ... }); ``` +### Binding user key storage + +Pass to `createBindingClient({ userKeyStorage })` to override the default key store: + +```ts +import { configureBindingUserKeyStorage } from '@ping-identity/rn-storage'; +import { createBindingClient } from '@ping-identity/rn-binding'; + +const userKeyStorage = configureBindingUserKeyStorage({ + android: { + keyAlias: 'binding.user.key', + fileName: 'binding_user_keys', + }, + ios: { + account: 'com.example.app.binding', + encryptor: true, + }, +}); + +const bindingClient = createBindingClient({ userKeyStorage }); +``` + +### Push storage + +Pass to `createPushClient({ storage })` to override the default push credential store: + +```ts +import { configurePushStorage } from '@ping-identity/rn-storage'; +import { createPushClient } from '@ping-identity/rn-push'; + +const pushStorage = configurePushStorage({ + android: { + keyAlias: 'push_key', + fileName: 'push_credentials', + }, +}); + +const pushClient = createPushClient({ storage: pushStorage }); +``` + +### OATH storage + +Pass to `createOathClient({ storage })` to override the default OATH credential store. +iOS supports additional OATH-specific keychain options via `iosOath`: + +```ts +import { configureOathStorage } from '@ping-identity/rn-storage'; +import { createOathClient } from '@ping-identity/rn-oath'; + +const oathStorage = configureOathStorage({ + android: { + fileName: 'oath_credentials.db', + }, + iosOath: { + service: 'com.example.app.oath', + requireBiometrics: true, + requireDevicePasscode: false, + biometricPrompt: 'Authenticate to access OATH credentials', + accessGroup: 'com.example.shared', + }, +}); + +const client = await createOathClient({ storage: oathStorage }); +``` + +`iosOath` options: + +| Option | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------- | +| `service` | `string` | Keychain service identifier | +| `requireBiometrics` | `boolean` | Require biometric authentication to access stored credentials | +| `requireDevicePasscode` | `boolean` | Require device passcode as a fallback | +| `biometricPrompt` | `string` | Prompt string shown during biometric authentication | +| `accessGroup` | `string` | Keychain access group for sharing across app extensions | + ### Journey module usage Pass the storage handle returned by `configureSessionStorage` or @@ -188,4 +267,4 @@ try { ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details diff --git a/packages/storage/android/build.gradle b/packages/storage/android/build.gradle index 3279f51c0..8fd676df8 100644 --- a/packages/storage/android/build.gradle +++ b/packages/storage/android/build.gradle @@ -87,7 +87,7 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.datastore:datastore-core:1.1.7' - implementation("com.pingidentity.sdks:storage:2.0.0") + implementation("com.pingidentity.sdks:storage:2.0.1") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation(project(":ping-identity_rn-core")) diff --git a/packages/storage/android/src/main/java/com/pingidentity/rnstorage/RNPingStorageCommon.kt b/packages/storage/android/src/main/java/com/pingidentity/rnstorage/RNPingStorageCommon.kt index 195e79acb..5533d066c 100644 --- a/packages/storage/android/src/main/java/com/pingidentity/rnstorage/RNPingStorageCommon.kt +++ b/packages/storage/android/src/main/java/com/pingidentity/rnstorage/RNPingStorageCommon.kt @@ -46,7 +46,6 @@ object RNPingStorageCommon { @JvmStatic fun registerSessionStorage(config: ReadableMap): String { val map = config.toHashMap() - // TODO: Resolve and apply native logger from `loggerId` once storage logger wiring is implemented. val storageConfig = buildStorageConfig(map) return sessionConfigRegistry.register(storageConfig) } @@ -64,7 +63,6 @@ object RNPingStorageCommon { @JvmStatic fun registerOidcStorage(config: ReadableMap): String { val map = config.toHashMap() - // TODO: Resolve and apply native logger from `loggerId` once storage logger wiring is implemented. val storageConfig = buildStorageConfig(map) return oidcConfigRegistry.register(storageConfig) } diff --git a/packages/storage/ios/RNPingStorageCommon.swift b/packages/storage/ios/RNPingStorageCommon.swift index 735642ca3..7f7c46747 100644 --- a/packages/storage/ios/RNPingStorageCommon.swift +++ b/packages/storage/ios/RNPingStorageCommon.swift @@ -125,7 +125,6 @@ public class RNPingStorageCommon: NSObject { @objc public static func registerSessionStorage(_ config: NSDictionary) -> String { return createQueue.sync { - // TODO: Resolve and apply native logger from `loggerId` once storage logger wiring is implemented. registerConfig(config, registry: CoreRuntime.sessionStorageConfigRegistry) } } @@ -137,7 +136,6 @@ public class RNPingStorageCommon: NSObject { @objc public static func registerOidcStorage(_ config: NSDictionary) -> String { return createQueue.sync { - // TODO: Resolve and apply native logger from `loggerId` once storage logger wiring is implemented. registerConfig(config, registry: CoreRuntime.oidcStorageConfigRegistry) } } diff --git a/packages/storage/src/NativeRNPingStorage.ts b/packages/storage/src/NativeRNPingStorage.ts index e416c851c..7317e6357 100644 --- a/packages/storage/src/NativeRNPingStorage.ts +++ b/packages/storage/src/NativeRNPingStorage.ts @@ -178,7 +178,7 @@ export type BaseStorageConfig = { * * **Platform:** iOS only * - * @defaultValue 'com.pingidentity.rnsampleapp.keyalias' + * @defaultValue `'com.pingidentity.rnstorage.storage'` */ account?: string; @@ -460,11 +460,12 @@ export function getNativeModule(): Spec { return classic; } + const availableModules = + '\nAvailable NativeModules: ' + JSON.stringify(Object.keys(NativeModules)); throw new Error( '[@ping-identity/rn-storage] Native module RNPingStorage not found.\n' + - 'Ensure the library is linked correctly and the app has been rebuilt.\n' + - 'Available NativeModules: ' + - JSON.stringify(Object.keys(NativeModules)), + 'Ensure the library is linked correctly and the app has been rebuilt.' + + availableModules, ); } diff --git a/packages/storage/src/index.tsx b/packages/storage/src/index.tsx index 70d279f3a..97c7529c3 100644 --- a/packages/storage/src/index.tsx +++ b/packages/storage/src/index.tsx @@ -11,6 +11,7 @@ import type { NativeStorageConfig, } from './NativeRNPingStorage'; import { CacheStrategy, StorageError } from './types'; +import { noopLogger } from '@ping-identity/rn-types'; import type { LoggerInstance, OathStorageHandle, @@ -35,18 +36,6 @@ export type { StorageLoggerOptions, } from './types'; -/** - * No-op logger used when callers do not provide one. - */ -const noopLogger: LoggerInstance = { - nativeHandle: { id: '' }, - changeLevel: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, -}; - /** * Resolve JS logger instance and native logger identifier for bridge calls. */ @@ -354,7 +343,7 @@ function createOathStorageHandle( * * @param config - Storage configuration parameters with platform-specific options * @returns A branded SessionStorage handle with native storage id metadata - * @throws {Error} If the configuration is missing or invalid + * @throws {StorageError} If the configuration is missing or invalid * * @example * ```typescript @@ -369,7 +358,6 @@ function createOathStorageHandle( * // Pass to Journey SDK * // initJourney({ sessionStorage, ... }); * ``` - * TODO: Analyze implications of turning storage operations async to better handle errors from native bridge calls. */ export function configureSessionStorage( config: StorageConfig, @@ -404,7 +392,7 @@ export function configureSessionStorage( * * @param config - Storage configuration parameters with platform-specific options * @returns A branded OidcStorage handle with native storage id metadata - * @throws {Error} If the configuration is missing or invalid + * @throws {StorageError} If the configuration is missing or invalid * * @example * ```typescript @@ -423,7 +411,6 @@ export function configureSessionStorage( * // Pass to OIDC configuration * // configureOidc({ storage: oidcStorage, ... }); * ``` - * TODO: Analyze implications of turning storage operations async to better handle errors from native bridge calls. */ export function configureOidcStorage( config: StorageConfig, @@ -453,7 +440,7 @@ export function configureOidcStorage( * @param config - Storage configuration parameters with platform-specific options * @param options - Optional logger configuration * @returns A branded BindingUserKeyStorage handle with native storage id metadata - * @throws {Error} If the configuration is missing or invalid + * @throws {StorageError} If the configuration is missing or invalid */ export function configureBindingUserKeyStorage( config: StorageConfig, @@ -510,7 +497,7 @@ function createPushStorageHandle( * @param config - Storage configuration parameters with platform-specific options * @param options - Optional logger configuration * @returns A branded PushStorage handle with native storage id metadata - * @throws {Error} If the configuration is missing or invalid + * @throws {StorageError} If the configuration is missing or invalid * * @example * ```typescript @@ -525,7 +512,6 @@ function createPushStorageHandle( * // Pass to push client * // createPushClient({ storage: pushStorage }); * ``` - * TODO: Analyze implications of turning storage operations async to better handle errors from native bridge calls. */ export function configurePushStorage( config: StorageConfig, @@ -545,7 +531,7 @@ export function configurePushStorage( return createPushStorageHandle(storageId, normalizeStorageConfig(result)); } catch (error) { logger.error('Storage configurePushStorage failed'); - throw error; + throw StorageError.from(error); } } @@ -577,7 +563,6 @@ export function configurePushStorage( * * const client = await createOathClient({ storage: oathStorage }); * ``` - * TODO: Analyze implications of turning storage operations async to better handle errors from native bridge calls. */ export function configureOathStorage( config: StorageConfig, @@ -613,6 +598,6 @@ export function configureOathStorage( return createOathStorageHandle(storageId, normalizeStorageConfig(result)); } catch (error) { logger.error('Storage configureOathStorage failed'); - throw error; + throw StorageError.from(error); } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d67ba5712..e34cd15ff 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,6 +14,7 @@ export * from '@forgerock/sdk-types'; import type { GenericError } from '@forgerock/sdk-types'; +import type { LoggerInstance } from './handles.types'; /** * Explicit exports for core auth flow shapes used across modules. @@ -102,11 +103,21 @@ export const nativeExtensionCallbackType = { export type NativeExtensionCallbackType = (typeof nativeExtensionCallbackType)[keyof typeof nativeExtensionCallbackType]; +import { callbackType } from '@forgerock/sdk-types'; + /** - * TODO(DX): Expose a single Journey callback constant source that merges - * ForgeRock `callbackType` and `nativeExtensionCallbackType` so consumers do - * not need to import from two separate constant maps. + * All Journey callback type strings — ForgeRock standard callbacks and + * Ping native-extension callbacks merged into a single constant map. + * + * @remarks + * Use this instead of importing `callbackType` and `nativeExtensionCallbackType` + * separately. Consumers can also import `callbackType` and + * `nativeExtensionCallbackType` individually if narrower imports are preferred. */ +export const journeyCallbackType = { + ...callbackType, + ...nativeExtensionCallbackType, +} as const; /** * Shared OIDC base configuration contracts used across RN modules. @@ -133,6 +144,19 @@ export type JourneyInstance = { getId: () => Promise; }; +/** + * No-op logger that satisfies the {@link LoggerInstance} contract without + * emitting anything. Used as the default when no logger is provided. + */ +export const noopLogger: LoggerInstance = { + nativeHandle: { id: '' }, + changeLevel: () => {}, + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, +}; + /** * Typed error class for all Ping SDK public API rejections. * diff --git a/yarn.lock b/yarn.lock index ab7891456..ae5c9e882 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3229,14 +3229,12 @@ __metadata: version: 0.0.0-use.local resolution: "@ping-identity/rn-push@workspace:packages/push" dependencies: - "@ping-identity/rn-storage": "workspace:*" "@ping-identity/rn-types": "workspace:*" jest: "npm:^30.2.0" react-native-builder-bob: "npm:^0.40.14" typescript: "npm:^5.9.2" peerDependencies: "@ping-identity/rn-core": "workspace:*" - "@ping-identity/rn-storage": "workspace:*" react: "*" react-native: "*" languageName: unknown