diff --git a/.changeset/experimental-trusted-device-resources.md b/.changeset/experimental-trusted-device-resources.md new file mode 100644 index 00000000000..67de7eb89a4 --- /dev/null +++ b/.changeset/experimental-trusted-device-resources.md @@ -0,0 +1,12 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add experimental support for trusted device (biometric credential) resources in native apps: + +- `signIn.create({ strategy: 'trusted_device', trustedDeviceId })` and `signIn.attemptFirstFactor({ strategy: 'trusted_device', trustedDeviceId, clientData, signature, algorithm: 'ES256' })` are now typed and supported. The challenge to sign is exposed on `signIn.firstFactorVerification.trustedDeviceChallenge`. +- `User` gains `__experimental_getBiometricCredentials()`, `__experimental_prepareBiometricCredential()`, `__experimental_attemptBiometricCredential()` and `__experimental_revokeBiometricCredential()` for managing a user's enrolled biometric credentials. +- The environment's auth config now exposes `nativeSettings`, indicating whether trusted device sign-in and enrollment prompts are enabled. + +These APIs are experimental and may change in future minor releases. diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md new file mode 100644 index 00000000000..6c3fd2fc364 --- /dev/null +++ b/.changeset/expo-native-components-moved.md @@ -0,0 +1,25 @@ +--- +'@clerk/expo': minor +--- + +The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native-components` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17. + +If you use the native components, install the new package: + +```sh +npx expo install @clerk/expo-native-components +``` + +add its config plugin alongside `@clerk/expo` in your app config: + +```json +{ + "expo": { + "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + } +} +``` + +then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native-components`, which you can also import from directly. Without `@clerk/expo-native-components` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it. + +The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native-components` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native-components` is installed, and warns otherwise. diff --git a/.changeset/expo-native-single-token-sync-bridge.md b/.changeset/expo-native-single-token-sync-bridge.md new file mode 100644 index 00000000000..0163480dc37 --- /dev/null +++ b/.changeset/expo-native-single-token-sync-bridge.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': minor +--- + +Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it. diff --git a/.changeset/expo-native-sync-behavior-tests.md b/.changeset/expo-native-sync-behavior-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-native-sync-behavior-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/introduce-expo-native.md b/.changeset/introduce-expo-native.md new file mode 100644 index 00000000000..a7a0839fb61 --- /dev/null +++ b/.changeset/introduce-expo-native.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': minor +--- + +Add `@clerk/expo-native-components`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options. diff --git a/.changeset/remove-legacy-expo-native-sync.md b/.changeset/remove-legacy-expo-native-sync.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/remove-legacy-expo-native-sync.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/simplify-expo-native-client-sync.md b/.changeset/simplify-expo-native-client-sync.md new file mode 100644 index 00000000000..bb85e34b5db --- /dev/null +++ b/.changeset/simplify-expo-native-client-sync.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Rewrite the sync between the Clerk JS client and the native Clerk SDK. Both now share a single device token stored by the native SDK, and startup makes fewer requests. This fixes a stale token in the app's `tokenCache` signing out a session that was signed in natively, and the two SDKs ending up on different tokens when both rotated the device token at the same time. Requires the matching native module that ships with the next native SDK versions. diff --git a/.claude/skills/clerk-monorepo/references/package-map.md b/.claude/skills/clerk-monorepo/references/package-map.md index c5cabf3765b..47abcecfa41 100644 --- a/.claude/skills/clerk-monorepo/references/package-map.md +++ b/.claude/skills/clerk-monorepo/references/package-map.md @@ -16,32 +16,33 @@ browser directly), **adapter** (framework SDK), **ui/i18n**, **tooling**. The "B two packages whose runtime is pushed into apps pinned to older SDKs, so they carry the strict backwards-compatibility contract (see `breaking-changes.md`). -| Package | Category | BC | Purpose | -| ----------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. | -| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. | -| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. | -| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. | -| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. | -| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. | -| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. | -| `@clerk/express` | adapter | | Express middleware and server helpers. | -| `@clerk/fastify` | adapter | | Fastify plugin. | -| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). | -| `@clerk/astro` | adapter | | Astro integration (components + server utilities). | -| `@clerk/nuxt` | adapter | | Nuxt module (Vue). | -| `@clerk/vue` | adapter | | Vue 3 composables and components. | -| `@clerk/react-router` | adapter | | React Router v7 SDK. | -| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. | -| `@clerk/expo` | adapter | | React Native / Expo SDK. | -| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. | -| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. | -| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. | -| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). | -| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). | -| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). | -| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. | -| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. | +| Package | Category | BC | Purpose | +| ------------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. | +| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. | +| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. | +| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. | +| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. | +| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. | +| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. | +| `@clerk/express` | adapter | | Express middleware and server helpers. | +| `@clerk/fastify` | adapter | | Fastify plugin. | +| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). | +| `@clerk/astro` | adapter | | Astro integration (components + server utilities). | +| `@clerk/nuxt` | adapter | | Nuxt module (Vue). | +| `@clerk/vue` | adapter | | Vue 3 composables and components. | +| `@clerk/react-router` | adapter | | React Router v7 SDK. | +| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. | +| `@clerk/expo` | adapter | | React Native / Expo SDK. | +| `@clerk/expo-native-components` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. | +| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. | +| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. | +| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. | +| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). | +| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). | +| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). | +| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. | +| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. | Tests: `pnpm turbo test --filter=@clerk/` (or `pnpm --filter @clerk/ test` after a build). Most packages use vitest; `@clerk/backend` runs a multi-runtime suite (node + edge + diff --git a/.github/labeler.yml b/.github/labeler.yml index b6f18eeb545..d07fbb87c3f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -28,6 +28,10 @@ expo: - changed-files: - any-glob-to-any-file: packages/expo/** +expo-native-components: + - changed-files: + - any-glob-to-any-file: packages/expo-native-components/** + express: - changed-files: - any-glob-to-any-file: packages/express/** diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml index 25971a1ad1b..9feadb28f55 100644 --- a/.github/workflows/api-changes.yml +++ b/.github/workflows/api-changes.yml @@ -19,6 +19,7 @@ on: - 'packages/clerk-js/**' - 'packages/expo/**' - 'packages/expo-google-signin/**' + - 'packages/expo-native-components/**' - 'packages/expo-passkeys/**' - 'packages/express/**' - 'packages/fastify/**' @@ -56,6 +57,7 @@ env: --filter=@clerk/clerk-js --filter=@clerk/expo --filter=@clerk/expo-google-signin + --filter=@clerk/expo-native-components --filter=@clerk/expo-passkeys --filter=@clerk/express --filter=@clerk/fastify diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index bf96461fe88..cd69db5c340 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -11,6 +11,7 @@ on: - 'integration/tests/expo-native/**' - 'packages/expo/**' - 'packages/expo-google-signin/**' + - 'packages/expo-native-components/**' workflow_dispatch: permissions: @@ -85,6 +86,7 @@ jobs: packages/clerk-js \ packages/expo \ packages/expo-google-signin \ + packages/expo-native-components \ packages/react \ packages/shared \ "$FIXTURE_DIR" | @@ -125,6 +127,7 @@ jobs: mkdir -p "$SDK_PACK_DIR" pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" + pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies if: steps.native-build-cache.outputs.cache-hit != 'true' @@ -135,10 +138,11 @@ jobs: run: | cp "package.sdk-$EXPO_SDK.json" package.json pnpm install --no-frozen-lockfile - # [0-9] keeps this glob off the clerk-expo-google-signin tarball. + # [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs. SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)" GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" - pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w + NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)" + pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w # expo-dev-client makes even release builds boot into the dev # launcher (unreachable Metro in CI), which stalls every Maestro # flow on a blank screen. Skip it on e2e jobs only. diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json index 58e258ea523..4ea4b82bb8f 100644 --- a/integration/templates/expo-native/app.json +++ b/integration/templates/expo-native/app.json @@ -12,6 +12,6 @@ "android": { "package": "com.clerk.exponativebuildfixture" }, - "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"] + "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-native", "expo-web-browser"] } } diff --git a/package.json b/package.json index 5884e61374c..3f4c74549d6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "changeset": "changeset", "changeset:empty": "pnpm changeset --empty", "clean": "turbo run clean", - "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension", + "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native-components --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension", "dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared", "dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js", "dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve", diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a421c0f931c..1bbefe91488 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "80KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "82KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts index 3bfc61dbacf..c190ddacd77 100644 --- a/packages/clerk-js/src/core/resources/AuthConfig.ts +++ b/packages/clerk-js/src/core/resources/AuthConfig.ts @@ -1,4 +1,11 @@ -import type { AuthConfigJSON, AuthConfigJSONSnapshot, AuthConfigResource, PhoneCodeChannel } from '@clerk/shared/types'; +import type { + AuthConfigJSON, + AuthConfigJSONSnapshot, + AuthConfigResource, + NativeSettingsJSON, + NativeSettingsResource, + PhoneCodeChannel, +} from '@clerk/shared/types'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource } from './internal'; @@ -9,6 +16,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { singleSessionMode: boolean = false; preferredChannels: Record | null = null; sessionMinter: boolean = false; + nativeSettings: NativeSettingsResource | null = null; public constructor(data: Partial | null = null) { super(); @@ -25,6 +33,9 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode); this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels); this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter); + if (data.native_settings !== undefined) { + this.nativeSettings = nativeSettingsFromJSON(data.native_settings); + } return this; } @@ -36,6 +47,32 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { reverification: this.reverification, single_session_mode: this.singleSessionMode, session_minter: this.sessionMinter, + native_settings: nativeSettingsToJSON(this.nativeSettings), }; } } + +function nativeSettingsFromJSON(data: NativeSettingsJSON | null): NativeSettingsResource | null { + if (!data) { + return null; + } + return { + apiEnabled: !!data.api_enabled, + trustedDeviceSignInEnabled: !!data.trusted_device_sign_in_enabled, + trustedDeviceEnrollmentPromptAfterSignInEnabled: !!data.trusted_device_enrollment_prompt_after_sign_in_enabled, + trustedDeviceEnrollmentPromptAfterSignUpEnabled: !!data.trusted_device_enrollment_prompt_after_sign_up_enabled, + }; +} + +function nativeSettingsToJSON(settings: NativeSettingsResource | null): NativeSettingsJSON | null { + if (!settings) { + return null; + } + return { + object: 'native_settings', + api_enabled: settings.apiEnabled, + trusted_device_sign_in_enabled: settings.trustedDeviceSignInEnabled, + trusted_device_enrollment_prompt_after_sign_in_enabled: settings.trustedDeviceEnrollmentPromptAfterSignInEnabled, + trusted_device_enrollment_prompt_after_sign_up_enabled: settings.trustedDeviceEnrollmentPromptAfterSignUpEnabled, + }; +} diff --git a/packages/clerk-js/src/core/resources/BiometricCredential.ts b/packages/clerk-js/src/core/resources/BiometricCredential.ts new file mode 100644 index 00000000000..6357c79fe7d --- /dev/null +++ b/packages/clerk-js/src/core/resources/BiometricCredential.ts @@ -0,0 +1,117 @@ +import type { + AttemptBiometricCredentialParams, + BiometricCredentialJSON, + BiometricCredentialJSONSnapshot, + BiometricCredentialPlatform, + BiometricCredentialResource, + BiometricCredentialStatus, + PrepareBiometricCredentialParams, + TrustedDeviceAlgorithm, + TrustedDeviceChallengeJSON, + TrustedDeviceChallengeResource, +} from '@clerk/shared/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './internal'; +import { trustedDeviceChallengeFromJSON } from './TrustedDeviceChallenge'; + +const PATH_ROOT = '/me/biometric_credentials'; + +function toEnrollmentBody(params: PrepareBiometricCredentialParams | AttemptBiometricCredentialParams) { + const { publicKeyJwk, ...rest } = params; + return { + ...rest, + publicKeyJwk: typeof publicKeyJwk === 'string' ? publicKeyJwk : JSON.stringify(publicKeyJwk), + }; +} + +export class BiometricCredential extends BaseResource implements BiometricCredentialResource { + id!: string; + pathRoot = PATH_ROOT; + platform!: BiometricCredentialPlatform; + appIdentifier!: string; + name: string | null = null; + algorithm!: TrustedDeviceAlgorithm; + status!: BiometricCredentialStatus; + lastUsedAt: Date | null = null; + revokedAt: Date | null = null; + createdAt!: Date; + updatedAt!: Date; + + public constructor(data: BiometricCredentialJSON | BiometricCredentialJSONSnapshot) { + super(); + this.fromJSON(data); + } + + static async list(): Promise { + const json = (await BaseResource._fetch({ path: PATH_ROOT, method: 'GET' })) + ?.response as unknown as BiometricCredentialJSON[]; + return (json || []).map(credential => new BiometricCredential(credential)); + } + + static async prepare(params: PrepareBiometricCredentialParams): Promise { + const json = ( + await BaseResource._fetch({ + path: `${PATH_ROOT}/prepare`, + method: 'POST', + body: toEnrollmentBody(params) as any, + }) + )?.response as unknown as TrustedDeviceChallengeJSON; + return trustedDeviceChallengeFromJSON(json) as TrustedDeviceChallengeResource; + } + + static async attempt(params: AttemptBiometricCredentialParams): Promise { + const json = ( + await BaseResource._fetch({ + path: `${PATH_ROOT}/attempt`, + method: 'POST', + body: toEnrollmentBody(params) as any, + }) + )?.response as unknown as BiometricCredentialJSON; + return new BiometricCredential(json); + } + + static async revoke(biometricCredentialId: string): Promise { + const json = ( + await BaseResource._fetch({ + path: `${PATH_ROOT}/${biometricCredentialId}`, + method: 'DELETE', + }) + )?.response as unknown as BiometricCredentialJSON; + return new BiometricCredential(json); + } + + protected fromJSON(data: BiometricCredentialJSON | BiometricCredentialJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.platform = data.platform; + this.appIdentifier = data.app_identifier; + this.name = data.name ?? null; + this.algorithm = data.algorithm; + this.status = data.status; + this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null; + this.revokedAt = data.revoked_at ? unixEpochToDate(data.revoked_at) : null; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + return this; + } + + public __internal_toSnapshot(): BiometricCredentialJSONSnapshot { + return { + object: 'trusted_device', + id: this.id, + platform: this.platform, + app_identifier: this.appIdentifier, + name: this.name, + algorithm: this.algorithm, + status: this.status, + last_used_at: this.lastUsedAt?.getTime() ?? null, + revoked_at: this.revokedAt?.getTime() ?? null, + created_at: this.createdAt.getTime(), + updated_at: this.updatedAt.getTime(), + }; + } +} diff --git a/packages/clerk-js/src/core/resources/TrustedDeviceChallenge.ts b/packages/clerk-js/src/core/resources/TrustedDeviceChallenge.ts new file mode 100644 index 00000000000..e88c298cfef --- /dev/null +++ b/packages/clerk-js/src/core/resources/TrustedDeviceChallenge.ts @@ -0,0 +1,35 @@ +import type { TrustedDeviceChallengeJSON, TrustedDeviceChallengeResource } from '@clerk/shared/types'; + +export function trustedDeviceChallengeFromJSON( + data: TrustedDeviceChallengeJSON | null | undefined, +): TrustedDeviceChallengeResource | null { + if (!data) { + return null; + } + return { + challenge: data.challenge, + challengeId: data.challenge_id, + trustedDeviceId: data.trusted_device_id ?? null, + clientData: data.client_data, + // FAPI returns this timestamp in seconds, unlike most resource timestamps. + expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null, + algorithm: data.algorithm, + }; +} + +export function trustedDeviceChallengeToJSON( + challenge: TrustedDeviceChallengeResource | null, +): TrustedDeviceChallengeJSON | null { + if (!challenge) { + return null; + } + return { + object: 'trusted_device_challenge', + challenge: challenge.challenge, + challenge_id: challenge.challengeId, + ...(challenge.trustedDeviceId ? { trusted_device_id: challenge.trustedDeviceId } : {}), + client_data: challenge.clientData, + expires_at: challenge.expiresAt ? Math.floor(challenge.expiresAt.getTime() / 1000) : 0, + algorithm: challenge.algorithm, + }; +} diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 77af8b62a29..a57e8a6249e 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -1,8 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys'; import type { + AttemptBiometricCredentialParams, BackupCodeJSON, BackupCodeResource, + BiometricCredentialResource, CreateEmailAddressParams, CreateExternalAccountParams, CreatePhoneNumberParams, @@ -23,10 +25,12 @@ import type { OrganizationMembershipResource, PasskeyResource, PhoneNumberResource, + PrepareBiometricCredentialParams, RemoveUserPasswordParams, SetProfileImageParams, TOTPJSON, TOTPResource, + TrustedDeviceChallengeResource, UpdateUserMetadataParams, UpdateUserParams, UpdateUserPasswordParams, @@ -45,6 +49,7 @@ import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '.. import { BackupCode } from './BackupCode'; import { BaseResource, + BiometricCredential, DeletedObject, EmailAddress, EnterpriseAccount, @@ -229,6 +234,26 @@ export class User extends BaseResource implements UserResource { return new BackupCode(json); }; + __experimental_getBiometricCredentials = (): Promise => { + return BiometricCredential.list(); + }; + + __experimental_prepareBiometricCredential = ( + params: PrepareBiometricCredentialParams, + ): Promise => { + return BiometricCredential.prepare(params); + }; + + __experimental_attemptBiometricCredential = ( + params: AttemptBiometricCredentialParams, + ): Promise => { + return BiometricCredential.attempt(params); + }; + + __experimental_revokeBiometricCredential = (biometricCredentialId: string): Promise => { + return BiometricCredential.revoke(biometricCredentialId); + }; + update = async (params: UpdateUserParams): Promise => { const { unsafeMetadata, ...rest } = params; const hasMetadata = unsafeMetadata !== undefined; diff --git a/packages/clerk-js/src/core/resources/Verification.ts b/packages/clerk-js/src/core/resources/Verification.ts index af1f61a4f88..08ca02b52b9 100644 --- a/packages/clerk-js/src/core/resources/Verification.ts +++ b/packages/clerk-js/src/core/resources/Verification.ts @@ -11,6 +11,7 @@ import type { SignUpVerificationsJSON, SignUpVerificationsJSONSnapshot, SignUpVerificationsResource, + TrustedDeviceChallengeResource, VerificationJSON, VerificationJSONSnapshot, VerificationResource, @@ -19,6 +20,7 @@ import type { import { unixEpochToDate } from '../../utils/date'; import { BaseResource } from './internal'; +import { trustedDeviceChallengeFromJSON, trustedDeviceChallengeToJSON } from './TrustedDeviceChallenge'; export class Verification extends BaseResource implements VerificationResource { pathRoot = ''; @@ -33,6 +35,7 @@ export class Verification extends BaseResource implements VerificationResource { error: ClerkAPIError | null = null; verifiedAtClient: string | null = null; channel?: PhoneCodeChannel; + trustedDeviceChallenge: TrustedDeviceChallengeResource | null = null; constructor(data: VerificationJSON | VerificationJSONSnapshot | null) { super(); @@ -59,6 +62,7 @@ export class Verification extends BaseResource implements VerificationResource { this.expireAt = unixEpochToDate(data.expire_at || undefined); this.error = data.error ? new ClerkAPIError(data.error) : null; this.channel = data.channel || undefined; + this.trustedDeviceChallenge = trustedDeviceChallengeFromJSON(data.trusted_device_challenge); } return this; } @@ -76,6 +80,9 @@ export class Verification extends BaseResource implements VerificationResource { expire_at: this.expireAt?.getTime() || null, error: errorToJSON(this.error), verified_at_client: this.verifiedAtClient, + ...(this.trustedDeviceChallenge && { + trusted_device_challenge: trustedDeviceChallengeToJSON(this.trustedDeviceChallenge), + }), }; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts index 3d16c8d430b..a83982a219d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts @@ -14,6 +14,7 @@ describe('AuthConfig', () => { expect(authConfig.claimedAt).toBeNull(); expect(authConfig.reverification).toBe(false); expect(authConfig.singleSessionMode).toBe(false); + expect(authConfig.nativeSettings).toBeNull(); }); it('initializes with provided values', () => { @@ -47,6 +48,43 @@ describe('AuthConfig', () => { reverification: true, single_session_mode: true, session_minter: false, + native_settings: null, + }); + }); + + describe('native_settings', () => { + const nativeSettingsJSON = { + object: 'native_settings' as const, + api_enabled: true, + trusted_device_sign_in_enabled: true, + trusted_device_enrollment_prompt_after_sign_in_enabled: false, + trusted_device_enrollment_prompt_after_sign_up_enabled: true, + }; + + it('parses native settings', () => { + const authConfig = new AuthConfig({ native_settings: nativeSettingsJSON }); + + expect(authConfig.nativeSettings).toEqual({ + apiEnabled: true, + trustedDeviceSignInEnabled: true, + trustedDeviceEnrollmentPromptAfterSignInEnabled: false, + trustedDeviceEnrollmentPromptAfterSignUpEnabled: true, + }); + }); + + it('round-trips native settings through the snapshot', () => { + const snapshot = new AuthConfig({ native_settings: nativeSettingsJSON }).__internal_toSnapshot(); + + expect(snapshot.native_settings).toEqual(nativeSettingsJSON); + expect(new AuthConfig(snapshot).nativeSettings).toEqual( + new AuthConfig({ native_settings: nativeSettingsJSON }).nativeSettings, + ); + }); + + it('treats null native settings as null', () => { + const authConfig = new AuthConfig({ native_settings: null }); + + expect(authConfig.nativeSettings).toBeNull(); }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/BiometricCredential.test.ts b/packages/clerk-js/src/core/resources/__tests__/BiometricCredential.test.ts new file mode 100644 index 00000000000..94f0c96aee0 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/BiometricCredential.test.ts @@ -0,0 +1,153 @@ +import { stringifyQueryParams } from '@clerk/shared/internal/clerk-js/querystring'; +import type { BiometricCredentialJSON, UserJSON } from '@clerk/shared/types'; +import { camelToSnake } from '@clerk/shared/underscore'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BiometricCredential } from '../BiometricCredential'; +import { BaseResource } from '../internal'; +import { User } from '../User'; + +const credentialJSON: BiometricCredentialJSON = { + object: 'trusted_device', + id: 'td_123', + platform: 'ios', + app_identifier: 'com.example.app', + name: 'iPhone', + algorithm: 'ES256', + status: 'active', + created_at: 1_700_000_000_000, + updated_at: 1_700_000_100_000, + last_used_at: 1_700_000_200_000, + revoked_at: null, +}; + +const publicKeyJwk = { kty: 'EC', crv: 'P-256', x: 'x_value', y: 'y_value' } as const; + +const enrollmentParams = { + platform: 'ios', + appIdentifier: 'com.example.app', + name: 'iPhone', + algorithm: 'ES256', + publicKeyJwk, +} as const; + +const createUser = () => + new User({ + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + } as unknown as UserJSON); + +const mockFetch = (response: unknown) => { + const fetch = vi.fn().mockResolvedValue({ response }); + BaseResource._fetch = fetch; + return fetch; +}; + +describe('BiometricCredential', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('parses the credential JSON', () => { + const credential = new BiometricCredential(credentialJSON); + + expect(credential.id).toBe('td_123'); + expect(credential.pathRoot).toBe('/me/biometric_credentials'); + expect(credential.platform).toBe('ios'); + expect(credential.appIdentifier).toBe('com.example.app'); + expect(credential.name).toBe('iPhone'); + expect(credential.algorithm).toBe('ES256'); + expect(credential.status).toBe('active'); + expect(credential.createdAt).toEqual(new Date(1_700_000_000_000)); + expect(credential.updatedAt).toEqual(new Date(1_700_000_100_000)); + expect(credential.lastUsedAt).toEqual(new Date(1_700_000_200_000)); + expect(credential.revokedAt).toBeNull(); + }); + + it('round-trips through the snapshot', () => { + const snapshot = new BiometricCredential(credentialJSON).__internal_toSnapshot(); + + expect(snapshot).toEqual(credentialJSON); + }); + + describe('User experimental methods', () => { + it('lists biometric credentials', async () => { + const fetch = mockFetch([credentialJSON]); + + const credentials = await createUser().__experimental_getBiometricCredentials(); + + expect(fetch).toHaveBeenCalledWith({ path: '/me/biometric_credentials', method: 'GET' }); + expect(credentials).toHaveLength(1); + expect(credentials[0]).toBeInstanceOf(BiometricCredential); + expect(credentials[0].id).toBe('td_123'); + }); + + it('prepares a biometric credential and returns the challenge', async () => { + const fetch = mockFetch({ + object: 'trusted_device_challenge', + challenge: 'challenge_value', + challenge_id: 'tdch_123', + client_data: 'client_data_value', + expires_at: 1_700_000_300, + algorithm: 'ES256', + }); + + const challenge = await createUser().__experimental_prepareBiometricCredential(enrollmentParams); + + expect(fetch).toHaveBeenCalledWith({ + path: '/me/biometric_credentials/prepare', + method: 'POST', + body: { ...enrollmentParams, publicKeyJwk: JSON.stringify(publicKeyJwk) }, + }); + expect(challenge).toEqual({ + challenge: 'challenge_value', + challengeId: 'tdch_123', + trustedDeviceId: null, + clientData: 'client_data_value', + expiresAt: new Date(1_700_000_300_000), + algorithm: 'ES256', + }); + }); + + it('attempts a biometric credential enrollment', async () => { + const fetch = mockFetch(credentialJSON); + const serializedJwk = JSON.stringify(publicKeyJwk); + + const credential = await createUser().__experimental_attemptBiometricCredential({ + ...enrollmentParams, + publicKeyJwk: serializedJwk, + clientData: 'client_data_value', + signature: 'signature_value', + }); + + const body = fetch.mock.calls[0][0].body; + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ path: '/me/biometric_credentials/attempt', method: 'POST' }), + ); + expect(Object.fromEntries(new URLSearchParams(stringifyQueryParams(body, { keyEncoder: camelToSnake })))).toEqual( + { + platform: 'ios', + app_identifier: 'com.example.app', + name: 'iPhone', + algorithm: 'ES256', + public_key_jwk: serializedJwk, + client_data: 'client_data_value', + signature: 'signature_value', + }, + ); + expect(credential.id).toBe('td_123'); + }); + + it('revokes a biometric credential', async () => { + const fetch = mockFetch({ ...credentialJSON, status: 'revoked', revoked_at: 1_700_000_400_000 }); + + const credential = await createUser().__experimental_revokeBiometricCredential('td_123'); + + expect(fetch).toHaveBeenCalledWith({ path: '/me/biometric_credentials/td_123', method: 'DELETE' }); + expect(credential.status).toBe('revoked'); + expect(credential.revokedAt).toEqual(new Date(1_700_000_400_000)); + }); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 2af05b64bbd..49c94773a7f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1,3 +1,5 @@ +import { stringifyQueryParams } from '@clerk/shared/internal/clerk-js/querystring'; +import { camelToSnake } from '@clerk/shared/underscore'; import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -760,6 +762,89 @@ describe('SignIn', () => { }); }); + describe('trusted_device strategy', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + const toFormFields = (body: Record) => + Object.fromEntries(new URLSearchParams(stringifyQueryParams(body, { keyEncoder: camelToSnake }))); + + it('creates a sign-in with a trusted device id', async () => { + vi.stubGlobal('navigator', { language: '' }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signin_123', + status: 'needs_first_factor', + first_factor_verification: { + object: 'verification_trusted_device', + status: 'unverified', + strategy: 'trusted_device', + attempts: 0, + trusted_device_challenge: { + object: 'trusted_device_challenge', + challenge: 'challenge_value', + challenge_id: 'tdch_123', + trusted_device_id: 'td_123', + client_data: 'client_data_value', + expires_at: 1_700_000_300, + algorithm: 'ES256', + }, + }, + }, + }); + BaseResource._fetch = mockFetch; + SignIn.clerk = { + client: { captchaBypass: false }, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + const signIn = new SignIn(); + await signIn.create({ strategy: 'trusted_device', trustedDeviceId: 'td_123' }); + + const { path, method, body } = mockFetch.mock.calls[0][0]; + expect({ path, method }).toEqual({ path: '/client/sign_ins', method: 'POST' }); + expect(toFormFields(body)).toEqual({ strategy: 'trusted_device', trusted_device_id: 'td_123' }); + expect(signIn.firstFactorVerification.trustedDeviceChallenge).toEqual({ + challenge: 'challenge_value', + challengeId: 'tdch_123', + trustedDeviceId: 'td_123', + clientData: 'client_data_value', + expiresAt: new Date(1_700_000_300_000), + algorithm: 'ES256', + }); + }); + + it('attempts the first factor with a signed challenge', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.attemptFirstFactor({ + strategy: 'trusted_device', + trustedDeviceId: 'td_123', + clientData: 'client_data_value', + signature: 'signature_value', + algorithm: 'ES256', + }); + + const { path, method, body } = mockFetch.mock.calls[0][0]; + expect({ path, method }).toEqual({ path: '/client/sign_ins/signin_123/attempt_first_factor', method: 'POST' }); + expect(toFormFields(body)).toEqual({ + strategy: 'trusted_device', + trusted_device_id: 'td_123', + client_data: 'client_data_value', + signature: 'signature_value', + algorithm: 'ES256', + }); + }); + }); + describe('SignInFuture', () => { it('can be serialized with JSON.stringify', () => { const signIn = new SignIn(); diff --git a/packages/clerk-js/src/core/resources/__tests__/Verification.test.ts b/packages/clerk-js/src/core/resources/__tests__/Verification.test.ts new file mode 100644 index 00000000000..0dc14642d9d --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/Verification.test.ts @@ -0,0 +1,75 @@ +import type { VerificationJSON } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { Verification } from '../Verification'; + +const trustedDeviceVerificationJSON = { + object: 'verification_trusted_device', + id: '', + status: 'unverified', + strategy: 'trusted_device', + attempts: 0, + expire_at: 1_700_000_600_000, + verified_at_client: '', + error: null, + trusted_device_challenge: { + object: 'trusted_device_challenge', + challenge: 'challenge_value', + challenge_id: 'tdch_123', + trusted_device_id: 'td_123', + client_data: 'client_data_value', + expires_at: 1_700_000_300, + algorithm: 'ES256', + }, +} as unknown as VerificationJSON; + +describe('Verification', () => { + it('parses a trusted device challenge', () => { + const verification = new Verification(trustedDeviceVerificationJSON); + + expect(verification.strategy).toBe('trusted_device'); + expect(verification.trustedDeviceChallenge).toEqual({ + challenge: 'challenge_value', + challengeId: 'tdch_123', + trustedDeviceId: 'td_123', + clientData: 'client_data_value', + expiresAt: new Date(1_700_000_300_000), + algorithm: 'ES256', + }); + }); + + it('round-trips a trusted device challenge through the snapshot', () => { + const snapshot = new Verification(trustedDeviceVerificationJSON).__internal_toSnapshot(); + + expect(snapshot.trusted_device_challenge).toEqual(trustedDeviceVerificationJSON.trusted_device_challenge); + expect(new Verification(snapshot).trustedDeviceChallenge).toEqual( + new Verification(trustedDeviceVerificationJSON).trustedDeviceChallenge, + ); + }); + + it('parses an enrollment challenge without a trusted device id', () => { + const verification = new Verification({ + ...trustedDeviceVerificationJSON, + trusted_device_challenge: { + object: 'trusted_device_challenge', + challenge: 'challenge_value', + challenge_id: 'tdch_123', + client_data: 'client_data_value', + expires_at: 1_700_000_300, + algorithm: 'ES256', + }, + }); + + expect(verification.trustedDeviceChallenge?.trustedDeviceId).toBeNull(); + expect(verification.__internal_toSnapshot().trusted_device_challenge).not.toHaveProperty('trusted_device_id'); + }); + + it('defaults to a null trusted device challenge and omits it from the snapshot', () => { + const { trusted_device_challenge: _, ...json } = trustedDeviceVerificationJSON; + const verification = new Verification(json as VerificationJSON); + + expect(verification.trustedDeviceChallenge).toBeNull(); + expect(new Verification(null).trustedDeviceChallenge).toBeNull(); + expect(verification.__internal_toSnapshot()).not.toHaveProperty('trusted_device_challenge'); + }); +}); diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index aab127ec134..dc5a17847b7 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -12,6 +12,7 @@ export * from './BillingPaymentMethod'; export * from './BillingPlan'; export * from './BillingStatement'; export * from './BillingSubscription'; +export * from './BiometricCredential'; export * from './Client'; export * from './CommerceSettings'; export * from './DeletedObject'; diff --git a/packages/expo/.gitignore b/packages/expo-native-components/.gitignore similarity index 100% rename from packages/expo/.gitignore rename to packages/expo-native-components/.gitignore diff --git a/packages/expo-native-components/LICENSE b/packages/expo-native-components/LICENSE new file mode 100644 index 00000000000..66914b6af7c --- /dev/null +++ b/packages/expo-native-components/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Clerk, Inc. + +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/expo-native-components/README.md b/packages/expo-native-components/README.md new file mode 100644 index 00000000000..dceac483ebb --- /dev/null +++ b/packages/expo-native-components/README.md @@ -0,0 +1,107 @@ +

+ + + + + + +
+

@clerk/expo-native-components

+

+ +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native_components) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components) + +
+ +## Getting Started + +`@clerk/expo-native-components` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs: + +- **iOS**: [clerk-ios](https://github.com/clerk/clerk-ios) (SwiftUI) +- **Android**: [clerk-android](https://github.com/clerk/clerk-android) (Jetpack Compose) + +Install it only if you use `AuthView`, `UserProfileView`, `UserButton`, or biometric credentials. Apps that only use `@clerk/expo` do not include the Clerk native SDKs. + +### Prerequisites + +- `@clerk/expo` +- Expo 54 or later, with a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (Expo Go is not supported) +- iOS 17 or later + +### Installation + +```sh +npx expo install @clerk/expo @clerk/expo-native-components +``` + +Add the config plugin alongside `@clerk/expo` in your app config, then rebuild your native app: + +```json +{ + "expo": { + "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + } +} +``` + +The plugin accepts the following options: + +- `keychainService`: keychain service identifier to share the session with app extensions. +- `theme`: path to a JSON file with `colors`, `darkColors`, and `design` keys that styles the native components. + +## Usage + +```tsx +import { useAuth } from '@clerk/expo'; +import { AuthView } from '@clerk/expo-native-components'; + +export default function SignInScreen() { + const { isSignedIn } = useAuth(); + + if (isSignedIn) { + return null; + } + + return ; +} +``` + +For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native_components). + +## Support + +For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components). + +## Community + +Join our [Discord community](https://clerk.com/discord) to connect with other developers. + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/expo-native-components` follows good practices of security, but 100% security cannot be assured. + +`@clerk/expo-native-components` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/LICENSE) for more information. diff --git a/packages/expo/android/build.gradle b/packages/expo-native-components/android/build.gradle similarity index 93% rename from packages/expo/android/build.gradle rename to packages/expo-native-components/android/build.gradle index 8975e848f7d..facf8a66952 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo-native-components/android/build.gradle @@ -13,8 +13,9 @@ applyKotlinExpoModulesCorePlugin() group = 'com.clerk.expo' version = '1.0.0' -def clerkExpoPackageJson = new JsonSlurper().parse(new File(projectDir, "../package.json")) -def clerkExpoVersion = clerkExpoPackageJson.version.toString() +// Set by the @clerk/expo-native-components config plugin to the installed @clerk/expo version. +def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toString() ?: + new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString() // Dependency versions - centralized for easier updates // See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs @@ -97,7 +98,7 @@ try { } } catch (Exception ignored) { // Future Gradle versions with Isolated Projects may block cross-project configuration. - // In that case, users should add '@clerk/expo' to their app.json plugins array instead, + // In that case, users should add '@clerk/expo-native-components' to their app.json plugins array instead, // which applies the flag via the config plugin's withClerkAndroid. } diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo-native-components/android/src/main/AndroidManifest.xml similarity index 100% rename from packages/expo/android/src/main/AndroidManifest.xml rename to packages/expo-native-components/android/src/main/AndroidManifest.xml diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt similarity index 98% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 8d5d9e49506..b72c1801e40 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -64,7 +64,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo private val onHostBack by EventDispatcher() init { - // At cold start, ClerkExpoModule.configure() may run before React's + // At cold start, ClerkExpoModule.configureNative() may run before React's // host-resume sync, so this view's construction is a reliable second hook. activity?.let { Clerk.attachActivity(it) } } diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt new file mode 100644 index 00000000000..8903f4424c8 --- /dev/null +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt @@ -0,0 +1,87 @@ +package expo.modules.clerk + +import com.clerk.api.network.model.client.Client + +/** The native client state that JS must refetch its own client for when it changes. */ +internal data class ClerkClientFingerprint( + val clientId: String?, + val lastActiveSessionId: String?, + val sessions: List, + val activeUserId: String?, + val activeUserUpdatedAt: Long?, + val deviceToken: String? +) { + data class SessionState(val id: String, val status: String) + + companion object { + fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint { + val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user + return ClerkClientFingerprint( + clientId = client?.id, + lastActiveSessionId = client?.lastActiveSessionId, + sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(), + activeUserId = activeUser?.id, + activeUserUpdatedAt = activeUser?.updatedAt, + deviceToken = deviceToken + ) + } + } +} + +/** + * Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from + * the last state JS was told about. Not thread-safe: call it from the main thread only. + */ +internal class ClerkClientInvalidationTracker( + private val schedule: (() -> Unit) -> Unit, + private val emit: () -> Unit +) { + private var baseline: ClerkClientFingerprint? = null + private var latest: ClerkClientFingerprint? = null + private var isFlushScheduled = false + + /** Sets the state JS already knows about without emitting. */ + fun reset(fingerprint: ClerkClientFingerprint?) { + baseline = fingerprint + latest = fingerprint + } + + fun observe(fingerprint: ClerkClientFingerprint) { + if (baseline == null) return + latest = fingerprint + if (fingerprint == baseline || isFlushScheduled) return + isFlushScheduled = true + schedule(::flush) + } + + /** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */ + fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) { + baseline = baseline?.copy(deviceToken = token) + observe(current) + } + + private fun flush() { + isFlushScheduled = false + val current = latest ?: return + if (current == baseline) return + baseline = current + emit() + } +} + +internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String) + +internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) { + is IllegalArgumentException -> ClerkDeviceTokenBridgeError( + "E_INVALID_DEVICE_TOKEN", + throwable.message ?: "Device token must not be blank" + ) + is IllegalStateException -> ClerkDeviceTokenBridgeError( + "E_NOT_CONFIGURED", + throwable.message ?: "Clerk must be configured with configureNative before syncing client state." + ) + else -> ClerkDeviceTokenBridgeError( + "E_SET_DEVICE_TOKEN_FAILED", + throwable.message ?: "Unable to set the device token" + ) +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt similarity index 61% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index c329174372d..93c1030fcb5 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -3,6 +3,8 @@ package expo.modules.clerk import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -30,19 +32,17 @@ import expo.modules.kotlin.modules.ModuleDefinition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext import org.json.JSONObject private const val TAG = "ClerkExpoModule" private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged" -private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged" +private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated" private const val HOST_SDK_HEADER = "x-clerk-host-sdk" private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version" private const val HOST_SDK = "expo" @@ -200,86 +200,54 @@ internal fun biometricCredentialBridgeError( class ClerkExpoModule : Module() { private val coroutineScope = CoroutineScope(Dispatchers.Main) private var authFlowStateObserverJob: Job? = null - private var clientStateObserverJob: Job? = null - private var lastObservedClientState: ClientStateSnapshot? = null - private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var clientInvalidationObserverJob: Job? = null + private val clientInvalidationTracker = ClerkClientInvalidationTracker( + schedule = { flush -> mainHandler.post(flush) }, + emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap()) } + ) private data class AuthFlowStateSnapshot( val isLoaded: Boolean, val isAuthFlowComplete: Boolean ) - private data class ClientStateSnapshot( - val client: Client?, - val deviceToken: String? - ) - - private data class ClientStateChanges( - val client: Boolean, - val deviceToken: Boolean - ) - - companion object { - private var sharedInstance: ClerkExpoModule? = null - - fun emitClientChanged(sourceId: String? = null) { - val instance = sharedInstance ?: return - instance.sendEvent( - NATIVE_CLIENT_CHANGED_EVENT, - instance.clientChangedPayload( - sourceId = sourceId, - changes = ClientStateChanges(client = true, deviceToken = true) - ) - ) - } - } - override fun definition() = ModuleDefinition { Name("ClerkExpo") - Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT) + Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT) OnCreate { - sharedInstance = this@ClerkExpoModule startAuthFlowStateObserver() } OnDestroy { - if (sharedInstance === this@ClerkExpoModule) { - sharedInstance = null - } authFlowStateObserverJob?.cancel() authFlowStateObserverJob = null - clientStateObserverJob?.cancel() - clientStateObserverJob = null + clientInvalidationObserverJob?.cancel() + clientInvalidationObserverJob = null + mainHandler.removeCallbacksAndMessages(null) } - AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> - configure(pubKey, bearerToken, promise) + AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise -> + configureNative(pubKey, seedDeviceToken, promise) } - AsyncFunction("getClientToken") { promise: Promise -> - getClientToken(promise) + AsyncFunction("getDeviceToken") { promise: Promise -> + getDeviceToken(promise) } - AsyncFunction("getAuthFlowState") { promise: Promise -> - promise.resolve(authFlowStatePayload()) + AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise -> + setDeviceToken(token, expected, promise) } - AsyncFunction("syncClientStateFromJs") { - deviceToken: String?, - sourceId: String?, - didChangeClient: Boolean, - didChangeDeviceToken: Boolean, - promise: Promise -> - syncClientStateFromJs( - deviceToken, - sourceId, - didChangeClient, - didChangeDeviceToken, - promise - ) + AsyncFunction("refreshClient") { promise: Promise -> + refreshClient(promise) + } + + AsyncFunction("getAuthFlowState") { promise: Promise -> + promise.resolve(authFlowStatePayload()) } AsyncFunction("getTrustedDeviceAvailability") { @@ -372,261 +340,144 @@ class ClerkExpoModule : Module() { ) } - private fun startClientStateObserver() { - if (clientStateObserverJob != null) { + // MARK: - single-token client sync + + private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) { + val context = reactContext ?: run { + promise.reject("E_CONFIGURE_FAILED", "React context is not available", null) return } - lastObservedClientState = clientStateSnapshot() - - clientStateObserverJob = coroutineScope.launch { - Clerk.clientFlow.collect { client -> - val previousClientState = lastObservedClientState - val newClientState = clientStateSnapshot(client) - - if (newClientState == previousClientState) { - return@collect + coroutineScope.launch { + try { + val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey + val didConfigure = when { + activePublishableKey == null -> { + Clerk.initialize(context, pubKey, clerkConfigurationOptions()) + true + } + activePublishableKey != pubKey -> { + Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) + true + } + else -> false } - - lastObservedClientState = newClientState - if (jsOriginatedClientSyncDepth > 0) { - return@collect + if (didConfigure) { + configuredPublishableKey = pubKey + appContext.currentActivity?.let { Clerk.attachActivity(it) } + // Must follow initialize(), which resets customTheme. + loadThemeFromAssets(context) } - sendEvent( - NATIVE_CLIENT_CHANGED_EVENT, - clientChangedPayload( - deviceToken = newClientState.deviceToken, - changes = ClientStateChanges( - client = newClientState.client != previousClientState?.client, - deviceToken = newClientState.deviceToken != previousClientState?.deviceToken - ) - ) - ) + val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken) + startClientInvalidationObserver() + if (didAdoptSeed) { + // An initialization refresh started before the seed was stored is fenced off by the token change. + launch { + val result = Clerk.refreshClient() + if (result is ClerkResult.Failure) { + debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}") + } + clientInvalidationTracker.observe(clientFingerprint()) + } + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e) } } } - private fun clientStateSnapshot(client: Client? = Clerk.clientFlow.value): ClientStateSnapshot { - return ClientStateSnapshot( - client = client, - deviceToken = try { - Clerk.getDeviceToken() - } catch (e: Exception) { - debugLog(TAG, "clientStateSnapshot - getDeviceToken failed: ${e.message}") - null - } - ) + private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean { + val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false + return withContext(Dispatchers.IO) { + Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null) + } } - private fun clientChangedPayload( - sourceId: String? = null, - changes: ClientStateChanges, - deviceToken: String? = clientStateSnapshot().deviceToken - ): Map { - val result = mutableMapOf( - "changed" to mapOf( - "client" to changes.client, - "deviceToken" to changes.deviceToken - ), - "deviceToken" to deviceToken - ) - if (!sourceId.isNullOrEmpty()) { - result["sourceId"] = sourceId + private fun startClientInvalidationObserver() { + clientInvalidationTracker.reset(clientFingerprint()) + if (clientInvalidationObserverJob != null) { + return + } + + clientInvalidationObserverJob = coroutineScope.launch { + Clerk.clientFlow.collect { client -> + clientInvalidationTracker.observe(clientFingerprint(client)) + } } - return result } - private fun emitSyncedClientChanged( - sourceId: String?, - changes: ClientStateChanges, - snapshot: ClientStateSnapshot = clientStateSnapshot() - ) { - lastObservedClientState = snapshot - sendEvent( - NATIVE_CLIENT_CHANGED_EVENT, - clientChangedPayload( - sourceId = sourceId, - changes = changes, - deviceToken = snapshot.deviceToken - ) - ) + private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint { + return ClerkClientFingerprint.from(client, currentDeviceToken()) + } + + private fun currentDeviceToken(): String? { + return try { + Clerk.getDeviceToken() + } catch (e: Exception) { + debugLog(TAG, "getDeviceToken failed: ${e.message}") + null + } } - // MARK: - configure + private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null - private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { - val context = reactContext ?: run { - promise.reject("E_INIT_FAILED", "React context is not available", null) + private fun getDeviceToken(promise: Promise) { + if (!isClerkConfigured()) { + promise.resolve(null) return } + try { + promise.resolve(Clerk.getDeviceToken()) + } catch (e: Exception) { + promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e) + } + } + private fun setDeviceToken(token: String?, expected: String?, promise: Promise) { coroutineScope.launch { try { - val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() } - - if (!Clerk.isInitialized.value) { - // First-time initialization — write the bearer token to SharedPreferences - // before initializing so the SDK boots with the correct client. - if (normalizedBearerToken != null) { - context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) - .edit() - .putString("DEVICE_TOKEN", normalizedBearerToken) - .apply() - } - - Clerk.initialize(context, pubKey, clerkConfigurationOptions()) - startClientStateObserver() - // clerk-android registers ActivityLifecycleCallbacks during - // initialize(), but in React Native MainActivity has already passed - // onResume() by the time mounts and we reach this - // line, so the callbacks miss the initial activity. Without seeding, - // the first Credential Manager call (Google sign-in / passkeys) - // fails with MissingActivity until the user backgrounds and - // foregrounds the app. currentActivity can be null here on - // cold start before React's host-resume sync — AuthView and - // UserProfile also call attachActivity() on mount as a backstop. - appContext.currentActivity?.let { Clerk.attachActivity(it) } - // Must be set AFTER Clerk.initialize() because initialize() - // resets customTheme to its `theme` parameter (default null). - loadThemeFromAssets(context) - - // Wait for initialization to complete with timeout - try { - withTimeout(10_000L) { - Clerk.isInitialized.first { it } - } - // If a bearer token was provided, wait for native client state to hydrate - // before resolving the configure call. - if (normalizedBearerToken != null) { - withTimeout(5_000L) { - Clerk.clientFlow.first { it != null } - } - } - } catch (e: TimeoutCancellationException) { - val initError = Clerk.initializationError.value - val message = if (initError != null) { - "Clerk initialization timed out: ${initError.message}" - } else { - "Clerk initialization timed out after 10 seconds" - } - promise.reject("E_TIMEOUT", message, null) - return@launch - } - - // Check for initialization errors - val error = Clerk.initializationError.value - if (error != null) { - promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null) - } else { - configuredPublishableKey = pubKey - lastObservedClientState = clientStateSnapshot() - promise.resolve(null) - } - return@launch + val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) } + val fingerprint = clientFingerprint() + if (didSet) { + clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint) + } else { + clientInvalidationTracker.observe(fingerprint) } - - val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey - if (activePublishableKey != null && activePublishableKey != pubKey) { - Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) - startClientStateObserver() - appContext.currentActivity?.let { Clerk.attachActivity(it) } - loadThemeFromAssets(context) - - try { - withTimeout(10_000L) { - Clerk.isInitialized.first { it } - } - } catch (e: TimeoutCancellationException) { - val initError = Clerk.initializationError.value - val message = if (initError != null) { - "Clerk reconfiguration timed out: ${initError.message}" - } else { - "Clerk reconfiguration timed out after 10 seconds" - } - promise.reject("E_TIMEOUT", message, null) - return@launch - } - - val error = Clerk.initializationError.value - if (error != null) { - promise.reject("E_RECONFIGURE_FAILED", "Failed to reconfigure Clerk SDK: ${error.message}", null) - return@launch - } - - if (normalizedBearerToken != null) { - val clientState = clientStateSnapshot() - if (clientState.deviceToken != normalizedBearerToken || clientState.client == null) { - val result = Clerk.updateDeviceToken(normalizedBearerToken) - if (result is ClerkResult.Failure) { - debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}") - } - } - - try { - withTimeout(5_000L) { - Clerk.clientFlow.first { it != null } - } - } catch (_: TimeoutCancellationException) { - debugLog(TAG, "configure - client did not appear after reconfigure token update") - } - } - - configuredPublishableKey = pubKey - lastObservedClientState = clientStateSnapshot() - promise.resolve(null) - return@launch - } - - // Already initialized — use the public SDK API to update - // the device token and trigger a client/environment refresh. - startClientStateObserver() - if (normalizedBearerToken != null) { - val clientState = clientStateSnapshot() - val result = if ( - clientState.deviceToken != normalizedBearerToken || - clientState.client == null - ) { - Clerk.updateDeviceToken(normalizedBearerToken) - } else { - // A remounted JS runtime can have the same token while native - // client state is stale, so preserve one refresh in that case. - Clerk.refreshClient() - } - if (result is ClerkResult.Failure) { - debugLog(TAG, "configure - client refresh failed: ${result.error}") - } - - // Wait for client state to hydrate with the new token (up to 5s). - try { - withTimeout(5_000L) { - Clerk.clientFlow.first { it != null } - } - } catch (_: TimeoutCancellationException) { - debugLog(TAG, "configure - client did not appear after token update") - } - } - - lastObservedClientState = clientStateSnapshot() - promise.resolve(null) + promise.resolve(didSet) } catch (e: Exception) { - promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) + val error = clerkSetDeviceTokenBridgeError(e) + promise.reject(error.code, error.message, e) } } } - // MARK: - getClientToken + private fun refreshClient(promise: Promise) { + if (!isClerkConfigured()) { + promise.reject( + "E_NOT_CONFIGURED", + "Clerk must be configured with configureNative before syncing client state.", + null + ) + return + } - private fun getClientToken(promise: Promise) { - try { - // Use the SDK's public API which handles encrypted storage transparently. - // Direct SharedPreferences reads break on clerk-android >= 1.0.11 where - // DEVICE_TOKEN is encrypted via StorageCipher. - val deviceToken = Clerk.getDeviceToken() - promise.resolve(deviceToken) - } catch (e: Exception) { - debugLog(TAG, "getClientToken failed: ${e.message}") - promise.resolve(null) + coroutineScope.launch { + try { + val result = Clerk.refreshClient() + clientInvalidationTracker.observe(clientFingerprint()) + when (result) { + is ClerkResult.Success -> promise.resolve(null) + is ClerkResult.Failure -> promise.reject( + "E_REFRESH_CLIENT_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed", + result.throwable + ) + } + } catch (e: Exception) { + promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e) + } } } @@ -899,100 +750,6 @@ class ClerkExpoModule : Module() { promise.reject(error.code, error.message, exception) } - // MARK: - syncClientStateFromJs - - private fun syncClientStateFromJs( - deviceToken: String?, - sourceId: String?, - didChangeClient: Boolean, - didChangeDeviceToken: Boolean, - promise: Promise - ) { - if (!Clerk.isInitialized.value) { - promise.resolve(null) - return - } - - coroutineScope.launch { - try { - jsOriginatedClientSyncDepth += 1 - val previousClientState = clientStateSnapshot() - var refreshedClientWhileUpdatingToken = false - - if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) { - val currentDeviceToken = try { - Clerk.getDeviceToken() - } catch (_: Exception) { - null - } - - if (currentDeviceToken != deviceToken) { - when (val result = Clerk.updateDeviceToken(deviceToken)) { - is ClerkResult.Failure -> { - promise.reject( - "E_SYNC_FROM_JS_FAILED", - result.error?.firstMessage() ?: result.throwable?.message ?: "Device token sync failed", - null - ) - return@launch - } - is ClerkResult.Success -> { - refreshedClientWhileUpdatingToken = true - try { - withTimeout(5_000L) { - Clerk.clientFlow.first { it != null } - } - } catch (_: TimeoutCancellationException) { - debugLog(TAG, "syncClientStateFromJs - client did not appear after token update") - } - } - } - } - } - - if (!refreshedClientWhileUpdatingToken && (didChangeClient || didChangeDeviceToken)) { - when (val result = Clerk.refreshClient()) { - is ClerkResult.Failure -> { - promise.reject( - "E_SYNC_FROM_JS_FAILED", - result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed", - null - ) - } - is ClerkResult.Success -> { - val newClientState = clientStateSnapshot() - emitSyncedClientChanged( - sourceId, - ClientStateChanges( - client = newClientState.client != previousClientState.client, - deviceToken = newClientState.deviceToken != previousClientState.deviceToken - ), - newClientState - ) - promise.resolve(null) - } - } - return@launch - } - - val newClientState = clientStateSnapshot() - emitSyncedClientChanged( - sourceId, - ClientStateChanges( - client = newClientState.client != previousClientState.client, - deviceToken = newClientState.deviceToken != previousClientState.deviceToken - ), - newClientState - ) - promise.resolve(null) - } catch (e: Exception) { - promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client state sync failed", e) - } finally { - jsOriginatedClientSyncDepth = maxOf(0, jsOriginatedClientSyncDepth - 1) - } - } - } - // MARK: - Theme Loading private fun loadThemeFromAssets(context: Context) { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt similarity index 100% rename from packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt diff --git a/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt new file mode 100644 index 00000000000..76f75c810b1 --- /dev/null +++ b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt @@ -0,0 +1,127 @@ +package expo.modules.clerk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ClerkClientInvalidationTest { + private val scheduled = mutableListOf<() -> Unit>() + private var emitCount = 0 + + private fun tracker() = ClerkClientInvalidationTracker( + schedule = { scheduled.add(it) }, + emit = { emitCount += 1 } + ) + + private fun runLoopTurn() { + val work = scheduled.toList() + scheduled.clear() + work.forEach { it() } + } + + private fun fingerprint( + clientId: String? = "client_1", + sessions: List = listOf(ClerkClientFingerprint.SessionState("sess_1", "ACTIVE")), + userUpdatedAt: Long? = 1, + deviceToken: String? = "token_1" + ) = ClerkClientFingerprint( + clientId = clientId, + lastActiveSessionId = sessions.firstOrNull()?.id, + sessions = sessions, + activeUserId = if (sessions.isEmpty()) null else "user_1", + activeUserUpdatedAt = if (sessions.isEmpty()) null else userUpdatedAt, + deviceToken = deviceToken + ) + + @Before + fun setUp() { + scheduled.clear() + emitCount = 0 + } + + @Test + fun `does not emit before a baseline or for unchanged state`() { + val tracker = tracker() + tracker.observe(fingerprint()) + assertTrue(scheduled.isEmpty()) + + tracker.reset(fingerprint()) + tracker.observe(fingerprint()) + runLoopTurn() + assertEquals(0, emitCount) + } + + @Test + fun `emits for each fingerprint field`() { + val changes = listOf( + fingerprint(clientId = "client_2"), + fingerprint(sessions = listOf(ClerkClientFingerprint.SessionState("sess_1", "ENDED"))), + fingerprint(sessions = emptyList()), + fingerprint(userUpdatedAt = 2), + fingerprint(deviceToken = "token_2") + ) + + for (change in changes) { + val tracker = tracker() + tracker.reset(fingerprint()) + emitCount = 0 + tracker.observe(change) + runLoopTurn() + assertEquals(change.toString(), 1, emitCount) + } + } + + @Test + fun `coalesces changes within one loop turn`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.observe(fingerprint(deviceToken = "token_2")) + tracker.observe(fingerprint(clientId = "client_2", deviceToken = "token_2")) + tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3")) + assertEquals(1, scheduled.size) + runLoopTurn() + assertEquals(1, emitCount) + + tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3")) + runLoopTurn() + assertEquals(1, emitCount) + + tracker.observe(fingerprint(clientId = "client_4", deviceToken = "token_3")) + runLoopTurn() + assertEquals(2, emitCount) + } + + @Test + fun `a change reverted within one loop turn does not emit`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.observe(fingerprint(clientId = "client_2")) + tracker.observe(fingerprint()) + runLoopTurn() + assertEquals(0, emitCount) + } + + @Test + fun `an acknowledged device token does not echo`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.acknowledgeDeviceToken("token_2", fingerprint(deviceToken = "token_2")) + runLoopTurn() + assertEquals(0, emitCount) + + tracker.acknowledgeDeviceToken("token_3", fingerprint(clientId = "client_2", deviceToken = "token_3")) + runLoopTurn() + assertEquals(1, emitCount) + } + + @Test + fun `maps setDeviceToken exceptions to stable codes`() { + assertEquals("E_INVALID_DEVICE_TOKEN", clerkSetDeviceTokenBridgeError(IllegalArgumentException("blank")).code) + assertEquals("E_NOT_CONFIGURED", clerkSetDeviceTokenBridgeError(IllegalStateException("not initialized")).code) + assertEquals("E_SET_DEVICE_TOKEN_FAILED", clerkSetDeviceTokenBridgeError(RuntimeException("boom")).code) + } +} diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt similarity index 100% rename from packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt diff --git a/packages/expo-native-components/app.plugin.d.ts b/packages/expo-native-components/app.plugin.d.ts new file mode 100644 index 00000000000..8808449dd73 --- /dev/null +++ b/packages/expo-native-components/app.plugin.d.ts @@ -0,0 +1,5 @@ +import type { ConfigPlugin } from '@expo/config-plugins'; + +declare const withClerkExpoNativeComponents: ConfigPlugin<{ keychainService?: string; theme?: string } | void>; + +export = withClerkExpoNativeComponents; diff --git a/packages/expo-native-components/app.plugin.js b/packages/expo-native-components/app.plugin.js new file mode 100644 index 00000000000..36e10092b7a --- /dev/null +++ b/packages/expo-native-components/app.plugin.js @@ -0,0 +1,333 @@ +/** + * Expo config plugin for @clerk/expo-native-components + * Configures iOS and Android for the Clerk native SDKs (clerk-ios / clerk-android) + * + * When this plugin is used: + * 1. iOS is configured with the required deployment target and metadata + * 2. Android is configured with packaging exclusions for dependencies + * + * Native modules and views are registered via Expo Modules autolinking. + */ +const { + createRunOncePlugin, + withXcodeProject, + withDangerousMod, + withInfoPlist, + withAppBuildGradle, + withGradleProperties, +} = require('@expo/config-plugins'); +const path = require('path'); +const fs = require('fs'); +const packageJson = require('./package.json'); + +const CLERK_MIN_IOS_VERSION = '17.0'; +const HOST_SDK_VERSION_GRADLE_PROPERTY = 'clerkExpo.hostSdkVersion'; + +const resolveClerkExpo = projectRoot => { + try { + const paths = [projectRoot, process.cwd()].filter(Boolean); + const clerkExpoPackageJsonPath = require.resolve('@clerk/expo/package.json', { paths }); + return { + dir: path.dirname(clerkExpoPackageJsonPath), + version: require(clerkExpoPackageJsonPath).version, + }; + } catch { + return null; + } +}; + +const withClerkIOS = (config, hostSdkVersion) => { + console.log('✅ Clerk iOS plugin loaded'); + + // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install + // This ensures ClerkExpo pod gets installed (it requires iOS 17.0) + config = withDangerousMod(config, [ + 'ios', + async config => { + const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json'); + + let properties = {}; + if (fs.existsSync(podfilePropertiesPath)) { + try { + properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8')); + } catch { + // If file exists but is invalid JSON, start fresh + } + } + + // Set the iOS deployment target + if ( + !properties['ios.deploymentTarget'] || + parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION) + ) { + properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION; + fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n'); + console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`); + } + + return config; + }, + ]); + + // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK) + config = withXcodeProject(config, config => { + const xcodeProject = config.modResults; + + try { + // Update deployment target in all build configurations + const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {}; + + for (const [uuid, buildConfig] of Object.entries(buildConfigs)) { + if (buildConfig && buildConfig.buildSettings) { + const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET; + if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) { + buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION; + } + } + } + + console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`); + } catch (error) { + console.error('❌ Error updating deployment target:', error.message); + } + + return config; + }); + + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkExpoVersion = hostSdkVersion; + return modConfig; + }); + + return config; +}; + +/** + * Add packaging exclusions to Android app build.gradle to resolve + * duplicate META-INF file conflicts from clerk-android dependencies. + */ +const withClerkAndroid = (config, hostSdkVersion) => { + console.log('✅ Clerk Android plugin loaded'); + + config = withGradleProperties(config, modConfig => { + modConfig.modResults = modConfig.modResults.filter( + item => !(item.type === 'property' && item.key === HOST_SDK_VERSION_GRADLE_PROPERTY), + ); + modConfig.modResults.push({ type: 'property', key: HOST_SDK_VERSION_GRADLE_PROPERTY, value: hostSdkVersion }); + return modConfig; + }); + + return withAppBuildGradle(config, modConfig => { + let buildGradle = modConfig.modResults.contents; + + // --- META-INF exclusion --- + if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions` + const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/); + if (packagingMatch) { + const blockName = packagingMatch[0].trim().replace(/\s*\{$/, ''); + const resourcesExclude = `${blockName} { + // Clerk Android SDK: exclude duplicate META-INF files + resources { + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + }`; + + buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude); + } else { + // No packaging block found; append one at the end of the android block + const androidBlockEnd = buildGradle.lastIndexOf('}'); + if (androidBlockEnd !== -1) { + const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`; + buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd); + } + } + console.log('✅ Clerk Android packaging exclusions added'); + } + + // --- Kotlin metadata version check skip --- + if (!buildGradle.includes('-Xskip-metadata-version-check')) { + const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/); + if (kotlinOptionsMatch) { + buildGradle = buildGradle.replace( + /kotlinOptions\s*\{/, + `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`, + ); + } else { + const androidMatch = buildGradle.match(/android\s*\{/); + if (androidMatch) { + buildGradle = buildGradle.replace( + /android\s*\{/, + `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`, + ); + } + } + console.log('✅ Clerk Android Kotlin metadata version check skip added'); + } + + modConfig.modResults.contents = buildGradle; + return modConfig; + }); +}; + +/** + * Write ClerkKeychainService to Info.plist when keychainService is provided. + * This allows extension apps (watch, widget, app clip) to share the same + * keychain entry as the main app by using a custom service identifier. + */ +const withClerkKeychainService = (config, { keychainService } = {}) => { + if (!keychainService) { + return config; + } + + return withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkKeychainService = keychainService; + console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`); + return modConfig; + }); +}; + +/** + * Apply a custom theme to Clerk native components (iOS + Android). + * + * Accepts a `theme` prop pointing to a JSON file with optional keys: + * - colors: { primary, background, input, danger, success, warning, + * foreground, mutedForeground, primaryForeground, inputForeground, + * neutral, border, ring, muted, shadow, secondaryButtonBackground, + * secondaryButtonForeground } (hex color strings) + * - darkColors: same keys as colors (for dark mode) + * - design: { fontFamily: string, borderRadius: number } + * + * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". + * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. + */ +const VALID_COLOR_KEYS = [ + 'primary', + 'background', + 'input', + 'danger', + 'success', + 'warning', + 'foreground', + 'mutedForeground', + 'primaryForeground', + 'inputForeground', + 'neutral', + 'border', + 'ring', + 'muted', + 'shadow', + 'secondaryButtonBackground', + 'secondaryButtonForeground', +]; + +const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateThemeJson(theme) { + if (!isPlainObject(theme)) { + throw new Error('Clerk theme: theme JSON must be a plain object'); + } + + const validateColors = (colors, label) => { + if (!isPlainObject(colors)) { + throw new Error(`Clerk theme: ${label} must be an object`); + } + for (const [key, value] of Object.entries(colors)) { + if (!VALID_COLOR_KEYS.includes(key)) { + console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); + continue; + } + if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { + throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); + } + } + }; + + if (theme.colors != null) validateColors(theme.colors, 'colors'); + if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); + + if (theme.design != null) { + if (!isPlainObject(theme.design)) { + throw new Error(`Clerk theme: design must be an object`); + } + if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { + throw new Error(`Clerk theme: design.fontFamily must be a string`); + } + if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { + throw new Error(`Clerk theme: design.borderRadius must be a number`); + } + } +} + +const withClerkTheme = (config, props = {}) => { + const { theme } = props; + if (!theme) return config; + + // Resolve the theme file path relative to the project root + const themePath = path.resolve(theme); + if (!fs.existsSync(themePath)) { + console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`); + return config; + } + + let themeJson; + try { + themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8')); + validateThemeJson(themeJson); + } catch (e) { + throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`); + } + + // iOS: Embed theme in Info.plist under "ClerkTheme" + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkTheme = themeJson; + console.log('✅ Embedded Clerk theme in Info.plist'); + return modConfig; + }); + + // Android: Copy theme JSON to assets + config = withDangerousMod(config, [ + 'android', + async config => { + const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets'); + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + const destPath = path.join(assetsDir, 'clerk_theme.json'); + fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n'); + console.log('✅ Copied Clerk theme to Android assets'); + return config; + }, + ]); + + return config; +}; + +const withClerkExpoNativeComponents = (config, props = {}, resolve = resolveClerkExpo) => { + const clerkExpo = resolve(config._internal?.projectRoot); + if (clerkExpo && fs.existsSync(path.join(clerkExpo.dir, 'expo-module.config.json'))) { + throw new Error( + `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native-components. Upgrade @clerk/expo to a version that supports @clerk/expo-native-components.`, + ); + } + // Native requests report the @clerk/expo version in the x-clerk-host-sdk-version header. + const hostSdkVersion = clerkExpo?.version ?? packageJson.version; + + config = withClerkIOS(config, hostSdkVersion); + config = withClerkAndroid(config, hostSdkVersion); + config = withClerkKeychainService(config, props); + config = withClerkTheme(config, props); + return config; +}; + +module.exports = createRunOncePlugin(withClerkExpoNativeComponents, packageJson.name, packageJson.version); +module.exports._testing = { + withClerkExpoNativeComponents, + validateThemeJson, + isPlainObject, + VALID_COLOR_KEYS, + HEX_COLOR_REGEX, +}; diff --git a/packages/expo/expo-module.config.json b/packages/expo-native-components/expo-module.config.json similarity index 100% rename from packages/expo/expo-module.config.json rename to packages/expo-native-components/expo-module.config.json diff --git a/packages/expo/ios/ClerkAppDelegateSubscriber.swift b/packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift similarity index 100% rename from packages/expo/ios/ClerkAppDelegateSubscriber.swift rename to packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo-native-components/ios/ClerkAuthNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkAuthNativeView.swift rename to packages/expo-native-components/ios/ClerkAuthNativeView.swift diff --git a/packages/expo-native-components/ios/ClerkClientInvalidation.swift b/packages/expo-native-components/ios/ClerkClientInvalidation.swift new file mode 100644 index 00000000000..478d6da60a1 --- /dev/null +++ b/packages/expo-native-components/ios/ClerkClientInvalidation.swift @@ -0,0 +1,105 @@ +import Foundation +@_spi(FrameworkIntegration) import ClerkKit + +/// The native client state that JS must refetch its own client for when it changes. +struct ClerkClientFingerprint: Equatable { + struct SessionState: Equatable { + let id: String + let status: String + } + + let clientId: String? + let lastActiveSessionId: String? + let sessions: [SessionState] + let activeUserId: String? + let activeUserUpdatedAt: Double? + var deviceToken: String? +} + +extension ClerkClientFingerprint { + init(client: Client?, deviceToken: String?) { + let activeUser = client?.sessions.first { $0.id == client?.lastActiveSessionId }?.user + self.init( + clientId: client?.id, + lastActiveSessionId: client?.lastActiveSessionId, + sessions: client?.sessions.map { SessionState(id: $0.id, status: $0.status.rawValue) } ?? [], + activeUserId: activeUser?.id, + activeUserUpdatedAt: activeUser?.updatedAt.timeIntervalSince1970, + deviceToken: deviceToken + ) + } +} + +/// Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from +/// the last state JS was told about. +@MainActor +final class ClerkClientInvalidationTracker { + typealias Scheduler = (@escaping @MainActor () -> Void) -> Void + + private let schedule: Scheduler + private let emit: () -> Void + private var baseline: ClerkClientFingerprint? + private var latest: ClerkClientFingerprint? + private var isFlushScheduled = false + + init( + schedule: @escaping Scheduler = { work in DispatchQueue.main.async { MainActor.assumeIsolated(work) } }, + emit: @escaping () -> Void + ) { + self.schedule = schedule + self.emit = emit + } + + /// Sets the state JS already knows about without emitting. + func reset(to fingerprint: ClerkClientFingerprint?) { + baseline = fingerprint + latest = fingerprint + } + + func observe(_ fingerprint: ClerkClientFingerprint) { + guard baseline != nil else { return } + latest = fingerprint + guard fingerprint != baseline, !isFlushScheduled else { return } + isFlushScheduled = true + schedule { [weak self] in self?.flush() } + } + + /// A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. + func acknowledgeDeviceToken(_ token: String?, current: ClerkClientFingerprint) { + baseline?.deviceToken = token + observe(current) + } + + private func flush() { + isFlushScheduled = false + guard let latest, latest != baseline else { return } + baseline = latest + emit() + } +} + +struct ClerkDeviceTokenErrorDescriptor: Equatable { + let code: String + let message: String +} + +enum ClerkClientSyncError: Error, LocalizedError { + case notConfigured + + var errorDescription: String? { + "Clerk must be configured with configureNative before syncing client state." + } +} + +func clerkSetDeviceTokenErrorDescriptor(_ error: Error) -> ClerkDeviceTokenErrorDescriptor { + switch error { + case ClerkClientSyncError.notConfigured: + return .init(code: "E_NOT_CONFIGURED", message: error.localizedDescription) + case Clerk.DeviceTokenError.emptyToken: + return .init(code: "E_INVALID_DEVICE_TOKEN", message: error.localizedDescription) + case is CancellationError: + return .init(code: "E_CANCELLED", message: "The device token update was cancelled.") + default: + return .init(code: "E_SET_DEVICE_TOKEN_FAILED", message: error.localizedDescription) + } +} diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec similarity index 92% rename from packages/expo/ios/ClerkExpo.podspec rename to packages/expo-native-components/ios/ClerkExpo.podspec index 13b4510aa12..104abc556a0 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo-native-components/ios/ClerkExpo.podspec @@ -18,7 +18,7 @@ else end clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git' -clerk_ios_version = '1.5.7' +clerk_ios_branch = 'mike/framework-set-device-token' Pod::Spec.new do |s| s.name = 'ClerkExpo' @@ -44,7 +44,7 @@ Pod::Spec.new do |s| spm_dependency( s, url: clerk_ios_repo, - requirement: { :kind => 'exactVersion', :version => clerk_ios_version }, + requirement: { :kind => 'branch', :branch => clerk_ios_branch }, products: ['ClerkKit', 'ClerkKitUI'] ) else @@ -52,6 +52,7 @@ Pod::Spec.new do |s| end s.source_files = "ClerkNativeBridge.swift", + "ClerkClientInvalidation.swift", "ClerkAppDelegateSubscriber.swift", "ClerkExpoModule.swift", "ClerkNativeViewHost.swift", diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift similarity index 71% rename from packages/expo/ios/ClerkExpoModule.swift rename to packages/expo-native-components/ios/ClerkExpoModule.swift index a1a12679066..411bf2c7204 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo-native-components/ios/ClerkExpoModule.swift @@ -9,22 +9,22 @@ import Foundation public class ClerkExpoModule: Module { private static let nativeAuthFlowChangedEvent = "clerkNativeAuthFlowChanged" - private static let nativeClientChangedEvent = "clerkNativeClientChanged" + private static let nativeClientInvalidatedEvent = "clerkNativeClientInvalidated" private static weak var sharedInstance: ClerkExpoModule? public func definition() -> ModuleDefinition { Name("ClerkExpo") - Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent) + Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientInvalidatedEvent) OnCreate { Self.sharedInstance = self ClerkNativeBridge.setAuthFlowChangedEmitter { body in Self.emitAuthFlowChanged(body) } - ClerkNativeBridge.setClientChangedEmitter { body in - Self.emitClientChanged(body) + ClerkNativeBridge.setClientInvalidatedEmitter { + Self.emitClientInvalidated() } } @@ -32,35 +32,57 @@ public class ClerkExpoModule: Module { if Self.sharedInstance === self { Self.sharedInstance = nil ClerkNativeBridge.setAuthFlowChangedEmitter(nil) - ClerkNativeBridge.setClientChangedEmitter(nil) + ClerkNativeBridge.setClientInvalidatedEmitter(nil) } } - AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in - self.configure(publishableKey, bearerToken: bearerToken, promise: promise) + AsyncFunction("configureNative") { (publishableKey: String, seedDeviceToken: String?, promise: Promise) in + Task { @MainActor in + do { + try await ClerkNativeBridge.shared.configureNative( + publishableKey: publishableKey, + seedDeviceToken: seedDeviceToken + ) + promise.resolve() + } catch { + promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) + } + } } - AsyncFunction("getClientToken") { (promise: Promise) in - self.getClientToken(promise: promise) + AsyncFunction("getDeviceToken") { (promise: Promise) in + Task { @MainActor in + promise.resolve(ClerkNativeBridge.shared.getDeviceToken()) + } } - AsyncFunction("getAuthFlowState") { (promise: Promise) in - self.getAuthFlowState(promise: promise) + AsyncFunction("setDeviceToken") { (token: String?, expected: String?, promise: Promise) in + Task { @MainActor in + do { + let didSet = try await ClerkNativeBridge.shared.setDeviceToken(token, expected: expected) + promise.resolve(didSet) + } catch { + let descriptor = clerkSetDeviceTokenErrorDescriptor(error) + promise.reject(descriptor.code, descriptor.message) + } + } } - AsyncFunction("syncClientStateFromJs") { - (deviceToken: String?, - sourceId: String?, - didChangeClient: Bool, - didChangeDeviceToken: Bool, - promise: Promise) in - self.syncClientStateFromJs( - deviceToken, - sourceId: sourceId, - didChangeClient: didChangeClient, - didChangeDeviceToken: didChangeDeviceToken, - promise: promise - ) + AsyncFunction("refreshClient") { (promise: Promise) in + Task { @MainActor in + do { + try await ClerkNativeBridge.shared.refreshClient() + promise.resolve() + } catch ClerkClientSyncError.notConfigured { + promise.reject("E_NOT_CONFIGURED", ClerkClientSyncError.notConfigured.localizedDescription) + } catch { + promise.reject("E_REFRESH_CLIENT_FAILED", error.localizedDescription) + } + } + } + + AsyncFunction("getAuthFlowState") { (promise: Promise) in + self.getAuthFlowState(promise: promise) } AsyncFunction("getTrustedDeviceAvailability") { @@ -122,28 +144,6 @@ public class ClerkExpoModule: Module { } } - // MARK: - configure - - private func configure(_ publishableKey: String, bearerToken: String?, promise: Promise) { - Task { - do { - try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken) - promise.resolve() - } catch { - promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) - } - } - } - - // MARK: - getClientToken - - private func getClientToken(promise: Promise) { - Task { - let token = await ClerkNativeBridge.shared.getClientToken() - promise.resolve(token) - } - } - // MARK: - getAuthFlowState private func getAuthFlowState(promise: Promise) { @@ -153,28 +153,6 @@ public class ClerkExpoModule: Module { } } - // MARK: - syncClientStateFromJs - - private func syncClientStateFromJs(_ deviceToken: String?, - sourceId: String?, - didChangeClient: Bool, - didChangeDeviceToken: Bool, - promise: Promise) { - Task { - do { - try await ClerkNativeBridge.shared.syncClientStateFromJs( - deviceToken: deviceToken, - sourceId: sourceId, - didChangeClient: didChangeClient, - didChangeDeviceToken: didChangeDeviceToken - ) - promise.resolve() - } catch { - promise.reject("E_SYNC_FROM_JS_FAILED", error.localizedDescription) - } - } - } - // MARK: - Biometric credentials private func getBiometricCredentialAvailability(id: String?, identifierHint: String?, promise: Promise) { @@ -287,17 +265,13 @@ public class ClerkExpoModule: Module { promise.reject(descriptor.code, descriptor.message) } - /// Emits a native client change event to JS from anywhere in the native layer. - /// Used by native views to ask ClerkProvider to reload JS client state. - static func emitClientChanged(_ body: [String: Any]? = nil) { - let eventBody = body ?? [:] - + static func emitClientInvalidated() { guard let instance = sharedInstance else { return } DispatchQueue.main.async { [weak instance] in - instance?.sendEvent(Self.nativeClientChangedEvent, eventBody) + instance?.sendEvent(Self.nativeClientInvalidatedEvent, [:]) } } diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift similarity index 84% rename from packages/expo/ios/ClerkNativeBridge.swift rename to packages/expo-native-components/ios/ClerkNativeBridge.swift index bcb20cb8bfc..5c0ccbc5d19 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift @@ -397,7 +397,7 @@ func userProfileCustomPageLabel( private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)? -private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)? +private var clerkNativeClientInvalidatedEmitter: (() -> Void)? struct ClerkNativeErrorDescriptor { let code: String @@ -431,8 +431,6 @@ private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { final class ClerkNativeBridge { static let shared = ClerkNativeBridge() - private static let clerkLoadMaxAttempts = 30 - private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false private static var configuredPublishableKey: String? @@ -440,34 +438,21 @@ final class ClerkNativeBridge { var lightTheme: ClerkTheme? var darkTheme: ClerkTheme? - private var clientObservationGeneration = 0 - private var lastObservedClientState: ClientStateSnapshot? private var authFlowObservationGeneration = 0 private var lastObservedAuthFlowState: AuthFlowStateSnapshot? private var configurationDepth = 0 - private var jsOriginatedClientSyncDepth = 0 + private var clientInvalidationGeneration = 0 + private var clientInvalidationTracker: ClerkClientInvalidationTracker? private var pendingURL: URL? private var shouldFlushPendingURL = false private init() {} - private struct ClientStateSnapshot: Equatable { - let client: Client? - let deviceToken: String? - } - private struct AuthFlowStateSnapshot: Equatable { let isLoaded: Bool let isAuthFlowComplete: Bool } - private struct ClientStateChanges { - let client: Bool - let deviceToken: Bool - - static let all = ClientStateChanges(client: true, deviceToken: true) - } - /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. private static var keychainService: String? { @@ -478,65 +463,129 @@ final class ClerkNativeBridge { } @MainActor - func configure(publishableKey: String, bearerToken: String? = nil) async throws { - configurationDepth += 1 - defer { - lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil - let authFlowState = Self.authFlowStateSnapshot() - lastObservedAuthFlowState = authFlowState - configurationDepth = max(0, configurationDepth - 1) - Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState)) - - // Overlapping calls can finish out of order, so replay once the last one settles and any - // of them succeeded. A batch where every call threw keeps the URL for the next attempt. - if configurationDepth == 0, shouldFlushPendingURL { - shouldFlushPendingURL = false - flushPendingURL() - } + private func endConfiguration() { + let authFlowState = Self.authFlowStateSnapshot() + lastObservedAuthFlowState = authFlowState + configurationDepth = max(0, configurationDepth - 1) + Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState)) + + // Overlapping calls can finish out of order, so replay once the last one settles and any + // of them succeeded. A batch where every call threw keeps the URL for the next attempt. + if configurationDepth == 0, shouldFlushPendingURL { + shouldFlushPendingURL = false + flushPendingURL() } + } + + /// Configures ClerkKit without waiting for the client to load. ClerkKit's stored device token + /// wins; `seedDeviceToken` is only adopted when ClerkKit has none. + @MainActor + func configureNative(publishableKey: String, seedDeviceToken: String?) async throws { + configurationDepth += 1 + defer { endConfiguration() } loadThemes() + var didConfigure = true if Self.shouldReconfigure(for: publishableKey) { try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) - Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey - startClientObserver(reset: true) startAuthFlowObserver(reset: true) + } else if Self.clerkConfigured { + didConfigure = false + startAuthFlowObserver() + } else { + Self.clerkConfigured = true + Self.configuredPublishableKey = publishableKey + Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + startAuthFlowObserver() + } - let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) + let didAdoptSeed = try await Self.adoptSeedDeviceTokenIfNeeded(seedDeviceToken) + startClientInvalidationObserver(reset: didConfigure) + if didAdoptSeed { + // A client load started before the seed was stored may have been fenced off by the token change. + Task { @MainActor in + _ = try? await Clerk.shared.refreshClient() + } + } + if didConfigure { Self.postConfiguredNotification() - shouldFlushPendingURL = true - return } + shouldFlushPendingURL = true + } - if Self.clerkConfigured { - startClientObserver() - startAuthFlowObserver() - let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken) - if didUpdateDeviceToken { - await Self.waitForLoadedClient() - } else if let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - // A remounted JS runtime can have the same token while native client - // state is stale, so preserve one refresh in that case. - _ = try await Clerk.shared.refreshClient() - await Self.waitForLoadedClient() + @MainActor + private static func adoptSeedDeviceTokenIfNeeded(_ seedDeviceToken: String?) async throws -> Bool { + guard let seed = seedDeviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !seed.isEmpty, + Clerk.shared.deviceToken == nil + else { + return false + } + return try await Clerk.shared.setDeviceToken(seed, expected: nil) + } + + @MainActor + func getDeviceToken() -> String? { + guard Self.clerkConfigured else { return nil } + return Clerk.shared.deviceToken + } + + @MainActor + func setDeviceToken(_ token: String?, expected: String?) async throws -> Bool { + guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured } + + let didSet = try await Clerk.shared.setDeviceToken(token, expected: expected) + let fingerprint = Self.clientFingerprint() + if didSet { + clientInvalidationTracker?.acknowledgeDeviceToken(fingerprint.deviceToken, current: fingerprint) + } else { + clientInvalidationTracker?.observe(fingerprint) + } + return didSet + } + + @MainActor + func refreshClient() async throws { + guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured } + defer { recordClientFingerprint() } + _ = try await Clerk.shared.refreshClient() + } + + @MainActor + private func startClientInvalidationObserver(reset: Bool) { + let tracker = clientInvalidationTracker ?? ClerkClientInvalidationTracker { Self.emitClientInvalidated() } + clientInvalidationTracker = tracker + tracker.reset(to: Self.clientFingerprint()) + + guard reset || clientInvalidationGeneration == 0 else { return } + clientInvalidationGeneration += 1 + observeClientInvalidation(generation: clientInvalidationGeneration) + } + + @MainActor + private func observeClientInvalidation(generation: Int) { + withObservationTracking { + _ = Self.clientFingerprint() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + await Task.yield() + + guard let self, generation == self.clientInvalidationGeneration else { return } + self.recordClientFingerprint() + self.observeClientInvalidation(generation: generation) } - shouldFlushPendingURL = true - return } + } - Self.clerkConfigured = true - Self.configuredPublishableKey = publishableKey - Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) - startClientObserver() - startAuthFlowObserver() + @MainActor + private func recordClientFingerprint() { + clientInvalidationTracker?.observe(Self.clientFingerprint()) + } - let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) - await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) - Self.postConfiguredNotification() - shouldFlushPendingURL = true + @MainActor + private static func clientFingerprint() -> ClerkClientFingerprint { + ClerkClientFingerprint(client: Clerk.shared.client, deviceToken: Clerk.shared.deviceToken) } @MainActor @@ -549,7 +598,7 @@ final class ClerkNativeBridge { /// `AuthView` only reaches `Clerk.handle(_:)` from `.onOpenURL`, which never fires for a UIKit-hosted controller. @MainActor func handle(url: URL) { - // A cold launch delivers the callback before, or partway through, JS calling `configure`. + // A cold launch delivers the callback before, or partway through, JS calling `configureNative`. guard Self.clerkConfigured, configurationDepth == 0 else { pendingURL = url return @@ -564,47 +613,6 @@ final class ClerkNativeBridge { } } - @MainActor - private func startClientObserver(reset: Bool = false) { - guard reset || clientObservationGeneration == 0 else { - return - } - - clientObservationGeneration += 1 - let generation = clientObservationGeneration - lastObservedClientState = Self.clientStateSnapshot() - observeClient(generation: generation) - } - - @MainActor - private func observeClient(generation: Int) { - withObservationTracking { - _ = Self.clientStateSnapshot() - } onChange: { [weak self] in - Task { @MainActor [weak self] in - await Task.yield() - - guard let self, generation == self.clientObservationGeneration else { return } - - let newClientState = Self.clientStateSnapshot() - if let previousClientState = self.lastObservedClientState, newClientState != previousClientState { - self.lastObservedClientState = newClientState - if self.configurationDepth == 0, self.jsOriginatedClientSyncDepth == 0 { - let payload = Self.clientChangedPayload( - changes: .init( - client: newClientState.client != previousClientState.client, - deviceToken: newClientState.deviceToken != previousClientState.deviceToken - ) - ) - Self.emitClientChanged(payload) - } - } - - self.observeClient(generation: generation) - } - } - } - @MainActor private func startAuthFlowObserver(reset: Bool = false) { guard reset || authFlowObservationGeneration == 0 else { @@ -659,46 +667,6 @@ final class ClerkNativeBridge { ] } - @MainActor - private static func clientStateSnapshot() -> ClientStateSnapshot { - let client = Clerk.shared.client - - return ClientStateSnapshot( - client: client, - deviceToken: Clerk.shared.deviceToken - ) - } - - @MainActor - private static func clientChangedPayload(sourceId: String? = nil, changes: ClientStateChanges = .all) -> [String: Any] { - var payload: [String: Any] = [:] - payload["changed"] = [ - "client": changes.client, - "deviceToken": changes.deviceToken, - ] - payload["deviceToken"] = Clerk.shared.deviceToken ?? NSNull() - if let sourceId, !sourceId.isEmpty { - payload["sourceId"] = sourceId - } - - return payload - } - - @MainActor - private static func syncTokenState(bearerToken: String?) async throws -> Bool { - await waitForLoadedClient() - - guard let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty - else { - return false - } - guard Clerk.shared.deviceToken != token || Clerk.shared.client == nil else { - return false - } - _ = try await Clerk.shared.updateDeviceToken(token) - return true - } - private static func shouldReconfigure(for publishableKey: String) -> Bool { guard clerkConfigured, let configuredPublishableKey else { return false } return configuredPublishableKey != publishableKey @@ -712,30 +680,6 @@ final class ClerkNativeBridge { return .init(keychainConfig: .init(service: service), middleware: middleware) } - @MainActor - private static func waitForLoadedClient() async { - // Wait for Clerk to finish loading client state from cached data + API refresh. - // The bridge sync contract is device-token based, not session based. - for _ in 0.. String? { - guard Self.clerkConfigured else { return nil } - return Clerk.shared.deviceToken - } - @MainActor func getAuthFlowState() -> [String: Any] { Self.authFlowStatePayload(Self.authFlowStateSnapshot()) @@ -1076,76 +1020,23 @@ final class ClerkNativeBridge { ) } - @MainActor - func syncClientStateFromJs( - deviceToken: String?, - sourceId: String?, - didChangeClient: Bool, - didChangeDeviceToken: Bool - ) async throws { - guard Self.clerkConfigured else { return } - - let previousClientState = Self.clientStateSnapshot() - var completedSuccessfully = false - jsOriginatedClientSyncDepth += 1 - defer { - let finalClientState = Self.clientStateSnapshot() - lastObservedClientState = finalClientState - jsOriginatedClientSyncDepth = max(0, jsOriginatedClientSyncDepth - 1) - - if !completedSuccessfully, finalClientState != previousClientState { - Self.emitClientChanged( - Self.clientChangedPayload( - changes: .init( - client: finalClientState.client != previousClientState.client, - deviceToken: finalClientState.deviceToken != previousClientState.deviceToken - ) - ) - ) - } - } - - var refreshedClientWhileUpdatingToken = false - - if didChangeDeviceToken, - let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty - { - if Clerk.shared.deviceToken != token { - _ = try await Clerk.shared.updateDeviceToken(token) - await Self.waitForLoadedClient() - refreshedClientWhileUpdatingToken = true - } - } - - if !refreshedClientWhileUpdatingToken, didChangeClient || didChangeDeviceToken { - _ = try await Clerk.shared.refreshClient() - await Self.waitForLoadedClient() - } - - let newClientState = Self.clientStateSnapshot() - lastObservedClientState = newClientState - Self.emitClientChanged( - Self.clientChangedPayload( - sourceId: sourceId, - changes: .init( - client: newClientState.client != previousClientState.client, - deviceToken: newClientState.deviceToken != previousClientState.deviceToken - ) - ) - ) - completedSuccessfully = true - } - private static func postConfiguredNotification() { NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil) } - static func setClientChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) { + static func setClientInvalidatedEmitter(_ emitter: (() -> Void)?) { clerkNativeClientEventQueue.sync { - clerkNativeClientChangedEmitter = emitter + clerkNativeClientInvalidatedEmitter = emitter } } + static func emitClientInvalidated() { + let emitter = clerkNativeClientEventQueue.sync { + clerkNativeClientInvalidatedEmitter + } + emitter?() + } + static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) { clerkNativeClientEventQueue.sync { clerkNativeAuthFlowChangedEmitter = emitter @@ -1159,14 +1050,6 @@ final class ClerkNativeBridge { emitter?(body) } - /// Requests that ClerkProvider reload the JS client from native client state. - static func emitClientChanged(_ body: [String: Any]? = nil) { - let emitter = clerkNativeClientEventQueue.sync { - clerkNativeClientChangedEmitter - } - emitter?(body) - } - private static func authMode(from mode: String) -> AuthView.Mode { switch mode { case "signIn": diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo-native-components/ios/ClerkNativeViewHost.swift similarity index 100% rename from packages/expo/ios/ClerkNativeViewHost.swift rename to packages/expo-native-components/ios/ClerkNativeViewHost.swift diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo-native-components/ios/ClerkUserButtonNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkUserButtonNativeView.swift rename to packages/expo-native-components/ios/ClerkUserButtonNativeView.swift diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkUserProfileNativeView.swift rename to packages/expo-native-components/ios/ClerkUserProfileNativeView.swift diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift rename to packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift diff --git a/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift new file mode 100644 index 00000000000..d5bc3ba9209 --- /dev/null +++ b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift @@ -0,0 +1,143 @@ +import XCTest +@_spi(FrameworkIntegration) import ClerkKit +@testable import ClerkExpo + +@MainActor +final class ClerkClientInvalidationTests: XCTestCase { + private var scheduled: [@MainActor () -> Void] = [] + private var emitCount = 0 + + private func makeTracker() -> ClerkClientInvalidationTracker { + ClerkClientInvalidationTracker( + schedule: { [unowned self] work in self.scheduled.append(work) }, + emit: { [unowned self] in self.emitCount += 1 } + ) + } + + private func runLoopTurn() { + let work = scheduled + scheduled.removeAll() + work.forEach { $0() } + } + + private func fingerprint( + clientId: String? = "client_1", + sessions: [ClerkClientFingerprint.SessionState] = [.init(id: "sess_1", status: "active")], + userUpdatedAt: Double? = 1, + deviceToken: String? = "token_1" + ) -> ClerkClientFingerprint { + ClerkClientFingerprint( + clientId: clientId, + lastActiveSessionId: sessions.first?.id, + sessions: sessions, + activeUserId: sessions.isEmpty ? nil : "user_1", + activeUserUpdatedAt: sessions.isEmpty ? nil : userUpdatedAt, + deviceToken: deviceToken + ) + } + + override func setUp() { + super.setUp() + scheduled = [] + emitCount = 0 + } + + func testDoesNotEmitBeforeBaselineOrForUnchangedState() { + let tracker = makeTracker() + tracker.observe(fingerprint()) + XCTAssertTrue(scheduled.isEmpty) + + tracker.reset(to: fingerprint()) + tracker.observe(fingerprint()) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + } + + func testEmitsForEachFingerprintField() { + let changes = [ + fingerprint(clientId: "client_2"), + fingerprint(sessions: [.init(id: "sess_1", status: "ended")]), + fingerprint(sessions: []), + fingerprint(userUpdatedAt: 2), + fingerprint(deviceToken: "token_2"), + ] + + for change in changes { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + emitCount = 0 + tracker.observe(change) + runLoopTurn() + XCTAssertEqual(emitCount, 1, "\(change)") + } + } + + func testCoalescesChangesWithinOneLoopTurn() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.observe(fingerprint(deviceToken: "token_2")) + tracker.observe(fingerprint(clientId: "client_2", deviceToken: "token_2")) + tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3")) + XCTAssertEqual(scheduled.count, 1) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + + tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + + tracker.observe(fingerprint(clientId: "client_4", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 2) + } + + func testChangeRevertedWithinOneLoopTurnDoesNotEmit() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.observe(fingerprint(clientId: "client_2")) + tracker.observe(fingerprint()) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + } + + func testAcknowledgedDeviceTokenDoesNotEcho() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.acknowledgeDeviceToken("token_2", current: fingerprint(deviceToken: "token_2")) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + + tracker.acknowledgeDeviceToken("token_3", current: fingerprint(clientId: "client_2", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + } + + func testSetDeviceTokenErrorCodes() { + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(ClerkClientSyncError.notConfigured).code, "E_NOT_CONFIGURED") + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.emptyToken).code, "E_INVALID_DEVICE_TOKEN") + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(CancellationError()).code, "E_CANCELLED") + XCTAssertEqual( + clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.updateRejected).code, + "E_SET_DEVICE_TOKEN_FAILED" + ) + } + + func testSyncFunctionsRejectOrReturnNilBeforeConfiguration() async { + XCTAssertNil(ClerkNativeBridge.shared.getDeviceToken()) + do { + _ = try await ClerkNativeBridge.shared.setDeviceToken("token", expected: nil) + XCTFail("Expected setDeviceToken to throw") + } catch { + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(error).code, "E_NOT_CONFIGURED") + } + do { + try await ClerkNativeBridge.shared.refreshClient() + XCTFail("Expected refreshClient to throw") + } catch { + XCTAssertTrue(error is ClerkClientSyncError) + } + } +} diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkNativeBridgeTests.swift rename to packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift diff --git a/packages/expo/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkNativeViewHostTests.swift rename to packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift diff --git a/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift rename to packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift diff --git a/packages/expo-native-components/package.json b/packages/expo-native-components/package.json new file mode 100644 index 00000000000..0d6f430f012 --- /dev/null +++ b/packages/expo-native-components/package.json @@ -0,0 +1,82 @@ +{ + "name": "@clerk/expo-native-components", + "version": "0.0.1", + "description": "Native Clerk components and native SDK integration for Expo, powered by clerk-ios and clerk-android", + "keywords": [ + "react", + "react-native", + "expo", + "clerk", + "auth", + "authentication", + "native" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/expo-native-components" + }, + "license": "MIT", + "author": "Clerk", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./app.plugin.js": "./app.plugin.js", + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "android", + "ios", + "src/specs", + "expo-module.config.json", + "react-native.config.js", + "app.plugin.js", + "app.plugin.d.ts" + ], + "scripts": { + "build": "tsdown", + "build:declarations": "tsc -p tsconfig.declarations.json", + "clean": "rimraf ./dist", + "dev": "tsdown --watch", + "dev:pub": "pnpm dev -- --env.publish", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "test": "vitest run", + "test:watch": "vitest watch" + }, + "dependencies": { + "@clerk/react": "workspace:^", + "tslib": "catalog:repo" + }, + "devDependencies": { + "@expo/config-plugins": "^54.0.4", + "react-native": "^0.86.0" + }, + "peerDependencies": { + "expo": "catalog:peer-expo", + "react": "^18.0.0 || ^19.0.0", + "react-native": ">=0.75" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + }, + "codegenConfig": { + "name": "ClerkExpoSpec", + "type": "all", + "jsSrcsDir": "src/specs" + } +} diff --git a/packages/expo/react-native.config.js b/packages/expo-native-components/react-native.config.js similarity index 100% rename from packages/expo/react-native.config.js rename to packages/expo-native-components/react-native.config.js diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo-native-components/src/AuthView.tsx similarity index 86% rename from packages/expo/src/native/AuthView.tsx rename to packages/expo-native-components/src/AuthView.tsx index 3f4b51ee346..99e1d395348 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo-native-components/src/AuthView.tsx @@ -2,9 +2,9 @@ import { useCallback } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { Text, View } from 'react-native'; -import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { isNativeSupported } from '../utils/native-module'; import type { AuthViewProps } from './AuthView.types'; +import NativeClerkAuthView from './specs/NativeClerkAuthView'; +import { isNativeSupported } from './utils/native-module'; type AuthNativeEvent = NativeSyntheticEvent>; @@ -24,7 +24,7 @@ type AuthNativeEvent = NativeSyntheticEvent>; * * @example * ```tsx - * import { AuthView } from '@clerk/expo/native'; + * import { AuthView } from '@clerk/expo-native-components'; * import { useAuth } from '@clerk/expo'; * * export default function SignInScreen() { @@ -63,7 +63,7 @@ export function AuthView({ {!isNativeSupported ? 'Native AuthView is only available on iOS and Android' - : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + : 'Native AuthView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'} ); diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts similarity index 100% rename from packages/expo/src/native/AuthView.types.ts rename to packages/expo-native-components/src/AuthView.types.ts diff --git a/packages/expo/src/native/EmbeddedNavigation.types.ts b/packages/expo-native-components/src/EmbeddedNavigation.types.ts similarity index 100% rename from packages/expo/src/native/EmbeddedNavigation.types.ts rename to packages/expo-native-components/src/EmbeddedNavigation.types.ts diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo-native-components/src/UserButton.tsx similarity index 94% rename from packages/expo/src/native/UserButton.tsx rename to packages/expo-native-components/src/UserButton.tsx index a1406f0652e..fc2504781b2 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo-native-components/src/UserButton.tsx @@ -3,8 +3,7 @@ import { useMemo, useRef } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { StyleSheet, useWindowDimensions } from 'react-native'; -import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; -import { isNativeSupported } from '../utils/native-module'; +import NativeClerkUserButtonView from './specs/NativeClerkUserButtonView'; import type { NativeUserProfileNavigationHandle, UserProfileCustomDestination, @@ -16,6 +15,7 @@ import { UserProfileCustomPageHosts, useUserProfileCustomPages, } from './UserProfileCustomPages'; +import { isNativeSupported } from './utils/native-module'; type CustomizableNativeUserButtonProps = ComponentProps> & { customPages?: string; @@ -47,7 +47,7 @@ export interface UserButtonProps { * * @example * ```tsx - * import { UserButton } from '@clerk/expo/native'; + * import { UserButton } from '@clerk/expo-native-components'; * * export default function Home() { * return ( diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo-native-components/src/UserProfileCustomPages.tsx similarity index 100% rename from packages/expo/src/native/UserProfileCustomPages.tsx rename to packages/expo-native-components/src/UserProfileCustomPages.tsx diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo-native-components/src/UserProfileView.tsx similarity index 92% rename from packages/expo/src/native/UserProfileView.tsx rename to packages/expo-native-components/src/UserProfileView.tsx index 3c39d955375..eb3e59bee96 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo-native-components/src/UserProfileView.tsx @@ -3,9 +3,8 @@ import { useCallback, useMemo, useRef } from 'react'; import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, Text, View } from 'react-native'; -import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { isNativeSupported } from '../utils/native-module'; import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; +import NativeClerkUserProfileView from './specs/NativeClerkUserProfileView'; import type { NativeUserProfileNavigationHandle, UserProfileCustomDestination, @@ -17,6 +16,7 @@ import { UserProfileCustomPageHosts, useUserProfileCustomPages, } from './UserProfileCustomPages'; +import { isNativeSupported } from './utils/native-module'; type CustomizableNativeUserProfileProps = ComponentProps> & { customPages?: string; @@ -75,7 +75,7 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps { * * @example * ```tsx - * import { UserProfileView } from '@clerk/expo/native'; + * import { UserProfileView } from '@clerk/expo-native-components'; * import { useAuth } from '@clerk/expo'; * * export default function ProfileScreen() { @@ -128,7 +128,7 @@ export function UserProfileView({ {!isNativeSupported ? 'Native UserProfileView is only available on iOS and Android' - : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + : 'Native UserProfileView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'} ); diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo-native-components/src/__tests__/AuthView.test.tsx similarity index 94% rename from packages/expo/src/native/__tests__/AuthView.test.tsx rename to packages/expo-native-components/src/__tests__/AuthView.test.tsx index 6b3029ecdda..528e251fb2e 100644 --- a/packages/expo/src/native/__tests__/AuthView.test.tsx +++ b/packages/expo-native-components/src/__tests__/AuthView.test.tsx @@ -10,13 +10,13 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../specs/NativeClerkAuthView', () => { +vi.mock('../specs/NativeClerkAuthView', () => { return { default: mocks.NativeClerkAuthView, }; }); -vi.mock('../../utils/native-module', () => { +vi.mock('../utils/native-module', () => { return { isNativeSupported: true, }; diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo-native-components/src/__tests__/UserButton.test.tsx similarity index 94% rename from packages/expo/src/native/__tests__/UserButton.test.tsx rename to packages/expo-native-components/src/__tests__/UserButton.test.tsx index d4eaef80382..d414aa0f89f 100644 --- a/packages/expo/src/native/__tests__/UserButton.test.tsx +++ b/packages/expo-native-components/src/__tests__/UserButton.test.tsx @@ -8,14 +8,14 @@ const mocks = vi.hoisted(() => ({ nativeProps: vi.fn(), })); -vi.mock('../../specs/NativeClerkUserButtonView', () => ({ +vi.mock('../specs/NativeClerkUserButtonView', () => ({ default: React.forwardRef((props: Record, _ref) => { mocks.nativeProps(props); return null; }), })); -vi.mock('../../utils/native-module', () => ({ +vi.mock('../utils/native-module', () => ({ isNativeSupported: true, })); diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx similarity index 100% rename from packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx rename to packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx similarity index 98% rename from packages/expo/src/native/__tests__/UserProfileView.test.tsx rename to packages/expo-native-components/src/__tests__/UserProfileView.test.tsx index 34af7275e6d..619c417305e 100644 --- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx +++ b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../specs/NativeClerkUserProfileView', () => { +vi.mock('../specs/NativeClerkUserProfileView', () => { return { default: React.forwardRef((props: { children?: React.ReactNode }, ref) => { React.useImperativeHandle(ref, () => ({ navigateCustomPage: mocks.navigateCustomPage })); @@ -24,7 +24,7 @@ vi.mock('../../specs/NativeClerkUserProfileView', () => { }; }); -vi.mock('../../utils/native-module', () => { +vi.mock('../utils/native-module', () => { return { isNativeSupported: true, }; diff --git a/packages/expo-native-components/src/__tests__/appPlugin.test.js b/packages/expo-native-components/src/__tests__/appPlugin.test.js new file mode 100644 index 00000000000..bbb2d247250 --- /dev/null +++ b/packages/expo-native-components/src/__tests__/appPlugin.test.js @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { withClerkExpoNativeComponents } = require('../../app.plugin.js')._testing; + +const applyMod = (config, platform, mod, modResults) => + config.mods[platform][mod]({ ...config, modRequest: {}, modResults }); + +describe('withClerkExpoNativeComponents', () => { + let clerkExpoDir; + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + clerkExpoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(clerkExpoDir, { recursive: true, force: true }); + }); + + test('reports the installed @clerk/expo version on iOS and Android', async () => { + const config = withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({ + dir: clerkExpoDir, + version: '4.8.0', + })); + + const infoPlist = await applyMod(config, 'ios', 'infoPlist', {}); + const gradleProperties = await applyMod(config, 'android', 'gradleProperties', [ + { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.7.0' }, + ]); + + expect(infoPlist.modResults.ClerkExpoVersion).toBe('4.8.0'); + expect(gradleProperties.modResults).toEqual([ + { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.8.0' }, + ]); + }); + + test('throws when the installed @clerk/expo still bundles the native module', () => { + fs.writeFileSync(path.join(clerkExpoDir, 'expo-module.config.json'), '{}'); + + expect(() => + withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({ + dir: clerkExpoDir, + version: '4.7.1', + })), + ).toThrow('@clerk/expo@4.7.1 still bundles the Clerk native module'); + }); +}); diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js similarity index 71% rename from packages/expo/src/__tests__/appPlugin.theme.test.js rename to packages/expo-native-components/src/__tests__/appPlugin.theme.test.js index fcff7577b0e..c0f89d02ab8 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js @@ -2,54 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export const clerkPlugin = require('../../app.plugin.js'); -const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing; - -function applyInfoPlistMod(config, modResults) { - return config.mods.ios.infoPlist({ - ...config, - modRequest: {}, - modResults, - }); -} - -describe('withClerkFaceIDPermission', () => { - test('adds the configured Face ID usage description', async () => { - const config = withClerkFaceIDPermission( - { name: 'test', slug: 'test' }, - { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' }, - ); - - const result = await applyInfoPlistMod(config, {}); - - expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.'); - }); - - test('preserves an app-provided Face ID usage description', async () => { - const config = withClerkFaceIDPermission( - { name: 'test', slug: 'test' }, - { faceIDPermission: 'Clerk-provided description' }, - ); - - const result = await applyInfoPlistMod(config, { - NSFaceIDUsageDescription: 'App-provided description', - }); - - expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description'); - }); - - test('does not configure the Info.plist without an explicit permission description', () => { - const config = { name: 'test', slug: 'test' }; - - expect(withClerkFaceIDPermission(config)).toBe(config); - expect(config).not.toHaveProperty('mods'); - }); - - test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => { - expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow( - 'faceIDPermission must be a non-empty string', - ); - }); -}); +const { validateThemeJson } = clerkPlugin._testing; describe('validateThemeJson', () => { beforeEach(() => { diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx similarity index 97% rename from packages/expo/src/native/__tests__/useAuthViewState.test.tsx rename to packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx index 988f2a57729..7b16d1816ef 100644 --- a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx +++ b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types'; +import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types'; import { useAuthViewState } from '../useAuthViewState'; const mocks = vi.hoisted(() => ({ @@ -13,11 +13,11 @@ const mocks = vi.hoisted(() => ({ remove: vi.fn(), })); -vi.mock('../../hooks/useAuth', () => ({ +vi.mock('@clerk/react', () => ({ useAuth: () => mocks.auth, })); -vi.mock('../../utils/native-module', () => ({ +vi.mock('../utils/native-module', () => ({ get ClerkExpoModule() { return mocks.module; }, diff --git a/packages/expo-native-components/src/index.ts b/packages/expo-native-components/src/index.ts new file mode 100644 index 00000000000..c102a43da33 --- /dev/null +++ b/packages/expo-native-components/src/index.ts @@ -0,0 +1,49 @@ +/** + * Native UI components for Clerk authentication in Expo apps. + * + * These components provide pre-built, native authentication experiences powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * ## Installation + * + * Native components require the `@clerk/expo-native-components` config plugin in your `app.json`, alongside `@clerk/expo`: + * + * ```json + * { + * "expo": { + * "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + * } + * } + * ``` + * + * Then run `npx expo prebuild` to generate native code. + * + * ## Components + * + * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline + * - {@link UserProfileView} - User profile and account management, renders inline + * - {@link UserButton} - Avatar button that opens the native user profile + * + * @module @clerk/expo-native-components + */ + +export { AuthView } from './AuthView'; +export type { AuthViewProps, AuthViewMode } from './AuthView.types'; +export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; +export { useAuthViewState } from './useAuthViewState'; +export type { UseAuthViewStateReturn } from './useAuthViewState'; +export { UserButton } from './UserButton'; +export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton'; +export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages'; +export type { + UserProfileCustomPageNavigation, + UserProfileCustomDestination, + UserProfileCustomPage, + UserProfileCustomPageIcon, + UserProfileCustomPagePlacement, + UserProfileRow, + UserProfileSection, +} from './UserProfileCustomPages'; +export { UserProfileView } from './UserProfileView'; +export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkAuthView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkAuthView.ts rename to packages/expo-native-components/src/specs/NativeClerkAuthView.ts diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.ts b/packages/expo-native-components/src/specs/NativeClerkModule.ts new file mode 100644 index 00000000000..072d011f539 --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.ts @@ -0,0 +1,6 @@ +import { requireOptionalNativeModule } from 'expo'; + +import type { Spec } from './NativeClerkModule.types'; + +// Optional so it resolves to null in Expo Go instead of throwing at import time. +export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.types.ts b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts new file mode 100644 index 00000000000..9df94448a91 --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts @@ -0,0 +1,9 @@ +export type NativeAuthFlowState = { + isLoaded: boolean; + isAuthFlowComplete: boolean; +}; + +export interface Spec { + addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; + getAuthFlowState?(): Promise; +} diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.web.ts b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts new file mode 100644 index 00000000000..625a2ed22ae --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts @@ -0,0 +1,3 @@ +import type { Spec } from './NativeClerkModule.types'; + +export default null as Spec | null; diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserButtonView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserButtonView.ts rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserProfileView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserProfileView.ts rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts diff --git a/packages/expo/src/specs/__tests__/native-view.web.test.ts b/packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts similarity index 100% rename from packages/expo/src/specs/__tests__/native-view.web.test.ts rename to packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts diff --git a/packages/expo/src/native/useAuthViewState.ts b/packages/expo-native-components/src/useAuthViewState.ts similarity index 94% rename from packages/expo/src/native/useAuthViewState.ts rename to packages/expo-native-components/src/useAuthViewState.ts index ecb793f84f1..c32f8e3e637 100644 --- a/packages/expo/src/native/useAuthViewState.ts +++ b/packages/expo-native-components/src/useAuthViewState.ts @@ -1,8 +1,8 @@ +import { useAuth } from '@clerk/react'; import { useEffect, useState } from 'react'; -import { useAuth } from '../hooks/useAuth'; -import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types'; -import { ClerkExpoModule as ClerkExpo } from '../utils/native-module'; +import type { NativeAuthFlowState } from './specs/NativeClerkModule.types'; +import { ClerkExpoModule as ClerkExpo } from './utils/native-module'; const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged'; diff --git a/packages/expo-native-components/src/utils/native-module.ts b/packages/expo-native-components/src/utils/native-module.ts new file mode 100644 index 00000000000..a416ccd8917 --- /dev/null +++ b/packages/expo-native-components/src/utils/native-module.ts @@ -0,0 +1,7 @@ +import { Platform } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; + +export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +export const ClerkExpoModule = isNativeSupported ? NativeClerkModule : null; diff --git a/packages/expo-native-components/tsconfig.declarations.json b/packages/expo-native-components/tsconfig.declarations.json new file mode 100644 index 00000000000..ac04a85ce27 --- /dev/null +++ b/packages/expo-native-components/tsconfig.declarations.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "incremental": false, + "skipLibCheck": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "sourceMap": false, + "declarationDir": "./dist" + }, + "include": ["src"], + "exclude": ["**/__tests__/**/*", "app.plugin.js"] +} diff --git a/packages/expo-native-components/tsconfig.json b/packages/expo-native-components/tsconfig.json new file mode 100644 index 00000000000..5510c6beb95 --- /dev/null +++ b/packages/expo-native-components/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "outDir": "dist", + "lib": ["es2019", "dom"], + "jsx": "react-jsx", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "importHelpers": true, + "declaration": true, + "declarationMap": false, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "target": "ES2019", + "noEmitOnError": false, + "incremental": true, + "moduleSuffixes": [".web", ".ios", ".android", ".native", ""] + }, + "include": ["src", "app.plugin.js"] +} diff --git a/packages/expo-native-components/tsconfig.test.json b/packages/expo-native-components/tsconfig.test.json new file mode 100644 index 00000000000..5635d6cd1b7 --- /dev/null +++ b/packages/expo-native-components/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + } +} diff --git a/packages/expo-native-components/tsdown.config.mts b/packages/expo-native-components/tsdown.config.mts new file mode 100644 index 00000000000..e84f7249174 --- /dev/null +++ b/packages/expo-native-components/tsdown.config.mts @@ -0,0 +1,33 @@ +import type { Options } from 'tsdown'; +import { defineConfig } from 'tsdown'; + +import { runAfterLast } from '../../scripts/utils.ts'; + +function preserveRelativeImports(id: string) { + return id.startsWith('.'); +} + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + const shouldPublish = !!overrideOptions.env?.publish; + + const options: Options = { + format: 'cjs', + fixedExtension: false, + outDir: './dist', + entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx,js}', '!./src/**/__tests__/**'], + unbundle: true, + clean: true, + minify: false, + sourcemap: true, + deps: { + // Keep relative import specifiers unchanged so Metro can apply platform-specific resolution. + neverBundle: preserveRelativeImports, + }, + define: { + __DEV__: `${isWatch}`, + }, + }; + + return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(options); +}); diff --git a/packages/expo-native-components/vitest.config.mts b/packages/expo-native-components/vitest.config.mts new file mode 100644 index 00000000000..20bc22a33db --- /dev/null +++ b/packages/expo-native-components/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { + environment: 'jsdom', + includeSource: ['**/*.{js,ts,jsx,tsx}'], + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/expo-native-components/vitest.setup.mts b/packages/expo-native-components/vitest.setup.mts new file mode 100644 index 00000000000..283c324f550 --- /dev/null +++ b/packages/expo-native-components/vitest.setup.mts @@ -0,0 +1,22 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, vi } from 'vitest'; + +// Mock globalThis.expo for expo-modules-core +if (!globalThis.expo) { + // @ts-expect-error - Mocking expo for tests + globalThis.expo = { + EventEmitter: vi.fn(), + }; +} + +// Define __DEV__ for expo-modules-core +if (typeof globalThis.__DEV__ === 'undefined') { + // @ts-expect-error - Mocking __DEV__ for tests + globalThis.__DEV__ = false; +} + +beforeAll(() => {}); + +afterEach(() => { + cleanup(); +}); diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index f8483877ff3..ecba866b513 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -1,27 +1,15 @@ /** - * Expo config plugin for @clerk/clerk-expo - * Automatically configures iOS and Android to work with Clerk native components + * Expo config plugin for @clerk/expo * * When this plugin is used: - * 1. iOS is configured with the required deployment target and metadata - * 2. Android is configured with packaging exclusions for dependencies - * - * Native modules and views are registered via Expo Modules autolinking. + * 1. Android registers the hosted auth callback intent filter + * 2. iOS gets the Sign in with Apple entitlement and, when configured, the Face ID usage description + * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ -const { - AndroidConfig, - withXcodeProject, - withDangerousMod, - withInfoPlist, - withAppBuildGradle, - withAndroidManifest, - withEntitlementsPlist, -} = require('@expo/config-plugins'); -const path = require('path'); -const fs = require('fs'); -const packageJson = require('./package.json'); +const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); -const CLERK_MIN_IOS_VERSION = '17.0'; +const CLERK_EXPO_NATIVE = '@clerk/expo-native-components'; +const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme']; const addHostedAuthIntentFilter = (mainActivity, packageName) => { const callbackHost = `${packageName}.hosted-callback`; @@ -52,80 +40,8 @@ const addHostedAuthIntentFilter = (mainActivity, packageName) => { mainActivity['intent-filter'] = intentFilters; }; -const withClerkIOS = config => { - console.log('✅ Clerk iOS plugin loaded'); - - // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install - // This ensures ClerkExpo pod gets installed (it requires iOS 17.0) - config = withDangerousMod(config, [ - 'ios', - async config => { - const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json'); - - let properties = {}; - if (fs.existsSync(podfilePropertiesPath)) { - try { - properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8')); - } catch { - // If file exists but is invalid JSON, start fresh - } - } - - // Set the iOS deployment target - if ( - !properties['ios.deploymentTarget'] || - parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION) - ) { - properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION; - fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n'); - console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`); - } - - return config; - }, - ]); - - // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK) - config = withXcodeProject(config, config => { - const xcodeProject = config.modResults; - - try { - // Update deployment target in all build configurations - const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {}; - - for (const [uuid, buildConfig] of Object.entries(buildConfigs)) { - if (buildConfig && buildConfig.buildSettings) { - const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET; - if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) { - buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION; - } - } - } - - console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`); - } catch (error) { - console.error('❌ Error updating deployment target:', error.message); - } - - return config; - }); - - config = withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkExpoVersion = packageJson.version; - return modConfig; - }); - - return config; -}; - -/** - * Add packaging exclusions to Android app build.gradle to resolve - * duplicate META-INF file conflicts from clerk-android dependencies. - */ -const withClerkAndroid = config => { - console.log('✅ Clerk Android plugin loaded'); - - config = withAndroidManifest(config, modConfig => { +const withClerkHostedAuthCallback = config => { + return withAndroidManifest(config, modConfig => { const packageName = config.android?.package; if (packageName) { const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults); @@ -133,83 +49,6 @@ const withClerkAndroid = config => { } return modConfig; }); - - return withAppBuildGradle(config, modConfig => { - let buildGradle = modConfig.modResults.contents; - - // --- META-INF exclusion --- - if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { - // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions` - const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/); - if (packagingMatch) { - const blockName = packagingMatch[0].trim().replace(/\s*\{$/, ''); - const resourcesExclude = `${blockName} { - // Clerk Android SDK: exclude duplicate META-INF files - resources { - excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] - }`; - - buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude); - } else { - // No packaging block found; append one at the end of the android block - const androidBlockEnd = buildGradle.lastIndexOf('}'); - if (androidBlockEnd !== -1) { - const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`; - buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd); - } - } - console.log('✅ Clerk Android packaging exclusions added'); - } - - // --- Kotlin metadata version check skip --- - if (!buildGradle.includes('-Xskip-metadata-version-check')) { - const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/); - if (kotlinOptionsMatch) { - buildGradle = buildGradle.replace( - /kotlinOptions\s*\{/, - `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`, - ); - } else { - const androidMatch = buildGradle.match(/android\s*\{/); - if (androidMatch) { - buildGradle = buildGradle.replace( - /android\s*\{/, - `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`, - ); - } - } - console.log('✅ Clerk Android Kotlin metadata version check skip added'); - } - - modConfig.modResults.contents = buildGradle; - return modConfig; - }); -}; - -/** - * Combined Clerk Expo plugin - * - * When this plugin is configured in app.json/app.config.js: - * 1. iOS gets the deployment target and metadata required by Clerk native views - * 2. Android gets packaging exclusions for dependency conflicts - * - * Native modules and views are registered via Expo Modules autolinking. - */ -/** - * Write ClerkKeychainService to Info.plist when keychainService is provided. - * This allows extension apps (watch, widget, app clip) to share the same - * keychain entry as the main app by using a custom service identifier. - */ -const withClerkKeychainService = (config, { keychainService } = {}) => { - if (!keychainService) { - return config; - } - - return withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkKeychainService = keychainService; - console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`); - return modConfig; - }); }; const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => { @@ -243,145 +82,75 @@ const withClerkAppleSignIn = config => { }); }; -/** - * Apply a custom theme to Clerk native components (iOS + Android). - * - * Accepts a `theme` prop pointing to a JSON file with optional keys: - * - colors: { primary, background, input, danger, success, warning, - * foreground, mutedForeground, primaryForeground, inputForeground, - * neutral, border, ring, muted, shadow, secondaryButtonBackground, - * secondaryButtonForeground } (hex color strings) - * - darkColors: same keys as colors (for dark mode) - * - design: { fontFamily: string, borderRadius: number } - * - * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". - * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. - */ -const VALID_COLOR_KEYS = [ - 'primary', - 'background', - 'input', - 'danger', - 'success', - 'warning', - 'foreground', - 'mutedForeground', - 'primaryForeground', - 'inputForeground', - 'neutral', - 'border', - 'ring', - 'muted', - 'shadow', - 'secondaryButtonBackground', - 'secondaryButtonForeground', -]; - -const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; - -function isPlainObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function validateThemeJson(theme) { - if (!isPlainObject(theme)) { - throw new Error('Clerk theme: theme JSON must be a plain object'); +const resolveClerkExpoNativePlugin = config => { + try { + const paths = [config._internal?.projectRoot, process.cwd()].filter(Boolean); + return require(require.resolve(`${CLERK_EXPO_NATIVE}/app.plugin.js`, { paths })); + } catch { + return null; } +}; - const validateColors = (colors, label) => { - if (!isPlainObject(colors)) { - throw new Error(`Clerk theme: ${label} must be an object`); - } - for (const [key, value] of Object.entries(colors)) { - if (!VALID_COLOR_KEYS.includes(key)) { - console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); - continue; - } - if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { - throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); - } - } - }; - - if (theme.colors != null) validateColors(theme.colors, 'colors'); - if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); - - if (theme.design != null) { - if (!isPlainObject(theme.design)) { - throw new Error(`Clerk theme: design must be an object`); +const getListedPluginProps = (config, name) => { + for (const entry of config.plugins || []) { + if (entry === name) { + return {}; } - if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { - throw new Error(`Clerk theme: design.fontFamily must be a string`); - } - if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { - throw new Error(`Clerk theme: design.borderRadius must be a number`); + if (Array.isArray(entry) && entry[0] === name) { + return entry[1] || {}; } } -} - -const withClerkTheme = (config, props = {}) => { - const { theme } = props; - if (!theme) return config; + return {}; +}; - // Resolve the theme file path relative to the project root - const themePath = path.resolve(theme); - if (!fs.existsSync(themePath)) { - console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`); +/** + * Apply the @clerk/expo-native-components config plugin when it is installed, so apps that only list + * "@clerk/expo" keep the iOS deployment target and native SDK configuration they need. + */ +const withClerkExpoNativeComponents = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => { + const nativeProps = Object.fromEntries( + CLERK_EXPO_NATIVE_OPTIONS.filter(option => props[option] !== undefined).map(option => [option, props[option]]), + ); + const nativeOptionNames = Object.keys(nativeProps) + .map(option => `"${option}"`) + .join(', '); + + if (config._internal?.pluginHistory?.[CLERK_EXPO_NATIVE]) { + if (nativeOptionNames) { + console.warn( + `⚠️ Clerk: The following "@clerk/expo" plugin options are ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran: ${nativeOptionNames}. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`, + ); + } return config; } - let themeJson; - try { - themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8')); - validateThemeJson(themeJson); - } catch (e) { - throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`); + const clerkExpoNativePlugin = resolvePlugin(config); + if (!clerkExpoNativePlugin) { + if (nativeOptionNames) { + console.warn( + `⚠️ Clerk: The following "@clerk/expo" plugin options require ${CLERK_EXPO_NATIVE} and are ignored: ${nativeOptionNames}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`, + ); + } + return config; } - // iOS: Embed theme in Info.plist under "ClerkTheme" - config = withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkTheme = themeJson; - console.log('✅ Embedded Clerk theme in Info.plist'); - return modConfig; - }); - - // Android: Copy theme JSON to assets - config = withDangerousMod(config, [ - 'android', - async config => { - const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets'); - if (!fs.existsSync(assetsDir)) { - fs.mkdirSync(assetsDir, { recursive: true }); - } - const destPath = path.join(assetsDir, 'clerk_theme.json'); - fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n'); - console.log('✅ Copied Clerk theme to Android assets'); - return config; - }, - ]); - - return config; + return clerkExpoNativePlugin(config, { ...nativeProps, ...getListedPluginProps(config, CLERK_EXPO_NATIVE) }); }; const withClerkExpo = (config, props = {}) => { const { appleSignIn = true } = props; - config = withClerkIOS(config); if (appleSignIn !== false) { config = withClerkAppleSignIn(config); } - config = withClerkAndroid(config); - config = withClerkKeychainService(config, props); + config = withClerkHostedAuthCallback(config); config = withClerkFaceIDPermission(config, props); - config = withClerkTheme(config, props); + config = withClerkExpoNativeComponents(config, props); return config; }; module.exports = withClerkExpo; module.exports._testing = { addHostedAuthIntentFilter, + withClerkExpoNativeComponents, withClerkFaceIDPermission, - validateThemeJson, - isPlainObject, - VALID_COLOR_KEYS, - HEX_COLOR_REGEX, }; diff --git a/packages/expo/native/index.d.ts b/packages/expo/native/index.d.ts new file mode 100644 index 00000000000..9d8fb781ea9 --- /dev/null +++ b/packages/expo/native/index.d.ts @@ -0,0 +1 @@ +export * from '@clerk/expo-native-components'; diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json index 6ae24b71af4..928da2cc7c8 100644 --- a/packages/expo/native/package.json +++ b/packages/expo/native/package.json @@ -1,4 +1,4 @@ { "main": "../dist/native/index.js", - "types": "../dist/native/index.d.ts" + "types": "./index.d.ts" } diff --git a/packages/expo/package.json b/packages/expo/package.json index 95aa54c6c84..095cc4ddba5 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -30,7 +30,7 @@ }, "./app.plugin.js": "./app.plugin.js", "./native": { - "types": "./dist/native/index.d.ts", + "types": "./native/index.d.ts", "default": "./dist/native/index.js" }, "./web": { @@ -85,8 +85,6 @@ "types": "./dist/index.d.ts", "files": [ "dist", - "android", - "ios", "native", "web", "local-credentials", @@ -99,9 +97,6 @@ "hosted-auth", "experimental", "legacy", - "src/specs", - "expo-module.config.json", - "react-native.config.js", "app.plugin.js", "app.plugin.d.ts" ], @@ -127,6 +122,7 @@ }, "devDependencies": { "@clerk/expo-google-signin": "workspace:*", + "@clerk/expo-native-components": "workspace:*", "@clerk/expo-passkeys": "workspace:*", "@expo/config-plugins": "^54.0.4", "@types/base-64": "^1.0.2", @@ -142,6 +138,7 @@ }, "peerDependencies": { "@clerk/expo-google-signin": ">=0.1.0", + "@clerk/expo-native-components": ">=0.1.0", "@clerk/expo-passkeys": ">=0.0.6", "expo": "catalog:peer-expo", "expo-apple-authentication": ">=7.0.0", @@ -159,6 +156,9 @@ "@clerk/expo-google-signin": { "optional": true }, + "@clerk/expo-native-components": { + "optional": true + }, "@clerk/expo-passkeys": { "optional": true }, @@ -192,10 +192,5 @@ }, "publishConfig": { "access": "public" - }, - "codegenConfig": { - "name": "ClerkExpoSpec", - "type": "all", - "jsSrcsDir": "src/specs" } } diff --git a/packages/expo/src/__tests__/appPlugin.test.js b/packages/expo/src/__tests__/appPlugin.test.js new file mode 100644 index 00000000000..55f9e5abdc3 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.test.js @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { withClerkExpoNativeComponents, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing; + +function applyInfoPlistMod(config, modResults) { + return config.mods.ios.infoPlist({ + ...config, + modRequest: {}, + modResults, + }); +} + +describe('withClerkFaceIDPermission', () => { + test('adds the configured Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' }, + ); + + const result = await applyInfoPlistMod(config, {}); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.'); + }); + + test('preserves an app-provided Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Clerk-provided description' }, + ); + + const result = await applyInfoPlistMod(config, { + NSFaceIDUsageDescription: 'App-provided description', + }); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description'); + }); + + test('does not configure the Info.plist without an explicit permission description', () => { + const config = { name: 'test', slug: 'test' }; + + expect(withClerkFaceIDPermission(config)).toBe(config); + expect(config).not.toHaveProperty('mods'); + }); + + test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => { + expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow( + 'faceIDPermission must be a non-empty string', + ); + }); +}); + +describe('withClerkExpoNativeComponents', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('applies the @clerk/expo-native-components plugin with its options when it is installed', () => { + const nativePlugin = vi.fn(config => ({ ...config, applied: true })); + const config = { name: 'test', slug: 'test' }; + + const result = withClerkExpoNativeComponents( + config, + { keychainService: 'com.example.shared', theme: './theme.json', appleSignIn: false }, + () => nativePlugin, + ); + + expect(result.applied).toBe(true); + expect(nativePlugin).toHaveBeenCalledWith(config, { keychainService: 'com.example.shared', theme: './theme.json' }); + }); + + test('prefers options passed to an explicitly listed @clerk/expo-native-components plugin', () => { + const nativePlugin = vi.fn(config => config); + const config = { + name: 'test', + slug: 'test', + plugins: ['@clerk/expo', ['@clerk/expo-native-components', { theme: './native-theme.json' }]], + }; + + withClerkExpoNativeComponents( + config, + { keychainService: 'com.example.shared', theme: './theme.json' }, + () => nativePlugin, + ); + + expect(nativePlugin).toHaveBeenCalledWith(config, { + keychainService: 'com.example.shared', + theme: './native-theme.json', + }); + }); + + test('does not apply the @clerk/expo-native-components plugin twice', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const nativePlugin = vi.fn(config => config); + const config = { + name: 'test', + slug: 'test', + _internal: { + pluginHistory: { '@clerk/expo-native-components': { name: '@clerk/expo-native-components', version: '0.0.1' } }, + }, + }; + + expect(withClerkExpoNativeComponents(config, {}, () => nativePlugin)).toBe(config); + expect(nativePlugin).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + + withClerkExpoNativeComponents(config, { theme: './theme.json' }, () => nativePlugin); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Pass them to the "@clerk/expo-native-components" plugin instead'), + ); + }); + + test('leaves the config untouched when @clerk/expo-native-components is not installed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { name: 'test', slug: 'test' }; + + expect(withClerkExpoNativeComponents(config, {}, () => null)).toBe(config); + expect(warn).not.toHaveBeenCalled(); + }); + + test('warns with install instructions when native-only options are passed without @clerk/expo-native-components', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { name: 'test', slug: 'test' }; + + expect(withClerkExpoNativeComponents(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native-components')); + }); +}); diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts index 75978520dd1..759b2583cb7 100644 --- a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts @@ -1,17 +1,14 @@ import { renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { - __internal_resetNativeClientSyncCoordinator, - registerNativeToJsSyncHandler, - trackPendingJsToNativeSync, -} from '../../provider/nativeClientSyncCoordinator'; import { useBiometricCredentials as useUnsupportedBiometrics } from '../useBiometricCredentials'; import { useBiometricCredentials as useAndroidBiometrics } from '../useBiometricCredentials.android'; import { useBiometricCredentials as useIosBiometrics } from '../useBiometricCredentials.ios'; const mocks = vi.hoisted(() => ({ useClerk: vi.fn(), + idle: vi.fn(), + synchronize: vi.fn(), nativeModule: { getTrustedDeviceAvailability: vi.fn(), listTrustedDevices: vi.fn(), @@ -24,6 +21,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk })); vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule })); +vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.synchronize })); vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); const session = { @@ -32,13 +30,11 @@ const session = { getToken: vi.fn(), }; const clerk = { session: session as typeof session | null, setActive: vi.fn() }; -const synchronize = vi.fn(); -let unregister: () => void; +const synchronize = mocks.synchronize; beforeEach(() => { vi.resetAllMocks(); - __internal_resetNativeClientSyncCoordinator(); - unregister = registerNativeToJsSyncHandler(synchronize); + mocks.idle.mockResolvedValue(undefined); clerk.session = session; mocks.useClerk.mockReturnValue(clerk); session.getToken.mockResolvedValue('fresh-token'); @@ -50,8 +46,6 @@ beforeEach(() => { }); }); -afterEach(() => unregister()); - describe.each([ ['iOS', useIosBiometrics], ['Android', useAndroidBiometrics], @@ -115,7 +109,7 @@ test('incomplete verification returns the synchronized session without refreshin test('waits for pending JS-to-native synchronization', async () => { let finish!: () => void; - trackPendingJsToNativeSync( + mocks.idle.mockReturnValueOnce( new Promise(resolve => { finish = resolve; }), @@ -130,7 +124,7 @@ test('waits for pending JS-to-native synchronization', async () => { test('does not start a biometric operation after the active session changes during synchronization', async () => { let finish!: () => void; - trackPendingJsToNativeSync( + mocks.idle.mockReturnValueOnce( new Promise(resolve => { finish = resolve; }), diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index dee295ff1a5..d5f8ddbaa89 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -1,11 +1,6 @@ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { - __internal_resetNativeClientSyncCoordinator, - registerNativeToJsSyncHandler, - trackPendingJsToNativeSync, -} from '../../provider/nativeClientSyncCoordinator'; import { isBiometricCredentialError } from '../errors'; import { useBiometricCredentials as useBiometricCredentialsOnUnsupportedPlatform } from '../useBiometricCredentials'; import { useBiometricCredentials as useBiometricCredentialsOnAndroid } from '../useBiometricCredentials.android'; @@ -23,7 +18,9 @@ const mocks = vi.hoisted(() => ({ jsSignedInSessions: [{ id: 'sess_123' }], useClerk: vi.fn(), setActive: vi.fn(), - synchronizeNativeClientToJs: vi.fn(), + idle: vi.fn(), + pullFromNative: vi.fn(), + isNativeModuleInstalled: true, nativeModule: { getTrustedDeviceAvailability: vi.fn(), listTrustedDevices: vi.fn(), @@ -37,8 +34,15 @@ vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk, })); +vi.mock('../../provider/nativeClientSync', () => ({ + idle: mocks.idle, + pullFromNative: mocks.pullFromNative, +})); + vi.mock('../../utils/native-module', () => ({ - ClerkExpoModule: mocks.nativeModule, + get ClerkExpoModule() { + return mocks.isNativeModuleInstalled ? mocks.nativeModule : null; + }, })); vi.mock('react-native', () => ({ @@ -65,12 +69,9 @@ function renderBiometricCredentials(useHook = useBiometricCredentialsOnIos) { return renderHook(() => useHook()).result.current; } -let unregisterNativeToJsSyncHandler: (() => void) | undefined; - beforeEach(() => { - __internal_resetNativeClientSyncCoordinator(); - unregisterNativeToJsSyncHandler = registerNativeToJsSyncHandler(mocks.synchronizeNativeClientToJs); - mocks.synchronizeNativeClientToJs.mockResolvedValue(undefined); + mocks.idle.mockResolvedValue(undefined); + mocks.pullFromNative.mockResolvedValue(undefined); mocks.useClerk.mockReturnValue({ client: { signIn: mocks.jsSignIn, signedInSessions: mocks.jsSignedInSessions }, setActive: mocks.setActive, @@ -85,7 +86,6 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); - unregisterNativeToJsSyncHandler?.(); }); describe('useBiometricCredentials on iOS', () => { @@ -114,7 +114,7 @@ describe('useBiometricCredentials on iOS', () => { const nativeSync = new Promise(resolve => { finishNativeSync = resolve; }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockReturnValueOnce(nativeSync); mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ isAvailable: true, unavailableReason: null, @@ -131,21 +131,12 @@ describe('useBiometricCredentials on iOS', () => { }); test('rejects availability when native client synchronization times out', async () => { - vi.useFakeTimers(); - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; - }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockRejectedValueOnce(Object.assign(new Error('timed out'), { code: 'environment_unavailable' })); - const availability = expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({ + await expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({ code: 'environment_unavailable', }); - - await vi.advanceTimersByTimeAsync(5_000); - await availability; expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled(); - finishNativeSync(); }); test('lists biometric credentials and converts native timestamps to dates', async () => { @@ -182,7 +173,7 @@ describe('useBiometricCredentials on iOS', () => { const nativeSync = new Promise(resolve => { finishNativeSync = resolve; }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockReturnValueOnce(nativeSync); mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeBiometricCredential]); const listing = renderBiometricCredentials().list(); @@ -227,7 +218,7 @@ describe('useBiometricCredentials on iOS', () => { const nativeSync = new Promise(resolve => { finishNativeSync = resolve; }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockReturnValueOnce(nativeSync); mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeBiometricCredential); const enrollment = renderBiometricCredentials().enroll(); @@ -259,7 +250,7 @@ describe('useBiometricCredentials on iOS', () => { const nativeSync = new Promise(resolve => { finishNativeSync = resolve; }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockReturnValueOnce(nativeSync); mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ ...nativeBiometricCredential, status: 'revoked', @@ -359,7 +350,7 @@ describe('useBiometricCredentials on iOS', () => { const nativeSync = new Promise(resolve => { finishNativeSync = resolve; }); - trackPendingJsToNativeSync(nativeSync); + mocks.idle.mockReturnValueOnce(nativeSync); mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ id: 'sia_123', status: 'complete', @@ -386,7 +377,7 @@ describe('useBiometricCredentials on iOS', () => { status, createdSessionId: null, }); - mocks.synchronizeNativeClientToJs.mockImplementation(() => { + mocks.pullFromNative.mockImplementation(() => { Object.assign(mocks.jsSignIn, { id: 'sia_mfa', status, @@ -412,7 +403,7 @@ describe('useBiometricCredentials on iOS', () => { status: 'complete', createdSessionId: 'sess_123', }); - mocks.synchronizeNativeClientToJs.mockReturnValue( + mocks.pullFromNative.mockReturnValue( new Promise(resolve => { finishSync = resolve; }), @@ -425,7 +416,7 @@ describe('useBiometricCredentials on iOS', () => { didResolve = true; return result; }); - await vi.waitFor(() => expect(mocks.synchronizeNativeClientToJs).toHaveBeenCalled()); + await vi.waitFor(() => expect(mocks.pullFromNative).toHaveBeenCalled()); expect(didResolve).toBe(false); finishSync(); @@ -539,13 +530,25 @@ describe('useBiometricCredentials on iOS', () => { }); }); + test('explains how to install @clerk/expo-native-components when the native module is missing', async () => { + mocks.isNativeModuleInstalled = false; + + try { + await expect(renderBiometricCredentials().signIn()).rejects.toThrow( + 'Biometric credentials require the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`', + ); + } finally { + mocks.isNativeModuleInstalled = true; + } + }); + test('explains that the development client must contain the native methods', async () => { const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); try { await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo.', + 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', ); } finally { Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts index 3e92e5287be..213011066bf 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts @@ -1,7 +1,7 @@ import { useClerk } from '@clerk/react'; import { useMemo } from 'react'; -import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../provider/nativeClientSyncCoordinator'; +import { idle, pullFromNative } from '../provider/nativeClientSync'; import type { NativeBiometricCredential, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types'; import { errorThrower } from '../utils/errors'; import { ClerkExpoModule } from '../utils/native-module'; @@ -14,6 +14,9 @@ import type { const DEFAULT_POLICY = 'biometry_current_set'; +const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS = + 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.'; + function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform { return platform === 'ios' || platform === 'android' ? platform : 'unknown'; } @@ -25,15 +28,21 @@ function toBiometricCredentialStatus(status: string): BiometricCredentialStatus function getNativeModule(): NativeBiometricCredentialModule { const nativeModule = ClerkExpoModule; + if (!nativeModule) { + return errorThrower.throw( + `Biometric credentials require the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`, + ); + } + if ( - !nativeModule?.getTrustedDeviceAvailability || + !nativeModule.getTrustedDeviceAvailability || !nativeModule.listTrustedDevices || !nativeModule.enrollTrustedDevice || !nativeModule.revokeTrustedDevice || !nativeModule.signInWithTrustedDevice ) { return errorThrower.throw( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo.', + 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', ); } @@ -56,18 +65,18 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom return { getAvailability: async params => { const nativeModule = getNativeModule(); - await waitForPendingJsToNativeSync(); + await idle(); return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null); }, list: async () => { const nativeModule = getNativeModule(); - await waitForPendingJsToNativeSync(); + await idle(); const credentials = await nativeModule.listTrustedDevices(); return credentials.map(toBiometricCredential); }, enroll: async params => { const nativeModule = getNativeModule(); - await waitForPendingJsToNativeSync(); + await idle(); const credential = await nativeModule.enrollTrustedDevice( params?.name ?? null, params?.identifierHint ?? null, @@ -78,7 +87,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom }, revoke: async id => { const nativeModule = getNativeModule(); - await waitForPendingJsToNativeSync(); + await idle(); const credential = await nativeModule.revokeTrustedDevice(id); return toBiometricCredential(credential); }, @@ -86,7 +95,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom const nativeModule = getNativeModule(); if (typeof nativeModule.reverifyWithBiometrics !== 'function') { return errorThrower.throw( - 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.', + 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.', ); } const level = params?.level ?? 'first_factor'; @@ -99,7 +108,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom if (!session) { return errorThrower.throw('Biometric reverification requires an active session.'); } - await waitForPendingJsToNativeSync(); + await idle(); if (clerk.session?.id !== session.id) { return errorThrower.throw('The active session changed before biometric reverification started.'); } @@ -110,7 +119,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom if (verification.status === 'complete') { session.clearCache(); } - await synchronizeNativeClientToJs(); + await pullFromNative(); const synchronizedSession = clerk.session; if (synchronizedSession?.id !== session.id) { return errorThrower.throw('The active session changed during biometric reverification.'); @@ -134,13 +143,13 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom }, signIn: async params => { const nativeModule = getNativeModule(); - await waitForPendingJsToNativeSync(); + await idle(); const nativeSignIn = await nativeModule.signInWithTrustedDevice( params?.id ?? null, params?.identifierHint ?? null, params?.reason ?? null, ); - await synchronizeNativeClientToJs(); + await pullFromNative(); const client = clerk.client; const signIn = client?.signIn; diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts deleted file mode 100644 index fb8eaba5cae..00000000000 --- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { type NativeClientSnapshot, useNativeClientEvents } from '../useNativeClientEvents'; - -const mocks = vi.hoisted(() => { - return { - moduleAddListener: vi.fn(), - nativeModule: {} as unknown, - nativeListener: undefined as ((snapshot?: NativeClientSnapshot) => void) | undefined, - remove: vi.fn(), - }; -}); - -vi.mock('../../utils/native-module', () => { - return { - get ClerkExpoModule() { - return mocks.nativeModule; - }, - isNativeSupported: true, - }; -}); - -describe('useNativeClientEvents', () => { - beforeEach(() => { - mocks.nativeModule = { - addListener: mocks.moduleAddListener, - }; - mocks.nativeListener = undefined; - mocks.remove.mockReset(); - mocks.moduleAddListener.mockReset(); - mocks.moduleAddListener.mockImplementation((_eventName, listener) => { - mocks.nativeListener = listener; - return { remove: mocks.remove }; - }); - }); - - afterEach(() => { - cleanup(); - }); - - test('stores native client change payloads', async () => { - const { result, unmount } = renderHook(() => useNativeClientEvents()); - - expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeClientChanged', expect.any(Function)); - - act(() => { - mocks.nativeListener?.({ - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'device-token', - sourceId: 'native-source', - }); - }); - - await waitFor(() => { - expect(result.current.nativeClientEvent?.deviceToken).toBe('device-token'); - expect(result.current.nativeClientEvent?.changed).toEqual({ - client: false, - deviceToken: true, - }); - expect(result.current.nativeClientEvent?.sourceId).toBe('native-source'); - }); - - unmount(); - }); - - test('subscribes only while native client events are enabled', () => { - const { rerender, unmount } = renderHook(({ enabled }) => useNativeClientEvents(enabled), { - initialProps: { enabled: false }, - }); - - expect(mocks.moduleAddListener).not.toHaveBeenCalled(); - - rerender({ enabled: true }); - expect(mocks.moduleAddListener).toHaveBeenCalledTimes(1); - - rerender({ enabled: false }); - expect(mocks.remove).toHaveBeenCalledTimes(1); - - unmount(); - }); - - test('does not subscribe modules without an Expo event emitter', () => { - mocks.nativeModule = { - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), - }; - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const { unmount } = renderHook(() => useNativeClientEvents()); - - expect(mocks.moduleAddListener).not.toHaveBeenCalled(); - expect(consoleError).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - unmount(); - }); -}); diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index 36c38445505..88b941f4a04 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -49,9 +49,10 @@ vi.mock('react-native', () => { vi.mock('../../specs/NativeClerkModule', () => { return { default: { - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), + configureNative: vi.fn(), + getDeviceToken: vi.fn(), + setDeviceToken: vi.fn(), + refreshClient: vi.fn(), }, }; }); diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts deleted file mode 100644 index a48a2ee316c..00000000000 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; - -const nativeClientChangedEvent = 'clerkNativeClientChanged'; - -export interface NativeClientSnapshot { - changed: { - client: boolean; - deviceToken: boolean; - }; - deviceToken: string | null; - sourceId?: string | null; -} - -/** - * Local marker for a native client event. - */ -export interface NativeClientEvent extends NativeClientSnapshot { - issuedAt: number; -} - -interface UseNativeClientEventsReturn { - nativeClientEvent: NativeClientEvent | null; -} - -type RefreshClientEventSubscription = { - remove: () => void; -}; - -type RefreshClientEventEmitter = { - addListener: ( - eventName: typeof nativeClientChangedEvent, - listener: (snapshot?: NativeClientSnapshot) => void, - ) => RefreshClientEventSubscription; -}; - -function getNativeClientEventEmitter(): RefreshClientEventEmitter | null { - if (ClerkExpo && typeof ClerkExpo.addListener === 'function') { - return ClerkExpo as RefreshClientEventEmitter; - } - - return null; -} - -function isNativeClientSnapshot(snapshot: NativeClientSnapshot | undefined): snapshot is NativeClientSnapshot { - return ( - typeof snapshot?.changed?.client === 'boolean' && - typeof snapshot.changed.deviceToken === 'boolean' && - (typeof snapshot.deviceToken === 'string' || snapshot.deviceToken === null) - ); -} - -/** - * Listens for native client events that should sync JS client state. - */ -export function useNativeClientEvents(enabled = true): UseNativeClientEventsReturn { - const [nativeClientEvent, setNativeClientEvent] = useState(null); - - useEffect(() => { - if (!enabled) { - setNativeClientEvent(null); - return; - } - - if (!isNativeSupported || !ClerkExpo) { - return; - } - - let subscription: { remove: () => void } | null = null; - - try { - const eventEmitter = getNativeClientEventEmitter(); - - if (!eventEmitter) { - return; - } - - subscription = eventEmitter.addListener(nativeClientChangedEvent, snapshot => { - if (!isNativeClientSnapshot(snapshot)) { - return; - } - - setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); - }); - } catch (error) { - if (__DEV__) { - console.error('[useNativeClientEvents] Failed to set up event listener:', error); - } - } - - return () => { - subscription?.remove(); - }; - }, [enabled]); - - return { - nativeClientEvent, - }; -} diff --git a/packages/expo/src/native/__tests__/index.test.ts b/packages/expo/src/native/__tests__/index.test.ts new file mode 100644 index 00000000000..f7904bb2a8b --- /dev/null +++ b/packages/expo/src/native/__tests__/index.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const importNativeEntry = () => import('../index'); + +describe('@clerk/expo/native', () => { + afterEach(() => { + vi.doUnmock('../loadClerkExpoNative'); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + describe('when @clerk/expo-native-components is not installed', () => { + beforeEach(() => { + vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => null })); + }); + + test.each(['AuthView', 'UserButton', 'UserProfileView'] as const)( + 'rendering %s throws an error with install instructions', + async name => { + const Component = (await importNativeEntry())[name] as (props: object) => unknown; + + expect(() => Component({})).toThrow( + /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native-components package\. Install it with `npx expo install @clerk\/expo-native-components`/, + ); + }, + ); + + test.each(['useAuthViewState', 'useUserProfileCustomPageNavigation'] as const)( + 'calling %s throws an error with install instructions', + async name => { + const hook = (await importNativeEntry())[name] as () => unknown; + + expect(() => hook()).toThrow(`\`${name}\` is unavailable.`); + expect(() => hook()).toThrow('add "@clerk/expo-native-components" to the plugins array in your app config'); + }, + ); + }); + + test('re-exports @clerk/expo-native-components when it is installed', async () => { + const clerkExpoNative = { + AuthView: vi.fn(() => null), + UserButton: vi.fn(() => null), + UserProfileView: vi.fn(() => null), + useAuthViewState: vi.fn(), + useUserProfileCustomPageNavigation: vi.fn(), + }; + vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => clerkExpoNative })); + + const nativeEntry = await importNativeEntry(); + + expect(nativeEntry.AuthView).toBe(clerkExpoNative.AuthView); + expect(nativeEntry.UserButton).toBe(clerkExpoNative.UserButton); + expect(nativeEntry.UserProfileView).toBe(clerkExpoNative.UserProfileView); + expect(nativeEntry.useAuthViewState).toBe(clerkExpoNative.useAuthViewState); + expect(nativeEntry.useUserProfileCustomPageNavigation).toBe(clerkExpoNative.useUserProfileCustomPageNavigation); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 2a62047dd85..67acaa9ab68 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -1,49 +1,27 @@ -/** - * Native UI components for Clerk authentication in Expo apps. - * - * These components provide pre-built, native authentication experiences powered by: - * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios - * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android - * - * ## Installation - * - * Native components require the `@clerk/expo` plugin to be configured in your `app.json`: - * - * ```json - * { - * "expo": { - * "plugins": ["@clerk/expo"] - * } - * } - * ``` - * - * Then run `npx expo prebuild` to generate native code. - * - * ## Components - * - * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline - * - {@link UserProfileView} - User profile and account management, renders inline - * - {@link UserButton} - Avatar button that opens the native user profile - * - * @module @clerk/expo/native - */ +import { errorThrower } from '../errorThrower'; +import type { ClerkExpoNativeModule } from './loadClerkExpoNative'; +import { loadClerkExpoNative } from './loadClerkExpoNative'; -export { AuthView } from './AuthView'; -export type { AuthViewProps, AuthViewMode } from './AuthView.types'; -export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; -export { useAuthViewState } from './useAuthViewState'; -export type { UseAuthViewStateReturn } from './useAuthViewState'; -export { UserButton } from './UserButton'; -export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton'; -export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages'; -export type { - UserProfileCustomPageNavigation, - UserProfileCustomDestination, - UserProfileCustomPage, - UserProfileCustomPageIcon, - UserProfileCustomPagePlacement, - UserProfileRow, - UserProfileSection, -} from './UserProfileCustomPages'; -export { UserProfileView } from './UserProfileView'; -export type { UserProfileViewProps } from './UserProfileView'; +// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native-components. + +const CLERK_EXPO_NATIVE_MISSING_MESSAGE = + 'Native components have moved to the @clerk/expo-native-components package. ' + + 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, ' + + 'then rebuild your native app. You can then import them from "@clerk/expo-native-components".'; + +const clerkExpoNative = loadClerkExpoNative(); + +function resolveExport(name: K) { + return ( + clerkExpoNative?.[name] ?? + function ClerkExpoNativeMissing(): never { + return errorThrower.throw(`\`${name}\` is unavailable. ${CLERK_EXPO_NATIVE_MISSING_MESSAGE}`); + } + ); +} + +export const AuthView = resolveExport('AuthView'); +export const UserButton = resolveExport('UserButton'); +export const UserProfileView = resolveExport('UserProfileView'); +export const useAuthViewState = resolveExport('useAuthViewState'); +export const useUserProfileCustomPageNavigation = resolveExport('useUserProfileCustomPageNavigation'); diff --git a/packages/expo/src/native/loadClerkExpoNative.ts b/packages/expo/src/native/loadClerkExpoNative.ts new file mode 100644 index 00000000000..194b0bed1d9 --- /dev/null +++ b/packages/expo/src/native/loadClerkExpoNative.ts @@ -0,0 +1,18 @@ +type ClerkExpoNativeExport = (...args: never[]) => unknown; + +export type ClerkExpoNativeModule = Partial< + Record< + 'AuthView' | 'UserButton' | 'UserProfileView' | 'useAuthViewState' | 'useUserProfileCustomPageNavigation', + ClerkExpoNativeExport + > +>; + +export function loadClerkExpoNative(): ClerkExpoNativeModule | null { + try { + // Synchronous require() in try/catch so Metro treats @clerk/expo-native-components as an optional dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@clerk/expo-native-components') as ClerkExpoNativeModule; + } catch { + return null; + } +} diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 8eb900d80f0..7c4fc4e72a5 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -2,19 +2,13 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; -import { useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { TokenCache } from '../cache/types'; +import { ClerkExpoModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; -import { - type DeviceTokenCacheListener, - NativeClientSync, - type NativeRefreshFromJsController, - useNativeClientBootstrap, - useNativeClientEventSync, - useSyncableTokenCache, -} from './nativeClientSync'; +import { createNativeClientSync, type SyncableClerk } from './nativeClientSync'; import { getClerkInstance } from './singleton'; import type { BuildClerkOptions } from './singleton/types'; @@ -77,20 +71,18 @@ export function ClerkProvider(props: ClerkProviderProps>(new Set()); - const suppressTokenCacheNotificationsRef = useRef(0); - const nativeRefreshFromJsControllerRef = useRef(null); - const syncableTokenCache = useSyncableTokenCache({ - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, - }); + const nativeModule = isNative() && !__experimental_disableNativeClientSync ? ClerkExpoModule : null; + const tokenCacheRef = useRef(tokenCache); + tokenCacheRef.current = tokenCache; + const nativeClientSync = useMemo( + () => (nativeModule ? createNativeClientSync(nativeModule, pk, () => tokenCacheRef.current) : null), + [nativeModule, pk], + ); const clerkInstance = isNative() ? getClerkInstance({ publishableKey: pk, - tokenCache: syncableTokenCache, + tokenCache: nativeClientSync?.tokenCache ?? tokenCache, proxyUrl, domain, __experimental_passkeys, @@ -98,24 +90,13 @@ export function ClerkProvider(props: ClerkProviderProps + nativeClientSync && clerkInstance + ? nativeClientSync.attach(clerkInstance as unknown as SyncableClerk) + : undefined, + [nativeClientSync, clerkInstance], + ); // Needed for `useOAuth` / `useSSO` to work correctly on web — must stay synchronous during render // so the redirect URL is caught before children mount. Resolves to a no-op on native via the @@ -144,17 +125,6 @@ export function ClerkProvider(props: ClerkProviderProps - {nativeClientSyncEnabled && ( - - )} {children} ); diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx deleted file mode 100644 index ee2ff2a4d60..00000000000 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ /dev/null @@ -1,2397 +0,0 @@ -import { act, render, waitFor } from '@testing-library/react'; -import React, { type ReactNode, useEffect } from 'react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { CLERK_CLIENT_JWT_KEY } from '../../constants'; -import { ClerkProvider } from '../ClerkProvider'; -import { - __internal_resetNativeClientSyncCoordinator, - synchronizeNativeClientToJs, - waitForPendingJsToNativeSync, -} from '../nativeClientSyncCoordinator'; - -const mocks = vi.hoisted(() => { - return { - configure: vi.fn(), - getClientToken: vi.fn(), - nativeClientEvent: null as unknown, - syncClientStateFromJs: vi.fn(), - tokenCache: { - clearToken: vi.fn(), - getToken: vi.fn(), - saveToken: vi.fn(), - }, - clerkOptions: undefined as - | { - tokenCache?: { - clearToken: (key: string) => void | Promise; - getToken: (key: string) => Promise; - saveToken: (key: string, token: string) => Promise; - }; - } - | undefined, - clerkInstance: { - __internal_setActiveInProgress: false, - __internal_reloadInitialResources: vi.fn(), - addListener: vi.fn(), - addOnLoaded: vi.fn(), - client: undefined as unknown, - handleUnauthenticated: vi.fn(), - loaded: false, - off: vi.fn(), - on: vi.fn(), - session: undefined as unknown, - setActive: vi.fn(), - status: 'loading', - updateClient: vi.fn(), - }, - clerkListener: undefined as (() => void) | undefined, - clerkOnLoaded: undefined as (() => void) | undefined, - clerkStatusListener: undefined as ((status: string) => void) | undefined, - }; -}); - -vi.mock('../../polyfills', () => ({})); - -vi.mock('@clerk/react/internal', () => { - return { - InternalClerkProvider: ({ children }: { children: ReactNode }) => - React.createElement(React.Fragment, null, children), - }; -}); - -vi.mock('react-native', () => { - return { - NativeModules: { - BlobModule: {}, - }, - Platform: { - OS: 'ios', - constants: { - reactNativeVersion: { - major: 0, - minor: 81, - patch: 0, - }, - }, - }, - }; -}); - -vi.mock('expo-secure-store', () => { - return { - AFTER_FIRST_UNLOCK: 0, - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), - }; -}); - -vi.mock('../../hooks/useNativeClientEvents', () => { - return { - useNativeClientEvents: () => ({ - nativeClientEvent: mocks.nativeClientEvent, - }), - }; -}); - -vi.mock('../../specs/NativeClerkModule', () => { - return { - default: { - addListener: vi.fn(), - configure: mocks.configure, - getClientToken: mocks.getClientToken, - syncClientStateFromJs: mocks.syncClientStateFromJs, - }, - }; -}); - -vi.mock('../../utils/runtime', () => { - return { - isNative: () => true, - isWeb: () => false, - }; -}); - -vi.mock('../singleton', () => { - return { - getClerkInstance: (options?: { tokenCache?: typeof mocks.tokenCache }) => { - mocks.clerkOptions = options; - return mocks.clerkInstance; - }, - }; -}); - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - -function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { - let reject!: (error: Error) => void; - const promise = new Promise((_resolve, innerReject) => { - reject = innerReject; - }); - return { promise, reject }; -} - -describe('ClerkProvider native client sync', () => { - beforeEach(() => { - __internal_resetNativeClientSyncCoordinator(); - vi.clearAllMocks(); - mocks.nativeClientEvent = null; - mocks.configure.mockResolvedValue(undefined); - mocks.getClientToken.mockResolvedValue(null); - mocks.syncClientStateFromJs.mockResolvedValue(undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - mocks.tokenCache.saveToken.mockResolvedValue(undefined); - mocks.tokenCache.clearToken.mockResolvedValue(undefined); - mocks.clerkOptions = undefined; - mocks.clerkInstance.__internal_setActiveInProgress = false; - mocks.clerkInstance.__internal_reloadInitialResources.mockResolvedValue(undefined); - mocks.clerkInstance.addOnLoaded = vi.fn(); - mocks.clerkInstance.client = undefined; - mocks.clerkInstance.handleUnauthenticated = vi.fn().mockResolvedValue(undefined); - mocks.clerkInstance.loaded = true; - mocks.clerkInstance.off.mockReset(); - mocks.clerkInstance.on.mockReset(); - mocks.clerkInstance.session = undefined; - mocks.clerkInstance.setActive.mockResolvedValue(undefined); - mocks.clerkInstance.status = 'ready'; - mocks.clerkInstance.updateClient = vi.fn(); - mocks.clerkInstance.updateClient.mockImplementation(client => { - mocks.clerkInstance.client = client; - const currentSession = mocks.clerkInstance.session as { id?: string } | null | undefined; - mocks.clerkInstance.session = currentSession - ? client.signedInSessions.find((session: { id: string }) => session.id === currentSession.id) || null - : currentSession; - }); - mocks.clerkListener = undefined; - mocks.clerkOnLoaded = undefined; - mocks.clerkStatusListener = undefined; - mocks.clerkInstance.addOnLoaded.mockImplementation(listener => { - mocks.clerkOnLoaded = listener; - }); - mocks.clerkInstance.addListener.mockImplementation(listener => { - mocks.clerkListener = listener; - return vi.fn(); - }); - mocks.clerkInstance.on.mockImplementation((event, listener) => { - if (event === 'status') { - mocks.clerkStatusListener = listener; - } - }); - }); - - test('does not start native client synchronization when disabled', async () => { - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - render( - , - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mocks.configure).not.toHaveBeenCalled(); - expect(mocks.getClientToken).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.addListener).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.handleUnauthenticated).toBe(originalHandleUnauthenticated); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('configures native once with the cached device token during StrictMode bootstrap', async () => { - const configure = deferred(); - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - mocks.configure.mockReturnValue(configure.promise); - - render( - - - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - let didFinishWaiting = false; - const waiting = waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - act(() => { - configure.resolve(); - }); - await waiting; - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('registers native bootstrap before child effects can await synchronization', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - let didFinishWaiting = false; - - function Child() { - useEffect(() => { - void waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - }, []); - return null; - } - - render( - - - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - act(() => { - configure.resolve(); - }); - await waitFor(() => { - expect(didFinishWaiting).toBe(true); - }); - }); - - test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { - mocks.clerkInstance.loaded = false; - mocks.clerkInstance.status = 'loading'; - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.on).toHaveBeenCalledWith('status', expect.any(Function)); - }); - expect(mocks.configure).not.toHaveBeenCalled(); - expect(mocks.getClientToken).not.toHaveBeenCalled(); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - act(() => { - mocks.clerkListener?.(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - await act(async () => { - mocks.clerkInstance.loaded = true; - mocks.clerkInstance.status = 'ready'; - mocks.clerkStatusListener?.('ready'); - }); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - expect(mocks.clerkInstance.off).toHaveBeenCalledWith('status', expect.any(Function)); - }); - - test('syncs a JS token rotated during bootstrap to native exactly once', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - mocks.tokenCache.getToken.mockResolvedValueOnce('cached-client-token').mockResolvedValue('rotated-client-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - act(() => { - configure.resolve(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'rotated-client-token', - 'clerk-expo-js-sync-bootstrap', - true, - true, - ); - }); - expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('flushes one JS client change that occurs after JS loads but before native is ready', async () => { - const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - act(() => { - mocks.clerkListener?.(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - act(() => { - configure.resolve(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1); - }); - - test('preserves native configure failures and keeps synchronization disabled', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('native refresh failed'); - const firstConfigure = rejectableDeferred(); - mocks.configure.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - act(() => { - mocks.clerkListener?.(); - firstConfigure.reject(configureError); - }); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); - expect(mocks.configure).toHaveBeenCalledTimes(2); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - }); - - test('does not wait for an obsolete native bootstrap after switching publishable keys', async () => { - const obsoleteConfigure = deferred(); - mocks.configure.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - expect(mocks.configure).toHaveBeenCalledTimes(2); - }); - - test('does not wait for an active native refresh after switching publishable keys', async () => { - const obsoleteRefresh = rejectableDeferred(); - const obsoleteRefreshError = new Error('obsolete native refresh failed'); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); - await waitForPendingJsToNativeSync(); - - mocks.syncClientStateFromJs.mockReturnValueOnce(obsoleteRefresh.promise); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'obsolete-client-token'); - }); - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'obsolete-client-token', - expect.any(String), - false, - true, - ); - }); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - - obsoleteRefresh.reject(obsoleteRefreshError); - await Promise.resolve(); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('retries a transient native configure failure', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('transient native refresh failure'); - mocks.configure.mockRejectedValueOnce(configureError); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'), - ); - consoleError.mockRestore(); - }); - - test('disables synchronization when switching publishable keys fails to configure native', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const configureError = new Error('native key switch failed'); - - const { rerender } = render( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(1)); - await waitForPendingJsToNativeSync(); - mocks.configure.mockRejectedValue(configureError); - mocks.tokenCache.saveToken.mockClear(); - mocks.syncClientStateFromJs.mockClear(); - - rerender( - , - ); - - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - expect(mocks.configure).toHaveBeenLastCalledWith('pk_test_456', null); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - consoleError.mockRestore(); - }); - - test('keeps native recovery authoritative when JS creates a client from an empty cache', async () => { - mocks.tokenCache.getToken.mockResolvedValueOnce(null).mockResolvedValue('anonymous-js-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1); - }); - - test('does not notify native when the token cache writes the current token again', async () => { - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('syncs JS token cache changes when ClerkProvider uses the default token cache', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render(); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - }); - - test('reloads JS resources after native emits a device token change', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - - test('reloads JS resources after native clears the device token', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything()); - expect(mocks.tokenCache.clearToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); - }); - - test('reloads JS resources after a native client-only change without rewriting the token cache', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: false, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything()); - expect(mocks.tokenCache.clearToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); - }); - - test('does not bounce a JS client listener event while applying a native client change', async () => { - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => { - mocks.clerkListener?.(); - }); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('serializes native token writes while keeping cache notifications suppressed', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const firstSave = deferred(); - const secondSave = deferred(); - mocks.tokenCache.saveToken - .mockImplementationOnce(() => firstSave.promise) - .mockImplementationOnce(() => secondSave.promise); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token-1', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-1'); - }); - - mocks.nativeClientEvent = { - issuedAt: 2, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'native-client-token-2', - }; - rerender( - , - ); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); - - await act(async () => { - firstSave.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); - }); - - await act(async () => { - secondSave.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(2); - }); - expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); - }); - - test('emits the refreshed JS client after a native client update keeps the active session', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1', lastName: 'Before' }, - }; - const updatedActiveSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1', lastName: 'After' }, - }; - const refreshedClient = { - signedInSessions: [updatedActiveSession], - lastActiveSessionId: 'session_1', - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [activeSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue(refreshedClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: false, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(originalUpdateClient).toHaveBeenCalledWith(refreshedClient); - }); - expect(originalUpdateClient).not.toHaveBeenCalledWith(refreshedClient, { - __internal_dangerouslySkipEmit: true, - }); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('sets the refreshed native last active session without emitting a stale signed-out JS state', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.setActive.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('does not explicitly sign JS out when a native client change leaves no signed-in sessions', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [], - lastActiveSessionId: null, - }), - }; - mocks.clerkInstance.session = removedSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - mocks.clerkInstance.setActive.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(originalUpdateClient).toHaveBeenCalledWith({ - signedInSessions: [], - lastActiveSessionId: null, - }); - }); - expect(originalUpdateClient).not.toHaveBeenCalledWith( - { - signedInSessions: [], - lastActiveSessionId: null, - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('rejects a foreign sessionless client when refreshing mutates the JS client in place', async () => { - const signedInSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - const client = { - id: 'client_1', - sessions: [signedInSession], - lastActiveSessionId: 'session_1' as string | null, - get signedInSessions() { - return this.sessions; - }, - __internal_toSnapshot() { - return { - id: this.id, - sessions: this.sessions, - last_active_session_id: this.lastActiveSessionId, - }; - }, - fromJSON(snapshot: { id: string; sessions: (typeof signedInSession)[]; last_active_session_id: string | null }) { - this.id = snapshot.id; - this.sessions = snapshot.sessions; - this.lastActiveSessionId = snapshot.last_active_session_id; - return this; - }, - fetch() { - this.id = 'client_2'; - this.sessions = []; - this.lastActiveSessionId = null; - return Promise.resolve(this); - }, - }; - const restoreClient = vi.spyOn(client, 'fromJSON'); - - mocks.clerkInstance.client = client; - mocks.clerkInstance.session = signedInSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - - originalUpdateClient.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: null, - }; - rerender( - , - ); - - await waitFor(() => { - expect(restoreClient).toHaveBeenCalled(); - }); - - expect(client.id).toBe('client_1'); - expect(client.sessions).toEqual([signedInSession]); - expect(client.signedInSessions).toEqual([signedInSession]); - expect(client.lastActiveSessionId).toBe('session_1'); - expect(originalUpdateClient).toHaveBeenCalledWith(client); - expect(mocks.clerkInstance.session).toBe(signedInSession); - }); - - test('keeps the remaining JS session when the old active session becomes unauthenticated', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockResolvedValue({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - - test('treats client payloads that remove the active session as a session switch when another session remains', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - mocks.clerkInstance.session = session; - return Promise.resolve(); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - - act(() => { - mocks.clerkInstance.updateClient({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - await waitFor(() => { - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - expect(originalUpdateClient).toHaveBeenCalledWith( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).not.toHaveBeenCalledWith({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - test('does not start fallback activation during an explicit session transition', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const replacementSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const replacementClient = { - signedInSessions: [replacementSession], - lastActiveSessionId: 'session_2', - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - mocks.clerkInstance.setActive.mockClear(); - mocks.clerkInstance.__internal_setActiveInProgress = true; - - act(() => { - mocks.clerkInstance.updateClient(replacementClient); - }); - - expect(originalUpdateClient).toHaveBeenCalledOnce(); - expect(originalUpdateClient).toHaveBeenCalledWith(replacementClient, { - __internal_dangerouslySkipEmit: true, - }); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); - }); - - test('keeps follow-up client updates suppressed while reconciling a removed active session', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const remainingSession = { - id: 'session_2', - status: 'active', - user: { id: 'user_2' }, - }; - const originalUpdateClient = mocks.clerkInstance.updateClient; - let resolveSetActive: (() => void) | undefined; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession, remainingSession], - lastActiveSessionId: 'session_1', - }; - mocks.clerkInstance.session = removedSession; - mocks.clerkInstance.setActive.mockImplementation(({ session }) => { - return new Promise(resolve => { - resolveSetActive = () => { - mocks.clerkInstance.session = session; - resolve(); - }; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); - }); - - originalUpdateClient.mockClear(); - - act(() => { - mocks.clerkInstance.updateClient( - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - mocks.clerkInstance.updateClient({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - }); - - expect(originalUpdateClient).toHaveBeenNthCalledWith( - 1, - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).toHaveBeenNthCalledWith( - 2, - { - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }, - { __internal_dangerouslySkipEmit: true }, - ); - expect(originalUpdateClient).not.toHaveBeenCalledWith({ - signedInSessions: [remainingSession], - lastActiveSessionId: 'session_2', - }); - - await act(async () => { - resolveSetActive?.(); - }); - - expect(mocks.clerkInstance.setActive).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession }); - }); - - test('does not fall back to JS sign-out when stale unauthenticated recovery still has a native device token', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - let reentersUnauthenticated = false; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockImplementation(async () => { - if (reentersUnauthenticated) { - await mocks.clerkInstance.handleUnauthenticated(); - } - throw new Error('stale session 401'); - }), - }; - mocks.clerkInstance.session = removedSession; - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - reentersUnauthenticated = true; - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.setActive).not.toHaveBeenCalledWith({ session: null }); - }); - - test('falls back to JS unauthenticated handling when native token recovery has no signed-in sessions', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: vi.fn().mockRejectedValue(new Error('stale session 401')), - }; - mocks.clerkInstance.session = removedSession; - mocks.getClientToken.mockResolvedValue('native-client-token'); - mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => { - mocks.clerkInstance.client = { - signedInSessions: [], - lastActiveSessionId: null, - }; - mocks.clerkInstance.session = null; - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); - expect(originalHandleUnauthenticated).toHaveBeenCalled(); - }); - - test('runs native recovery once for a burst of unauthenticated responses', async () => { - const removedSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - const fetchClient = vi.fn().mockResolvedValue(null); - - mocks.clerkInstance.client = { - signedInSessions: [removedSession], - lastActiveSessionId: 'session_1', - fetch: fetchClient, - }; - mocks.clerkInstance.session = removedSession; - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(fetchClient).toHaveBeenCalledTimes(1); - expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(2); - }); - - test('recovers again inside the cooldown window once native pushes a new device token', async () => { - const session = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - const fetchClient = vi.fn(); - const client = { - id: 'client_1', - signedInSessions: [session], - lastActiveSessionId: 'session_1', - fetch: fetchClient, - }; - fetchClient.mockResolvedValue(client); - - mocks.clerkInstance.client = client; - mocks.clerkInstance.session = session; - mocks.getClientToken.mockResolvedValue('native-client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - // Bootstrap's suppressed device token write would swallow the rotation below. - await act(async () => { - await waitForPendingJsToNativeSync(); - }); - - // Drop the client fetches the bootstrap already made; only the 401 handling matters here. - fetchClient.mockClear(); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - expect(fetchClient).toHaveBeenCalledTimes(1); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-native-client-token'); - }); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(fetchClient).toHaveBeenCalledTimes(2); - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - }); - - test('keeps the cooldown when a failed recovery rolls the device token back', async () => { - const session = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - const fetchClient = vi.fn().mockRejectedValue(new Error('stale session 401')); - - mocks.clerkInstance.client = { - id: 'client_1', - signedInSessions: [session], - lastActiveSessionId: 'session_1', - fetch: fetchClient, - }; - mocks.clerkInstance.session = session; - // Cached token A differs from native token B, so the rollback write changes the cached value. - mocks.tokenCache.getToken.mockResolvedValue('cached-token-A'); - mocks.getClientToken.mockResolvedValue('native-token-B'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - // Bootstrap fetches the client too, and that fetch fails here, so drain its retries first. - await act(async () => { - await expect(waitForPendingJsToNativeSync()).rejects.toThrow('stale session 401'); - }); - - fetchClient.mockClear(); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - expect(fetchClient).toHaveBeenCalledTimes(1); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - // The rollback is internal recovery, not an external rotation, so the second 401 delegates to core. - expect(fetchClient).toHaveBeenCalledTimes(1); - expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1); - }); - - test('refreshes native from the server after the JS client changes', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - act(() => { - mocks.clerkListener?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - }); - - test('retries failed native state while processing a queued sync', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - let rejectFirstSync: ((error: Error) => void) | undefined; - mocks.syncClientStateFromJs.mockImplementationOnce(() => { - return new Promise((_resolve, reject) => { - rejectFirstSync = reject; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - act(() => { - mocks.clerkListener?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - rejectFirstSync?.(new Error('native sync failed')); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), true, true); - }); - }); - - test('keeps a pending native client refresh while a token sync is in flight', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - let resolveFirstSync: (() => void) | undefined; - mocks.syncClientStateFromJs.mockImplementationOnce(() => { - return new Promise(resolve => { - resolveFirstSync = resolve; - }); - }); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - act(() => { - mocks.clerkListener?.(); - }); - - await act(async () => { - resolveFirstSync?.(); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); - }); - - test('refreshes native with the saved token after the JS token cache changes', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - }); - - test('tracks an in-flight device-token sync until native reconciliation completes', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - const nativeSync = deferred(); - mocks.syncClientStateFromJs.mockReturnValueOnce(nativeSync.promise); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - let didFinishWaiting = false; - const waiting = waitForPendingJsToNativeSync().then(() => { - didFinishWaiting = true; - }); - await Promise.resolve(); - expect(didFinishWaiting).toBe(false); - - nativeSync.resolve(); - await waiting; - expect(didFinishWaiting).toBe(true); - }); - - test('preserves a failed native refresh until a later refresh succeeds', async () => { - const error = new Error('native refresh failed'); - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockRejectedValueOnce(error); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'failed-client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('failed-client-token', expect.any(String), false, true); - }); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - - mocks.syncClientStateFromJs.mockResolvedValueOnce(undefined); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'recovered-client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( - 'recovered-client-token', - expect.any(String), - false, - true, - ); - }); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - consoleWarn.mockRestore(); - }); - - test('ignores a canceled native refresh that later rejects', async () => { - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - await waitForPendingJsToNativeSync(); - - const staleRefresh = rejectableDeferred(); - const staleError = new Error('canceled native refresh failed'); - mocks.syncClientStateFromJs.mockReturnValueOnce(staleRefresh.promise); - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'stale-client-token'); - }); - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('stale-client-token', expect.any(String), false, true); - }); - - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: 'native-client-token', - }; - rerender( - , - ); - await waitFor(() => { - expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); - }); - - staleRefresh.reject(staleError); - await Promise.resolve(); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - consoleWarn.mockRestore(); - }); - - test('awaits JS session activation during explicit native-to-JS synchronization', async () => { - const activeSession = { - id: 'sess_native', - status: 'active', - user: { id: 'user_native' }, - }; - const refreshedClient = { - id: 'client_1', - signIn: { - id: 'sia_native', - status: 'complete', - createdSessionId: activeSession.id, - }, - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - }; - const fetchClient = vi.fn().mockResolvedValue(refreshedClient); - mocks.tokenCache.getToken.mockResolvedValue('native-client-token'); - mocks.getClientToken.mockResolvedValue('native-client-token'); - mocks.clerkInstance.client = { - id: 'client_1', - signIn: { id: '', status: null, createdSessionId: null }, - signedInSessions: [], - lastActiveSessionId: null, - fetch: fetchClient, - }; - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'native-client-token'); - }); - fetchClient.mockClear(); - - const activation = deferred(); - mocks.clerkInstance.setActive.mockReturnValueOnce(activation.promise); - let didFinishSync = false; - const sync = synchronizeNativeClientToJs().then(() => { - didFinishSync = true; - }); - - await waitFor(() => { - expect(fetchClient).toHaveBeenCalledTimes(1); - expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: activeSession }); - }); - expect(didFinishSync).toBe(false); - - activation.resolve(); - await sync; - expect(didFinishSync).toBe(true); - }); - - test('ignores native client events that echo a JS-originated sync', async () => { - mocks.tokenCache.getToken.mockResolvedValue(null); - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); - }); - - const sourceId = mocks.syncClientStateFromJs.mock.calls[0]?.[1]; - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: false, - deviceToken: true, - }, - deviceToken: 'client-token', - sourceId, - }; - rerender( - , - ); - - await act(async () => {}); - - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - }); - - test('refreshes native from the server after the JS token cache is cleared', async () => { - mocks.tokenCache.getToken.mockResolvedValue('client-token'); - mocks.getClientToken.mockResolvedValue('client-token'); - - render( - , - ); - - await waitFor(() => { - expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); - }); - - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkOptions?.tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); - }); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true); - }); - }); - - test('rejects a foreign session-less native client and restores the signed-in JS token', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - }); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(updateClient).not.toHaveBeenCalledWith(foreignClient); - expect(mocks.clerkInstance.session).toBe(activeSession); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - }); - - test('restores the signed-in JS token when native client verification fails', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockRejectedValue(new Error('verification failed')), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - }); - expect(mocks.clerkInstance.session).toBe(activeSession); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - - consoleError.mockRestore(); - }); - - test('does not replace a signed-in JS token when the native client cannot be verified', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.syncClientStateFromJs.mockClear(); - mocks.tokenCache.saveToken.mockClear(); - mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken); - expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.session).toBe(activeSession); - }); - - test('applies a session-less native response when it belongs to the current JS client', async () => { - const jsDeviceToken = 'js-device-token'; - const nativeDeviceToken = 'native-device-token'; - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const signedOutClient = { - id: 'client_shared', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - - mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken); - mocks.getClientToken.mockResolvedValue(jsDeviceToken); - mocks.clerkInstance.client = { - id: 'client_shared', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(signedOutClient), - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); - }); - - mocks.tokenCache.saveToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: nativeDeviceToken, - }; - rerender( - , - ); - - await waitFor(() => { - expect(updateClient).toHaveBeenCalledWith(signedOutClient); - }); - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken); - expect(mocks.clerkInstance.session).toBeNull(); - }); - - test('rejects a foreign session-less native client during unauthenticated recovery', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const foreignClient = { - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }; - const updateClient = mocks.clerkInstance.updateClient; - const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; - - mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); - mocks.getClientToken.mockResolvedValue('js-device-token'); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: vi.fn().mockResolvedValue(foreignClient), - }; - mocks.clerkInstance.session = activeSession; - - render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); - }); - await waitFor(() => { - expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); - }); - - mocks.getClientToken.mockResolvedValue('ghost-device-token'); - mocks.tokenCache.saveToken.mockClear(); - mocks.syncClientStateFromJs.mockClear(); - - await act(async () => { - await mocks.clerkInstance.handleUnauthenticated(); - }); - - expect(updateClient).not.toHaveBeenCalledWith(foreignClient); - expect(mocks.clerkInstance.session).toBe(activeSession); - expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-device-token'); - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-device-token'); - // The rollback write is notification-suppressed, so the restored token reaches native via a direct push. - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true); - }); - }); - - test('skips native adoption when the cached device token read times out while signed in', async () => { - const activeSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - }; - const fetchClient = vi.fn().mockResolvedValue({ - id: 'client_foreign', - signedInSessions: [], - lastActiveSessionId: null, - }); - - mocks.tokenCache.getToken.mockResolvedValue('js-device-token'); - mocks.getClientToken.mockResolvedValue('js-device-token'); - mocks.clerkInstance.client = { - id: 'client_js', - signedInSessions: [activeSession], - lastActiveSessionId: activeSession.id, - fetch: fetchClient, - }; - mocks.clerkInstance.session = activeSession; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); - }); - - mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); - mocks.tokenCache.saveToken.mockClear(); - mocks.tokenCache.clearToken.mockClear(); - - mocks.nativeClientEvent = { - issuedAt: 1, - changed: { - client: true, - deviceToken: true, - }, - deviceToken: 'native-device-token', - }; - rerender( - , - ); - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 1_200)); - }); - - expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-device-token'); - expect(mocks.tokenCache.clearToken).not.toHaveBeenCalled(); - expect(fetchClient).not.toHaveBeenCalled(); - expect(mocks.clerkInstance.session).toBe(activeSession); - }); -}); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx new file mode 100644 index 00000000000..5e2b0c7e261 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx @@ -0,0 +1,65 @@ +import { type ReactNode, useEffect } from 'react'; + +import type { TokenCache } from '../../../cache/types'; +import type { FakeClerk } from './fakeClerk'; + +type BehaviorEnvironment = { + js: FakeClerk | null; + nativeModule: unknown; +}; + +// Lives on globalThis so module-level mock factories see the same state after `vi.resetModules()`. +const registryKey = Symbol.for('@clerk/expo/nativeClientSyncBehavior'); +const globalRegistry = globalThis as typeof globalThis & { [registryKey]?: BehaviorEnvironment }; + +export function activeEnvironment(): BehaviorEnvironment { + globalRegistry[registryKey] ??= { js: null, nativeModule: null }; + return globalRegistry[registryKey]; +} + +export function setActiveEnvironment(environment: BehaviorEnvironment): void { + globalRegistry[registryKey] = environment; +} + +export function getClerkInstanceForActiveEnvironment(options?: { tokenCache?: TokenCache }): FakeClerk { + const { js } = activeEnvironment(); + if (!js) { + throw new Error('No native client sync behavior environment is active.'); + } + js.tokenCache = options?.tokenCache; + return js; +} + +/** Stands in for `InternalClerkProvider`, which loads the Clerk instance after mounting. */ +export function LoadingClerkProvider({ children, Clerk }: { children?: ReactNode; Clerk?: FakeClerk | null }) { + useEffect(() => { + void Clerk?.load(); + }, [Clerk]); + return <>{children}; +} + +/** A persistent token cache such as one backed by `expo-secure-store`. */ +export class FakePersistentTokenCache implements TokenCache { + #values = new Map(); + + constructor(initial: Record = {}) { + for (const [key, value] of Object.entries(initial)) { + this.#values.set(key, value); + } + } + + read(key: string): string | null { + return this.#values.get(key) ?? null; + } + + getToken = (key: string): Promise => Promise.resolve(this.#values.get(key) ?? null); + + saveToken = (key: string, token: string): Promise => { + this.#values.set(key, token); + return Promise.resolve(); + }; + + clearToken = (key: string): void => { + this.#values.delete(key); + }; +} diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts new file mode 100644 index 00000000000..0f7bfc91839 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts @@ -0,0 +1,275 @@ +import type { TokenCache } from '../../../cache/types'; +import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; +import type { ClientTokenCache } from '../../nativeClientSync'; +import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer'; + +export type FakeSessionResource = { + id: string; + status: 'active'; + activityVersion: number; + updatedAt: Date; + user: { id: string; profileVersion: number; updatedAt: Date }; +}; + +export type JsEmission = { + sessionId: string | null; + signedInSessionIds: string[]; +}; + +type ListenerCallback = (resources: { client: FakeClientResource; session: FakeSessionResource | null }) => void; +type StatusListener = (status: string) => void; + +function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource { + return { + id: session.id, + status: 'active', + activityVersion: session.activityVersion, + updatedAt: new Date(session.profileVersion + session.activityVersion), + user: { id: session.userId, profileVersion: session.profileVersion, updatedAt: new Date(session.profileVersion) }, + }; +} + +/** + * Mirrors clerk-js' `Client` resource: `fetch` mutates the instance in place and returns it. + */ +export class FakeClientResource { + id = ''; + sessions: FakeSessionResource[] = []; + lastActiveSessionId: string | null = null; + signIn = { id: '', status: null as string | null, createdSessionId: null as string | null }; + + constructor(private readonly clerk: FakeClerk) {} + + get signedInSessions(): FakeSessionResource[] { + return this.sessions.filter(session => session.status === 'active'); + } + + fetch = async (_options?: { fetchMaxTries?: number }): Promise => { + const response = await this.clerk.fapi(token => this.clerk.server.getClient('js', token)); + return this.fromJSON(response.client); + }; + + fromJSON(data: FakeServerClientJSON | null): this { + this.id = data?.id ?? ''; + this.sessions = data?.sessions.map(toSessionResource) ?? []; + this.lastActiveSessionId = data?.lastActiveSessionId ?? null; + return this; + } + + __internal_toSnapshot(): FakeServerClientJSON { + return { + id: this.id, + lastActiveSessionId: this.lastActiveSessionId, + sessions: this.sessions.map(session => ({ + id: session.id, + userId: session.user.id, + profileVersion: session.user.profileVersion, + activityVersion: session.activityVersion, + })), + }; + } +} + +/** + * Stand-in for the clerk-js instance returned by `getClerkInstance`, with the same surface the existing + * ClerkProvider tests mock. It talks to the fake Frontend API through the token cache it was built with, + * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`, + * including the compare-and-set write against the token the request used. + */ +export class FakeClerk { + loaded = false; + status = 'loading'; + client: FakeClientResource | undefined = undefined; + session: FakeSessionResource | null = null; + __internal_setActiveInProgress = false; + /** Every state emitted to Clerk listeners (what `useAuth` consumers observe). */ + readonly emissions: JsEmission[] = []; + tokenCache: (TokenCache & Partial) | undefined; + #listeners: ListenerCallback[] = []; + #statusListeners = new Set(); + #onLoadedListeners: Array<() => void> = []; + #loading: Promise | null = null; + + constructor(readonly server: FakeClerkServer) {} + + async fapi(request: (token: string | null) => Promise): Promise { + const token = (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + const response = await request(token); + if (response.token) { + await (this.tokenCache?.saveClientToken + ? this.tokenCache.saveClientToken(response.token, token) + : this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token)); + } + return response; + } + + async currentDeviceToken(): Promise { + return (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + } + + load = (): Promise => { + this.#loading ??= (async () => { + const client = await new FakeClientResource(this).fetch(); + this.updateClient(client); + this.loaded = true; + this.status = 'ready'; + for (const listener of [...this.#statusListeners]) { + listener('ready'); + } + for (const listener of this.#onLoadedListeners.splice(0)) { + listener(); + } + })(); + return this.#loading; + }; + + addListener = (listener: ListenerCallback, options?: { skipInitialEmit?: boolean }): (() => void) => { + this.#listeners.push(listener); + if (this.client && !options?.skipInitialEmit) { + listener({ client: this.client, session: this.session }); + } + return () => { + this.#listeners = this.#listeners.filter(candidate => candidate !== listener); + }; + }; + + addOnLoaded = (listener: () => void): void => { + if (this.loaded) { + listener(); + return; + } + this.#onLoadedListeners.push(listener); + }; + + on = (event: 'status', listener: StatusListener): void => { + if (event === 'status') { + this.#statusListeners.add(listener); + } + }; + + off = (event: 'status', listener: StatusListener): void => { + if (event === 'status') { + this.#statusListeners.delete(listener); + } + }; + + updateClient = (newClient: FakeClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }): void => { + if (!this.client) { + this.session = this.#defaultSession(newClient); + } + this.client = newClient; + if (this.session) { + const sessionId = this.session.id; + this.session = newClient.signedInSessions.find(session => session.id === sessionId) ?? null; + } + if (!options?.__internal_dangerouslySkipEmit) { + this.#emit(); + } + }; + + setActive = async ({ session }: { session: FakeSessionResource | string | null }): Promise => { + this.__internal_setActiveInProgress = true; + try { + const sessionId = typeof session === 'string' ? session : (session?.id ?? null); + if (sessionId && this.client) { + const response = await this.fapi(token => this.server.touchSession('js', token, sessionId)); + this.#applyPiggybackedClient(response); + } + this.session = sessionId ? (this.client?.signedInSessions.find(s => s.id === sessionId) ?? null) : null; + this.#emit(); + } finally { + this.__internal_setActiveInProgress = false; + } + }; + + handleUnauthenticated = async (_options?: { broadcast?: boolean }): Promise => { + if (!this.client || !this.session) { + return; + } + const newClient = await this.client.fetch(); + this.updateClient(newClient); + if (this.session) { + return; + } + return this.setActive({ session: null }); + }; + + __internal_reloadInitialResources = async (): Promise => { + const client = this.client ?? new FakeClientResource(this); + await client.fetch({ fetchMaxTries: 1 }); + this.updateClient(client); + this.#emit(); + }; + + // JS user actions. + + async signIn(userId: string): Promise { + const response = await this.fapi(token => this.server.signIn('js', token, userId)); + this.#applyPiggybackedClient(response); + await this.setActive({ session: response.sessionId }); + return response.sessionId; + } + + async signOut(): Promise { + const response = await this.fapi(token => this.server.removeAllSessions('js', token)); + this.#applyPiggybackedClient(response); + await this.setActive({ session: null }); + } + + async updateProfile(): Promise { + const sessionId = this.session?.id; + if (!sessionId) { + throw new Error('updateProfile requires an active session'); + } + const response = await this.fapi(token => this.server.updateUser('js', token, sessionId)); + if (response.status === 401) { + await this.handleUnauthenticated(); + return; + } + this.#applyPiggybackedClient(response); + } + + /** A session token request; a 401 goes through `handleUnauthenticated` like the clerk-js fetcher. */ + async getSessionToken(): Promise { + const sessionId = this.session?.id; + if (!sessionId) { + return false; + } + const response = await this.fapi(token => this.server.createSessionToken('js', token, sessionId)); + if (response.status === 401) { + await this.handleUnauthenticated(); + return false; + } + this.#applyPiggybackedClient(response); + return true; + } + + #applyPiggybackedClient(response: FapiResponse): void { + if (response.status !== 200) { + return; + } + const client = this.client ?? new FakeClientResource(this); + this.updateClient(client.fromJSON(response.client)); + } + + #defaultSession(client: FakeClientResource): FakeSessionResource | null { + return ( + client.signedInSessions.find(session => session.id === client.lastActiveSessionId) ?? + client.signedInSessions[0] ?? + null + ); + } + + #emit(): void { + if (!this.client) { + return; + } + this.emissions.push({ + sessionId: this.session?.id ?? null, + signedInSessionIds: this.client.signedInSessions.map(session => session.id), + }); + for (const listener of [...this.#listeners]) { + listener({ client: this.client, session: this.session }); + } + } +} diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts new file mode 100644 index 00000000000..47708f092e1 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts @@ -0,0 +1,162 @@ +import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer'; + +export type NativeClientChange = { + client: boolean; + deviceToken: boolean; +}; + +export type NativeClientChangeListener = (change: NativeClientChange, origin: NativeChangeOrigin) => void; + +/** `native` for changes the native SDK made on its own, `js` for changes JS asked native to make. */ +export type NativeChangeOrigin = { kind: 'native' } | { kind: 'js'; tag: string | null }; + +function sameClient(a: FakeServerClientJSON | null, b: FakeServerClientJSON | null): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * The native Clerk SDK: it persists its own device token (keychain / keystore), keeps its own view of + * the client, and notifies listeners when either changes. The native module surface that exposes it to + * JS is engine specific and lives in the adapter. + */ +export class FakeNativeClerk { + token: string | null; + client: FakeServerClientJSON | null = null; + isConfigured = false; + /** Times native fetched its client from the Frontend API. */ + clientRefreshCount = 0; + #listeners = new Set(); + #heldChanges: Array<[NativeClientChange, NativeChangeOrigin]> | null = null; + #startupGate: Promise | null = null; + #nextRefreshError: Error | null = null; + + constructor( + private readonly server: FakeClerkServer, + initialToken: string | null = null, + ) { + this.token = initialToken; + } + + get activeSessionId(): string | null { + return this.client?.lastActiveSessionId ?? null; + } + + get sessionIds(): string[] { + return this.client?.sessions.map(session => session.id) ?? []; + } + + onChange(listener: NativeClientChangeListener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + /** Native keeps its change notifications queued until `releaseChangeNotifications`. */ + holdChangeNotifications(): void { + this.#heldChanges ??= []; + } + + releaseChangeNotifications(): void { + const held = this.#heldChanges ?? []; + this.#heldChanges = null; + for (const [change, origin] of held) { + this.emitChange(change, origin); + } + } + + /** Native stays in its startup phase until the returned release function is called. */ + delayStartup(): () => void { + let release!: () => void; + this.#startupGate = new Promise(resolve => { + release = resolve; + }); + return release; + } + + failNextClientRefresh(error: Error): void { + this.#nextRefreshError = error; + } + + // Primitives used by engine adapters. + + async waitForStartup(): Promise { + await this.#startupGate; + } + + async refreshClient(): Promise { + this.clientRefreshCount += 1; + if (this.#nextRefreshError) { + const error = this.#nextRefreshError; + this.#nextRefreshError = null; + throw error; + } + this.#apply(await this.server.getClient('native', this.token)); + } + + async adoptToken(token: string | null): Promise { + this.token = token; + await this.refreshClient(); + } + + emitChange(change: NativeClientChange, origin: NativeChangeOrigin): void { + if (this.#heldChanges) { + this.#heldChanges.push([change, origin]); + return; + } + for (const listener of this.#listeners) { + listener(change, origin); + } + } + + // Native user actions (native UI components, native-only flows). + + async signIn(userId: string): Promise { + const response = await this.#observe(() => this.server.signIn('native', this.token, userId)); + return response.sessionId; + } + + async setActiveSession(sessionId: string): Promise { + await this.#observe(() => this.server.touchSession('native', this.token, sessionId)); + } + + async signOutSession(sessionId: string): Promise { + await this.#observe(() => this.server.removeSession('native', this.token, sessionId)); + } + + async signOut(): Promise { + await this.#observe(() => this.server.removeAllSessions('native', this.token)); + } + + async rotateDeviceToken(): Promise { + if (this.token) { + this.server.rotateTokenOnNextResponse(this.token); + } + await this.#observe(() => this.server.getClient('native', this.token)); + } + + /** Native ends up on a brand new client with no sessions, e.g. after losing its keychain entry. */ + async switchToForeignSessionlessClient(): Promise { + await this.#observe(() => this.server.createSessionlessClient('native')); + } + + #apply(response: FapiResponse): void { + if (response.token) { + this.token = response.token; + } + this.client = response.client; + } + + async #observe(request: () => Promise): Promise { + const previousToken = this.token; + const previousClient = this.client; + const response = await request(); + this.#apply(response); + const change = { + client: !sameClient(previousClient, this.client), + deviceToken: previousToken !== this.token, + }; + if (change.client || change.deviceToken) { + this.emitChange(change, { kind: 'native' }); + } + return response; + } +} diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts new file mode 100644 index 00000000000..9a3ea40a8f8 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts @@ -0,0 +1,219 @@ +export type FakeServerSessionJSON = { + id: string; + userId: string; + profileVersion: number; + /** Bumped by session activity such as minting a session token. */ + activityVersion: number; +}; + +export type FakeServerClientJSON = { + id: string; + sessions: FakeServerSessionJSON[]; + lastActiveSessionId: string | null; +}; + +export type FapiCaller = 'js' | 'native'; + +export type FapiResponse = { + status: 200 | 401; + client: FakeServerClientJSON | null; + /** The device token the response hands back, like the `authorization` response header. */ + token: string | null; +}; + +export type FapiRequestLogEntry = { + caller: FapiCaller; + operation: string; + token: string | null; +}; + +function networkRoundTrip(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +function cloneClient(client: FakeServerClientJSON): FakeServerClientJSON { + return { + id: client.id, + lastActiveSessionId: client.lastActiveSessionId, + sessions: client.sessions.map(session => ({ ...session })), + }; +} + +/** + * A single Frontend API shared by the JS and native SDKs. Clients are keyed by device token, and + * both SDKs only ever see server state through these requests. + */ +export class FakeClerkServer { + readonly requests: FapiRequestLogEntry[] = []; + #clients = new Map(); + #clientIdByToken = new Map(); + #pendingRotations = new Map(); + #sequence = 0; + + #nextId(prefix: string): string { + this.#sequence += 1; + return `${prefix}_${this.#sequence}`; + } + + #clientFor(token: string | null): FakeServerClientJSON | null { + if (!token) { + return null; + } + const clientId = this.#clientIdByToken.get(token); + return clientId ? (this.#clients.get(clientId) ?? null) : null; + } + + #createClient(): { client: FakeServerClientJSON; token: string } { + const client: FakeServerClientJSON = { id: this.#nextId('client'), sessions: [], lastActiveSessionId: null }; + const token = this.#nextId(`device_token_${client.id}`); + this.#clients.set(client.id, client); + this.#clientIdByToken.set(token, client.id); + return { client, token }; + } + + #respond(token: string | null, client: FakeServerClientJSON | null, status: 200 | 401 = 200): FapiResponse { + let responseToken = client ? token : null; + const pendingRotations = client ? (this.#pendingRotations.get(client.id) ?? 0) : 0; + if (client && pendingRotations > 0) { + this.#pendingRotations.set(client.id, pendingRotations - 1); + responseToken = this.#nextId(`device_token_${client.id}`); + this.#clientIdByToken.set(responseToken, client.id); + } + return { status, client: client ? cloneClient(client) : null, token: responseToken }; + } + + async #request(caller: FapiCaller, operation: string, token: string | null): Promise { + this.requests.push({ caller, operation, token }); + await networkRoundTrip(); + } + + // Seeding helpers (no request is logged). + + seedClient({ users = [], activeUser }: { users?: string[]; activeUser?: string } = {}): { + token: string; + clientId: string; + sessionIds: Record; + } { + const { client, token } = this.#createClient(); + const sessionIds: Record = {}; + for (const userId of users) { + const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; + client.sessions.push(session); + sessionIds[userId] = session.id; + } + const activeUserId = activeUser ?? users[0]; + client.lastActiveSessionId = activeUserId ? sessionIds[activeUserId] : null; + return { token, clientId: client.id, sessionIds }; + } + + rotateTokenOnNextResponse(token: string): void { + const client = this.#clientFor(token); + if (client) { + this.#pendingRotations.set(client.id, (this.#pendingRotations.get(client.id) ?? 0) + 1); + } + } + + inspectClient(token: string | null): FakeServerClientJSON | null { + const client = this.#clientFor(token); + return client ? cloneClient(client) : null; + } + + clientIdForToken(token: string | null): string | null { + return this.#clientFor(token)?.id ?? null; + } + + requestCount(caller: FapiCaller, operation?: string): number { + return this.requests.filter(request => request.caller === caller && (!operation || request.operation === operation)) + .length; + } + + // Frontend API + + async getClient(caller: FapiCaller, token: string | null): Promise { + await this.#request(caller, 'GET /client', token); + return this.#respond(token, this.#clientFor(token)); + } + + async createSessionlessClient(caller: FapiCaller): Promise { + await this.#request(caller, 'POST /client', null); + const { client, token } = this.#createClient(); + return this.#respond(token, client); + } + + async signIn( + caller: FapiCaller, + token: string | null, + userId: string, + ): Promise { + await this.#request(caller, 'POST /client/sign_ins', token); + let client = this.#clientFor(token); + let responseToken = token; + if (!client) { + const created = this.#createClient(); + client = created.client; + responseToken = created.token; + } + const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; + client.sessions.push(session); + client.lastActiveSessionId = session.id; + return { ...this.#respond(responseToken, client), sessionId: session.id }; + } + + async touchSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { + await this.#request(caller, 'POST /client/sessions/:id/touch', token); + const client = this.#clientFor(token); + if (!client?.sessions.some(session => session.id === sessionId)) { + return this.#respond(token, client, 401); + } + client.lastActiveSessionId = sessionId; + return this.#respond(token, client); + } + + async removeSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { + await this.#request(caller, 'POST /client/sessions/:id/remove', token); + const client = this.#clientFor(token); + if (!client) { + return this.#respond(token, null, 401); + } + client.sessions = client.sessions.filter(session => session.id !== sessionId); + if (client.lastActiveSessionId === sessionId) { + client.lastActiveSessionId = client.sessions[0]?.id ?? null; + } + return this.#respond(token, client); + } + + async removeAllSessions(caller: FapiCaller, token: string | null): Promise { + await this.#request(caller, 'DELETE /client/sessions', token); + const client = this.#clientFor(token); + if (client) { + client.sessions = []; + client.lastActiveSessionId = null; + } + return this.#respond(token, client); + } + + async createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise { + await this.#request(caller, 'POST /client/sessions/:id/tokens', token); + const client = this.#clientFor(token); + const session = client?.sessions.find(candidate => candidate.id === sessionId); + if (session) { + session.activityVersion += 1; + } + return this.#respond(token, client, session ? 200 : 401); + } + + async updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise { + await this.#request(caller, 'PATCH /me', token); + const client = this.#clientFor(token); + const session = client?.sessions.find(candidate => candidate.id === sessionId); + if (!client || !session) { + return this.#respond(token, client, 401); + } + for (const candidate of client.sessions) { + if (candidate.userId === session.userId) { + candidate.profileVersion += 1; + } + } + return this.#respond(token, client); + } +} diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts new file mode 100644 index 00000000000..6e308ac447e --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts @@ -0,0 +1,52 @@ +import type { FakePersistentTokenCache } from './environment'; +import type { FakeClerk } from './fakeClerk'; +import type { FakeNativeClerk } from './fakeNativeClerk'; +import type { FakeClerkServer } from './fakeServer'; + +export type NativeSyncScenario = { + /** Shared Frontend API; seed it before rendering to start from existing clients. */ + server?: FakeClerkServer; + /** + * Device token in an app-provided persistent token cache. When omitted (and `persistentTokenCache` + * is not set), ClerkProvider runs with its default in-memory token cache. + */ + jsDeviceToken?: string | null; + persistentTokenCache?: boolean; + /** Device token the native SDK has persisted from a previous launch. */ + nativeDeviceToken?: string | null; + nativeModule?: 'present' | 'absent'; + /** The native SDK fails to configure, e.g. a native build that cannot initialize Clerk. */ + failNativeConfigure?: boolean; + disableNativeClientSync?: boolean; + /** Keep the native SDK in its startup phase until `releaseNativeStartup` is called. */ + delayNativeStartup?: boolean; + strictMode?: boolean; +}; + +/** + * Engine-agnostic handle on a rendered ClerkProvider wired to a fake Frontend API, a fake native SDK, + * and a clerk-js stand-in. Behavior tests only talk to this; engine specifics stay in the adapter. + */ +export type NativeSyncHarness = { + server: FakeClerkServer; + native: FakeNativeClerk; + js: FakeClerk; + /** The app-provided persistent token cache, when the scenario uses one. */ + persistentTokenCache: FakePersistentTokenCache | null; + /** The device token the next JS Frontend API request authenticates with. */ + jsDeviceToken: () => Promise; + /** Names of native module methods the engine has called. */ + nativeModuleCalls: string[]; + /** Runs a world action (JS or native) inside `act`. */ + run: (action: () => T | Promise) => Promise; + /** Lets in-flight requests, notifications, and renders drain. */ + settle: () => Promise; + /** What biometric flows do before a native operation: wait until native reflects JS. */ + awaitJsToNativeSync: () => Promise; + /** What biometric flows do after a native operation: bring JS up to date with native. */ + pullNativeToJs: () => Promise; + releaseNativeStartup: () => void; + unmount: () => void; +}; + +export type RenderWithNativeSync = (scenario?: NativeSyncScenario) => Promise; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts new file mode 100644 index 00000000000..63272a52c61 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts @@ -0,0 +1,4 @@ +// Swap this export to run the behavior suite against a different sync engine. +export { renderWithNativeSync } from './newEngineAdapter'; +export { FakeClerkServer } from './fakeServer'; +export type { NativeSyncHarness, NativeSyncScenario } from './harness'; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx new file mode 100644 index 00000000000..8659c489ee8 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -0,0 +1,501 @@ +import { waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; +import { FakeClerkServer, type NativeSyncHarness, renderWithNativeSync } from './index'; + +vi.mock('../../../polyfills', () => ({})); + +vi.mock('react-native', () => ({ + NativeModules: { BlobModule: {} }, + Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } }, +})); + +vi.mock('expo-secure-store', () => ({ + AFTER_FIRST_UNLOCK: 0, + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock('../../../utils/runtime', () => ({ + isNative: () => true, + isWeb: () => false, +})); + +vi.mock('@clerk/react/internal', async () => { + const { LoadingClerkProvider } = await import('./environment'); + return { InternalClerkProvider: LoadingClerkProvider }; +}); + +vi.mock('../../singleton', async () => { + const { getClerkInstanceForActiveEnvironment } = await import('./environment'); + return { getClerkInstance: getClerkInstanceForActiveEnvironment }; +}); + +vi.mock('../../../specs/NativeClerkModule', async () => { + const { activeEnvironment } = await import('./environment'); + return { + get default() { + return activeEnvironment().nativeModule; + }, + }; +}); + +let harness: NativeSyncHarness | undefined; + +async function start(...args: Parameters): Promise { + harness = await renderWithNativeSync(...args); + return harness; +} + +afterEach(() => { + harness?.unmount(); + harness = undefined; +}); + +function seedDevice(users: string[] = ['user_1'], activeUser?: string) { + const server = new FakeClerkServer(); + return { server, ...server.seedClient({ users, activeUser }) }; +} + +function signedOutEmissionsSince(h: NativeSyncHarness, emissionIndex: number) { + return h.js.emissions.slice(emissionIndex).filter(emission => emission.sessionId === null); +} + +/** Both SDKs authenticate with the same device token and agree on the client's sessions. */ +async function expectConverged(h: NativeSyncHarness): Promise { + await waitFor(async () => { + const jsDeviceToken = await h.jsDeviceToken(); + expect(h.native.token).toBe(jsDeviceToken); + expect(h.server.clientIdForToken(h.native.token)).toBe(h.js.client?.id || null); + expect(h.native.sessionIds).toEqual(h.js.client?.signedInSessions.map(session => session.id) ?? []); + expect(h.native.activeSessionId).toBe(h.js.session?.id ?? null); + }); +} + +describe('native client sync behavior', () => { + describe('native-originated changes', () => { + it('signs JS in with the session a native sign-in created', async () => { + const h = await start(); + + const sessionId = await h.run(() => h.native.signIn('user_1')); + + await waitFor(() => expect(h.js.session?.id).toBe(sessionId)); + await expectConverged(h); + }); + + it('switches the JS active session when native switches sessions, without a signed-out emission', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + expect(h.js.session?.id).toBe(sessionIds.user_1); + const emissionIndex = h.js.emissions.length; + + await h.run(() => h.native.setActiveSession(sessionIds.user_2)); + + await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2)); + await h.settle(); + expect(h.js.session?.id).toBe(sessionIds.user_2); + expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); + await expectConverged(h); + }); + + it('keeps the remaining session active in JS when native signs out of the active one', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const emissionIndex = h.js.emissions.length; + + await h.run(() => h.native.signOutSession(sessionIds.user_1)); + + await waitFor(() => { + expect(h.js.session?.id).toBe(sessionIds.user_2); + expect(h.js.client?.signedInSessions.map(session => session.id)).toEqual([sessionIds.user_2]); + }); + await h.settle(); + expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); + await expectConverged(h); + }); + + it('keeps the remaining session when a JS request is rejected for the removed session before native reports the change', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const emissionIndex = h.js.emissions.length; + + h.native.holdChangeNotifications(); + await h.run(() => h.native.signOutSession(sessionIds.user_1)); + await h.run(() => h.js.getSessionToken()); + + await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2)); + + await h.run(() => h.native.releaseChangeNotifications()); + await h.settle(); + expect(h.js.session?.id).toBe(sessionIds.user_2); + expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); + await expectConverged(h); + }); + + it('signs JS out when native signs out of the only session', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + await h.run(() => h.native.signOut()); + + await waitFor(() => expect(h.js.session).toBeNull()); + expect(await h.jsDeviceToken()).toBe(token); + await expectConverged(h); + }); + + it('follows native onto a new client when native replaces the shared device token', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + await h.run(() => h.native.switchToForeignSessionlessClient()); + await h.settle(); + + expect(h.native.token).not.toBe(token); + expect(h.js.client?.id).toBe(server.clientIdForToken(h.native.token)); + expect(h.js.session).toBeNull(); + await expectConverged(h); + }); + + it('moves JS to the rotated device token when native rotates it', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const emissionIndex = h.js.emissions.length; + + await h.run(() => h.native.rotateDeviceToken()); + expect(h.native.token).not.toBe(token); + + await waitFor(async () => expect(await h.jsDeviceToken()).toBe(h.native.token)); + expect(h.js.session?.id).toBe(sessionIds.user_1); + expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); + await expectConverged(h); + }); + + it('signs JS out when a JS request is rejected after native removed the only session', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + h.native.holdChangeNotifications(); + await h.run(() => h.native.signOut()); + await h.run(() => h.js.getSessionToken()); + + expect(h.js.session).toBeNull(); + expect(h.js.client?.signedInSessions).toEqual([]); + await h.run(() => h.native.releaseChangeNotifications()); + await expectConverged(h); + }); + + it('refetches the JS client a bounded number of times for a burst of rejected requests', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const jsClientFetchesBefore = server.requestCount('js', 'GET /client'); + + h.native.holdChangeNotifications(); + await h.run(() => h.native.signOutSession(sessionIds.user_1)); + await h.run(() => Promise.all(Array.from({ length: 5 }, () => h.js.getSessionToken()))); + + expect(h.js.session?.id).toBe(sessionIds.user_2); + expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2); + await h.run(() => h.native.releaseChangeNotifications()); + await expectConverged(h); + }); + + it('settles a native change without an echo loop', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const jsClientFetchesBefore = server.requestCount('js', 'GET /client'); + const nativeRefreshesBefore = h.native.clientRefreshCount; + + await h.run(() => h.native.setActiveSession(sessionIds.user_2)); + await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2)); + await h.settle(); + + await expectConverged(h); + expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBe(1); + expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(0); + }); + }); + + describe('JS-originated changes', () => { + it('refreshes native onto the same token and signed-in client after a JS sign-in', async () => { + const h = await start(); + + const sessionId = await h.run(() => h.js.signIn('user_1')); + + await waitFor(() => expect(h.native.activeSessionId).toBe(sessionId)); + await expectConverged(h); + }); + + it('refreshes native to the signed-out client after a JS sign-out', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + await h.run(() => h.js.signOut()); + + await waitFor(() => expect(h.native.sessionIds).toEqual([])); + expect(h.js.session).toBeNull(); + expect(h.native.token).toBe(token); + await expectConverged(h); + }); + + it('moves the native active session after JS switches sessions with setActive', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + await h.run(() => h.js.setActive({ session: sessionIds.user_2 })); + + await waitFor(() => expect(h.native.activeSessionId).toBe(sessionIds.user_2)); + await expectConverged(h); + }); + + it('moves native to the rotated device token when a JS response rotates it', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + server.rotateTokenOnNextResponse(token); + await h.run(() => h.js.updateProfile()); + const rotatedToken = await h.jsDeviceToken(); + expect(rotatedToken).not.toBe(token); + + await waitFor(() => expect(h.native.token).toBe(rotatedToken)); + await expectConverged(h); + }); + + it('coalesces a burst of JS client changes into a bounded number of native refreshes', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const refreshesBefore = h.native.clientRefreshCount; + + await h.run(() => Promise.all(Array.from({ length: 10 }, () => h.js.updateProfile()))); + await h.settle(); + + await waitFor(() => expect(h.native.client?.sessions[0]?.profileVersion).toBe(10)); + expect(h.native.clientRefreshCount - refreshesBefore).toBeLessThanOrEqual(3); + await expectConverged(h); + }); + + it('surfaces a failed native refresh through the sync barrier until a later refresh succeeds', async () => { + const { server, token } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const failure = new Error('native refresh failed'); + + h.native.failNextClientRefresh(failure); + await h.run(() => h.js.updateProfile()); + await expect(h.run(() => h.awaitJsToNativeSync())).rejects.toBe(failure); + + await h.run(() => h.js.updateProfile()); + await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined(); + await expectConverged(h); + }); + + it('refreshes native for a profile update but not for session activity that only bumps the session', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const refreshesBefore = h.native.clientRefreshCount; + const updatedAtBefore = h.js.session?.updatedAt.getTime(); + + await h.run(() => Promise.all([h.js.getSessionToken(), h.js.getSessionToken()])); + await h.settle(); + + expect(h.js.session?.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore ?? Infinity); + expect(h.native.clientRefreshCount - refreshesBefore).toBe(0); + + await h.run(() => h.js.updateProfile()); + await h.settle(); + + expect(h.native.clientRefreshCount - refreshesBefore).toBe(1); + expect(h.native.client?.sessions.find(session => session.id === sessionIds.user_1)?.profileVersion).toBe(1); + await expectConverged(h); + }); + + it('settles a JS change without an echo loop', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + const jsClientFetchesBefore = server.requestCount('js', 'GET /client'); + const nativeRefreshesBefore = h.native.clientRefreshCount; + + await h.run(() => h.js.setActive({ session: sessionIds.user_2 })); + await h.settle(); + + await expectConverged(h); + expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(1); + expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(1); + }); + }); + + describe('startup', () => { + it('signs JS in from the native device token when the default in-memory JS cache is empty', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + + const h = await start({ server, nativeDeviceToken: token }); + + await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_1)); + expect(await h.jsDeviceToken()).toBe(token); + await expectConverged(h); + }); + + it('moves native onto the JS device token when native has none', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + + const h = await start({ server, jsDeviceToken: token }); + + expect(h.js.session?.id).toBe(sessionIds.user_1); + await waitFor(() => expect(h.native.token).toBe(token)); + await waitFor(() => expect(h.native.activeSessionId).toBe(sessionIds.user_1)); + await expectConverged(h); + }); + + it('configures native once when ClerkProvider mounts under StrictMode', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + + const h = await start({ server, jsDeviceToken: token, strictMode: true }); + + expect(h.nativeModuleCalls.filter(call => call === 'configureNative')).toHaveLength(1); + expect(h.js.session?.id).toBe(sessionIds.user_1); + await expectConverged(h); + }); + + it('keeps JS working on its own token cache with sync disabled when native fails to configure', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token, failNativeConfigure: true }); + + expect(h.js.session?.id).toBe(sessionIds.user_1); + server.rotateTokenOnNextResponse(token); + await h.run(() => h.js.updateProfile()); + await h.settle(); + + const rotatedToken = h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY) ?? null; + expect(rotatedToken).not.toBe(token); + expect(await h.jsDeviceToken()).toBe(rotatedToken); + expect(h.native.token).toBe(token); + expect(h.nativeModuleCalls.filter(call => call !== 'configureNative' && call !== 'addListener')).toEqual([]); + await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined(); + }); + + it('mirrors the shared device token into the app token cache', async () => { + const server = new FakeClerkServer(); + const stale = server.seedClient(); + const signedIn = server.seedClient({ users: ['user_1'] }); + + const h = await start({ server, jsDeviceToken: stale.token, nativeDeviceToken: signedIn.token }); + server.rotateTokenOnNextResponse(signedIn.token); + await h.run(() => h.js.updateProfile()); + + await waitFor(() => expect(h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY)).toBe(h.native.token)); + expect(h.native.token).not.toBe(signedIn.token); + }); + + it('delivers a JS sign-in that happens while native is still starting up', async () => { + const h = await start({ delayNativeStartup: true }); + + const sessionId = await h.run(() => h.js.signIn('user_1')); + await h.run(() => h.releaseNativeStartup()); + + await waitFor(() => expect(h.native.activeSessionId).toBe(sessionId)); + await expectConverged(h); + }); + }); + + describe('explicit synchronization barrier (biometric flows)', () => { + it('lets a native operation observe the latest JS state and JS observe the native result', async () => { + const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1'); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + await h.run(async () => { + await h.js.setActive({ session: sessionIds.user_2 }); + await h.awaitJsToNativeSync(); + }); + expect(h.native.activeSessionId).toBe(sessionIds.user_2); + + h.native.holdChangeNotifications(); + await h.run(async () => { + await h.native.setActiveSession(sessionIds.user_1); + await h.pullNativeToJs(); + }); + expect(h.js.session?.id).toBe(sessionIds.user_1); + + await h.run(() => h.native.releaseChangeNotifications()); + await h.settle(); + expect(h.js.session?.id).toBe(sessionIds.user_1); + await expectConverged(h); + }); + + it('lets JS observe a session created by a native sign-in once the pull resolves', async () => { + const h = await start(); + + await h.run(() => h.awaitJsToNativeSync()); + h.native.holdChangeNotifications(); + const sessionId = await h.run(async () => { + const createdSessionId = await h.native.signIn('user_1'); + await h.pullNativeToJs(); + return createdSessionId; + }); + + expect(h.js.client?.signedInSessions.map(session => session.id)).toContain(sessionId); + expect(h.js.session?.id).toBe(sessionId); + }); + + it('rejects with environment_unavailable when native does not finish a refresh in time', async () => { + const h = await start({ delayNativeStartup: true }); + await h.run(() => h.js.signIn('user_1')); + + vi.useFakeTimers(); + try { + const barrier = expect(h.awaitJsToNativeSync()).rejects.toMatchObject({ code: 'environment_unavailable' }); + await vi.advanceTimersByTimeAsync(5_000); + await barrier; + } finally { + vi.useRealTimers(); + } + + await h.run(() => h.releaseNativeStartup()); + await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined(); + await expectConverged(h); + }); + }); + + describe('without native client sync', () => { + it.each([ + ['sync is disabled', { disableNativeClientSync: true }], + ['the native module is absent', { nativeModule: 'absent' as const }], + ])('makes no native calls and persists the JS device token in the token cache when %s', async (_, scenario) => { + const h = await start({ persistentTokenCache: true, ...scenario }); + + const sessionId = await h.run(() => h.js.signIn('user_1')); + await h.settle(); + + expect(h.js.session?.id).toBe(sessionId); + const cachedToken = h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY) ?? null; + expect(h.server.clientIdForToken(cachedToken)).toBe(h.js.client?.id); + expect(h.nativeModuleCalls).toEqual([]); + expect(h.native.token).toBeNull(); + }); + }); + + describe('shared device token', () => { + it('keeps the native signed-in session when JS starts with a stale device token', async () => { + const server = new FakeClerkServer(); + const stale = server.seedClient(); + const signedIn = server.seedClient({ users: ['user_1'] }); + + const h = await start({ server, jsDeviceToken: stale.token, nativeDeviceToken: signedIn.token }); + + await waitFor(() => expect(h.js.session?.id).toBe(signedIn.sessionIds.user_1)); + await expectConverged(h); + }); + + it('converges on one device token when JS and native rotate it at the same time', async () => { + const { server, token, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + server.rotateTokenOnNextResponse(token); + await h.run(() => Promise.all([h.native.rotateDeviceToken(), h.js.updateProfile()])); + await h.settle(); + + expect(await h.jsDeviceToken()).not.toBe(token); + expect(h.js.session?.id).toBe(sessionIds.user_1); + await expectConverged(h); + }); + }); +}); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx new file mode 100644 index 00000000000..c5d86387b16 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx @@ -0,0 +1,162 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { vi } from 'vitest'; + +import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; +import type { ClerkExpoNativeModule } from '../../../utils/native-module'; +import { FakePersistentTokenCache, setActiveEnvironment } from './environment'; +import { FakeClerk } from './fakeClerk'; +import { FakeNativeClerk } from './fakeNativeClerk'; +import { FakeClerkServer } from './fakeServer'; +import type { NativeSyncHarness, RenderWithNativeSync } from './harness'; + +const nativeClientInvalidatedEvent = 'clerkNativeClientInvalidated'; + +/** + * The `ClerkExpo` native module contract of the simplified engine: native storage owns the device token, + * `configureNative` keeps native's own token and only adopts the seed when it has none, `setDeviceToken` is a + * compare-and-set, and any change to native's client or token is signalled without a payload. + */ +function createNativeModule( + native: FakeNativeClerk, + calls: string[], + { failConfigure }: { failConfigure: boolean }, +): ClerkExpoNativeModule { + const listeners = new Set<() => void>(); + native.onChange(() => { + for (const listener of [...listeners]) { + listener(); + } + }); + + const notifyIfChanged = async (change: () => Promise | void) => { + const previousToken = native.token; + const previousClient = JSON.stringify(native.client); + await change(); + const changed = { + client: JSON.stringify(native.client) !== previousClient, + deviceToken: native.token !== previousToken, + }; + if (changed.client || changed.deviceToken) { + native.emitChange(changed, { kind: 'native' }); + } + }; + + return { + addListener: (eventName, listener) => { + calls.push('addListener'); + if (eventName !== nativeClientInvalidatedEvent || !listener) { + return { remove: () => undefined }; + } + const invalidated = () => listener(); + listeners.add(invalidated); + return { remove: () => listeners.delete(invalidated) }; + }, + configureNative: (_publishableKey, seedDeviceToken) => { + calls.push('configureNative'); + if (failConfigure) { + return Promise.reject(new Error('native configure failed')); + } + native.token ??= seedDeviceToken; + native.isConfigured = true; + return Promise.resolve(); + }, + getDeviceToken: () => { + calls.push('getDeviceToken'); + return Promise.resolve(native.token); + }, + setDeviceToken: async (token, expected) => { + calls.push('setDeviceToken'); + if (native.token !== expected) { + return false; + } + await notifyIfChanged(() => { + native.token = token; + }); + return true; + }, + refreshClient: async () => { + calls.push('refreshClient'); + await native.waitForStartup(); + await notifyIfChanged(() => native.refreshClient()); + }, + }; +} + +async function flushMacrotasks(count: number): Promise { + for (let i = 0; i < count; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => { + const server = scenario.server ?? new FakeClerkServer(); + const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null); + const js = new FakeClerk(server); + const persistentTokenCache = + scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined + ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {}) + : null; + const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined; + const nativeModuleCalls: string[] = []; + + setActiveEnvironment({ + js, + nativeModule: + scenario.nativeModule === 'absent' + ? null + : createNativeModule(native, nativeModuleCalls, { failConfigure: !!scenario.failNativeConfigure }), + }); + + // Fresh engine modules per scenario: the native module is resolved at import time and the native + // configuration and active engine are module singletons. + vi.resetModules(); + const { ClerkProvider } = await import('../../ClerkProvider'); + const { idle, pullFromNative } = await import('../../nativeClientSync'); + + const provider = ( + + ); + const view = render(scenario.strictMode ? {provider} : provider); + + const settle = async () => { + for (let round = 0; round < 8; round++) { + await act(async () => { + await flushMacrotasks(10); + }); + } + }; + + await waitFor(() => { + if (!js.loaded) { + throw new Error('Clerk has not loaded yet'); + } + }); + await settle(); + + const harness: NativeSyncHarness = { + server, + native, + js, + persistentTokenCache, + jsDeviceToken: () => js.currentDeviceToken(), + nativeModuleCalls, + run: async action => { + let result!: Awaited>; + await act(async () => { + result = await action(); + }); + return result; + }, + settle, + awaitJsToNativeSync: () => idle(), + pullNativeToJs: () => pullFromNative(), + releaseNativeStartup, + unmount: () => view.unmount(), + }; + return harness; +}; diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts deleted file mode 100644 index dde69d9c2f0..00000000000 --- a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { NativeClientEvent } from '../../hooks/useNativeClientEvents'; -import { - __internal_resetNativeClientSyncCoordinator, - registerNativeToJsSyncHandler, - synchronizeNativeClientToJs, - trackPendingJsToNativeSync, - waitForPendingJsToNativeSync, -} from '../nativeClientSyncCoordinator'; - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - -function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { - let reject!: (error: Error) => void; - const promise = new Promise((_resolve, innerReject) => { - reject = innerReject; - }); - return { promise, reject }; -} - -function nativeClientEvent(issuedAt: number): NativeClientEvent { - return { - issuedAt, - changed: { client: true, deviceToken: true }, - deviceToken: `native-token-${issuedAt}`, - }; -} - -let unregister: (() => void) | undefined; - -beforeEach(() => { - __internal_resetNativeClientSyncCoordinator(); -}); - -afterEach(() => { - vi.useRealTimers(); - unregister?.(); - unregister = undefined; -}); - -describe('native client sync coordinator', () => { - test('preserves a JS-to-native sync failure until a later sync succeeds', async () => { - const error = new Error('native sync failed'); - trackPendingJsToNativeSync(Promise.reject(error)); - - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); - - trackPendingJsToNativeSync(Promise.resolve()); - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('does not restore an older failure after a newer sync succeeds', async () => { - const olderSync = rejectableDeferred(); - trackPendingJsToNativeSync(olderSync.promise); - trackPendingJsToNativeSync(Promise.resolve()); - - olderSync.reject(new Error('stale native sync failure')); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('ignores a tracked synchronization that is invalidated before it rejects', async () => { - const staleSync = rejectableDeferred(); - const invalidate = trackPendingJsToNativeSync(staleSync.promise); - - invalidate(); - staleSync.reject(new Error('canceled native refresh failed')); - await Promise.resolve(); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('rejects with environment unavailable when JS-to-native synchronization times out', async () => { - vi.useFakeTimers(); - const pendingSync = deferred(); - trackPendingJsToNativeSync(pendingSync.promise); - - const waiting = expect(waitForPendingJsToNativeSync()).rejects.toMatchObject({ - code: 'environment_unavailable', - message: 'Timed out waiting for the native Clerk client to synchronize.', - }); - - await vi.advanceTimersByTimeAsync(5_000); - await waiting; - pendingSync.resolve(); - }); - - test('honors an extended timeout for native bootstrap synchronization', async () => { - vi.useFakeTimers(); - const pendingSync = deferred(); - trackPendingJsToNativeSync(pendingSync.promise, 15_000); - - const waiting = waitForPendingJsToNativeSync(); - let didSettle = false; - void waiting.then( - () => { - didSettle = true; - }, - () => { - didSettle = true; - }, - ); - - await vi.advanceTimersByTimeAsync(5_000); - expect(didSettle).toBe(false); - - pendingSync.resolve(); - await expect(waiting).resolves.toBeUndefined(); - }); - - test('ignores pending synchronization outcomes from before a reset', async () => { - const staleSync = rejectableDeferred(); - trackPendingJsToNativeSync(staleSync.promise); - - __internal_resetNativeClientSyncCoordinator(); - trackPendingJsToNativeSync(Promise.resolve()); - staleSync.reject(new Error('stale native sync failure')); - - await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - }); - - test('waits for an event sync before starting explicit synchronization', async () => { - const eventSync = deferred(); - const explicitSync = deferred(); - const handler = vi.fn((event?: NativeClientEvent | null) => (event ? eventSync.promise : explicitSync.promise)); - unregister = registerNativeToJsSyncHandler(handler); - - const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1)); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const explicit = synchronizeNativeClientToJs(); - await Promise.resolve(); - expect(handler).toHaveBeenCalledTimes(1); - - eventSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(); - - explicitSync.resolve(); - await Promise.all([fromEvent, explicit]); - }); - - test('runs a follow-up synchronization when an event arrives during explicit synchronization', async () => { - const explicitSync = deferred(); - const followUpSync = deferred(); - const signOutEvent: NativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: true }, - deviceToken: null, - }; - const handler = vi - .fn() - .mockImplementationOnce(() => explicitSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const explicit = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const fromEvent = synchronizeNativeClientToJs(signOutEvent); - expect(fromEvent).toBe(explicit); - - explicitSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(signOutEvent); - - let didFinish = false; - void fromEvent.then(() => { - didFinish = true; - }); - await Promise.resolve(); - expect(didFinish).toBe(false); - - followUpSync.resolve(); - await Promise.all([explicit, fromEvent]); - }); - - test('merges change flags while preserving the latest event snapshot during explicit synchronization', async () => { - const explicitSync = deferred(); - const followUpSync = deferred(); - const handler = vi - .fn() - .mockImplementationOnce(() => explicitSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const explicit = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const clientEvent: NativeClientEvent = { - issuedAt: 1, - changed: { client: true, deviceToken: false }, - deviceToken: 'native-token-1', - }; - const tokenEvent: NativeClientEvent = { - issuedAt: 2, - changed: { client: false, deviceToken: true }, - deviceToken: null, - sourceId: 'native-sign-out', - }; - const fromClientEvent = synchronizeNativeClientToJs(clientEvent); - const fromTokenEvent = synchronizeNativeClientToJs(tokenEvent); - - explicitSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith({ - ...tokenEvent, - changed: { client: true, deviceToken: true }, - }); - - followUpSync.resolve(); - await Promise.all([explicit, fromClientEvent, fromTokenEvent]); - }); - - test('runs a follow-up synchronization for another explicit request', async () => { - const firstSync = deferred(); - const followUpSync = deferred(); - const handler = vi - .fn() - .mockImplementationOnce(() => firstSync.promise) - .mockImplementationOnce(() => followUpSync.promise); - unregister = registerNativeToJsSyncHandler(handler); - - const first = synchronizeNativeClientToJs(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - - const second = synchronizeNativeClientToJs(); - expect(second).toBe(first); - - firstSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - - followUpSync.resolve(); - await Promise.all([first, second]); - }); - - test('serializes native event synchronizations in arrival order', async () => { - const firstSync = deferred(); - const secondSync = deferred(); - const handler = vi.fn((event?: NativeClientEvent | null) => - event?.issuedAt === 1 ? firstSync.promise : secondSync.promise, - ); - unregister = registerNativeToJsSyncHandler(handler); - - const first = synchronizeNativeClientToJs(nativeClientEvent(1)); - const second = synchronizeNativeClientToJs(nativeClientEvent(2)); - - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(1)); - - firstSync.resolve(); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(2)); - - secondSync.resolve(); - await Promise.all([first, second]); - }); -}); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts new file mode 100644 index 00000000000..dc282fc4f3d --- /dev/null +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -0,0 +1,336 @@ +import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; + +import { MemoryTokenCache } from '../cache/MemoryTokenCache'; +import type { TokenCache } from '../cache/types'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import type { ClerkExpoNativeModule } from '../utils/native-module'; + +const CONFIGURE_TIMEOUT_MS = 3_000; +const IDLE_TIMEOUT_MS = 5_000; +const INVALIDATED_EVENT = 'clerkNativeClientInvalidated'; + +type FetchableClient = ClientResource & { fetch?: (options?: { fetchMaxTries?: number }) => Promise }; + +export type SyncableClerk = { + addListener: (listener: () => void) => () => void; + client?: ClientResource | null; + session?: SignedInSessionResource | null; + setActive: (params: { session: SignedInSessionResource | null }) => Promise; + updateClient: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; + handleUnauthenticated: (options?: { broadcast?: boolean }) => Promise; + __internal_reloadInitialResources?: () => Promise; + __internal_setActiveInProgress?: boolean; +}; + +/** A token cache whose client JWT writes are compare-and-set against the token the request used. */ +export type ClientTokenCache = TokenCache & { + saveClientToken: (token: string, requestToken: string | null) => Promise; +}; + +type SingleFlight = { run: () => Promise; current: () => Promise | null }; + +type Engine = { + settled: () => Promise; + pull: () => Promise; + refreshFailure: () => { error: unknown } | null; +}; + +const noop = () => undefined; + +let configuration: { publishableKey: string; ready: Promise } | null = null; +let activeEngine: Engine | null = null; + +function warn(message: string, error?: unknown): void { + if (__DEV__) { + console.warn(`[ClerkExpo] ${message}`, error); + } +} + +function withTimeout(promise: Promise, ms: number, error: () => Error): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(error()), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function singleFlight(task: () => Promise): SingleFlight { + let running: Promise | null = null; + let rerun = false; + const run = (): Promise => { + if (running) { + rerun = true; + return running; + } + running = (async () => { + let failure: { error: unknown } | null = null; + try { + do { + rerun = false; + try { + await task(); + failure = null; + } catch (error) { + failure = { error }; + } + } while (rerun); + } finally { + running = null; + } + if (failure) { + throw failure.error; + } + })(); + return running; + }; + return { run, current: () => running }; +} + +function defaultSession(client: ClientResource | null | undefined): SignedInSessionResource | null { + const sessions = client?.signedInSessions ?? []; + return sessions.find(session => session.id === client?.lastActiveSessionId) ?? sessions[0] ?? null; +} + +function fingerprint(clerk: SyncableClerk): string { + const client = clerk.client; + const user = clerk.session?.user; + return JSON.stringify([ + client?.id ?? null, + client?.lastActiveSessionId ?? null, + client?.signedInSessions.map(session => [session.id, session.status]) ?? [], + user?.id ?? null, + +(user?.updatedAt ?? 0), + ]); +} + +function configureOnce(native: ClerkExpoNativeModule, publishableKey: string, tokenCache: TokenCache) { + if (configuration?.publishableKey !== publishableKey) { + const ready = (async () => { + const seed = (await tokenCache.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + await native.configureNative(publishableKey, seed); + })(); + configuration = { + publishableKey, + ready: withTimeout(ready, CONFIGURE_TIMEOUT_MS, () => new Error('Timed out configuring the native Clerk SDK.')) + .then(() => true) + .catch(error => { + warn('Native client sync is disabled because the native Clerk SDK failed to configure.', error); + return false; + }), + }; + } + return configuration.ready; +} + +/** + * Shares one device token, owned by native storage, between clerk-js and the native Clerk SDK, and + * keeps both clients current by refetching on a payload-free "client changed" signal in each direction. + */ +export function createNativeClientSync( + native: ClerkExpoNativeModule, + publishableKey: string, + getUserTokenCache: () => TokenCache | undefined, +) { + const fallback = () => getUserTokenCache() ?? MemoryTokenCache; + const ready = () => configureOnce(native, publishableKey, fallback()); + let mirroredToken: string | null = null; + let lastFingerprint: string | undefined; + let refreshFailure: { error: unknown } | null = null; + + const mirror = (token: string | null) => { + if (token && token !== mirroredToken) { + mirroredToken = token; + void Promise.resolve(fallback().saveToken(CLERK_CLIENT_JWT_KEY, token)).catch(noop); + } + }; + + const setDeviceToken = (token: string | null, expected: string | null) => + native.setDeviceToken(token, expected).catch(error => { + warn('Failed to update the native device token.', error); + return false; + }); + + const tokenCache: ClientTokenCache = { + getToken: async key => { + if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) { + return fallback().getToken(key); + } + return native.getDeviceToken().catch(error => { + warn('Failed to read the native device token.', error); + return fallback().getToken(key); + }); + }, + saveToken: async (key, token) => { + if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) { + return fallback().saveToken(key, token); + } + await tokenCache.saveClientToken(token, await native.getDeviceToken().catch(() => null)); + }, + clearToken: key => fallback().clearToken?.(key), + saveClientToken: async (token, requestToken) => { + if (!(await ready())) { + return fallback().saveToken(CLERK_CLIENT_JWT_KEY, token); + } + const didSet = token === requestToken || (await setDeviceToken(token, requestToken)); + mirror(didSet ? token : await native.getDeviceToken().catch(() => null)); + }, + }; + + const attach = (clerk: SyncableClerk): (() => void) => { + let reconciling: Promise | null = null; + + // Refetches the JS client with the shared device token and follows native's active session. + const pull = async () => { + const client = clerk.client as FetchableClient | null | undefined; + if (typeof client?.fetch === 'function') { + clerk.updateClient(await client.fetch({ fetchMaxTries: 1 })); + } else { + await clerk.__internal_reloadInitialResources?.(); + } + const target = defaultSession(clerk.client); + if (target && clerk.session?.id !== target.id && !reconciling && !clerk.__internal_setActiveInProgress) { + await clerk.setActive({ session: target }); + } + }; + + const fromNative = singleFlight(async () => { + if (await ready()) { + await pull(); + await reconciling; + lastFingerprint = fingerprint(clerk); + } + }); + + const toNative = singleFlight(async () => { + // Let an in-flight native pull settle first so its own emissions are not echoed back. + await fromNative.current()?.catch(noop); + const next = fingerprint(clerk); + if (next === lastFingerprint || !(await ready())) { + return; + } + lastFingerprint = next; + try { + await native.refreshClient(); + refreshFailure = null; + } catch (error) { + lastFingerprint = undefined; + refreshFailure = { error }; + throw error; + } + }); + + const originalUpdateClient = clerk.updateClient; + const updateClient: SyncableClerk['updateClient'] = (client, options) => { + const sessionId = clerk.session?.id; + const fallbackSession = defaultSession(client); + const wasRemoved = !!sessionId && !client.signedInSessions.some(session => session.id === sessionId); + if (!fallbackSession || !(wasRemoved || reconciling)) { + return originalUpdateClient(client, options); + } + // clerk-js would emit a transient signed-out state before another session is activated. + originalUpdateClient(client, { __internal_dangerouslySkipEmit: true }); + if (reconciling || clerk.__internal_setActiveInProgress) { + return; + } + reconciling = clerk + .setActive({ session: fallbackSession }) + .catch(error => { + warn('Failed to activate the remaining session.', error); + originalUpdateClient(client, options); + }) + .finally(() => { + reconciling = null; + }); + }; + + const originalHandleUnauthenticated = clerk.handleUnauthenticated; + let isHandlingUnauthenticated = false; + const handleUnauthenticated: SyncableClerk['handleUnauthenticated'] = async options => { + if (isHandlingUnauthenticated) { + return; + } + isHandlingUnauthenticated = true; + try { + if (await ready()) { + // Native may have moved the client to another session that JS has not seen yet. + const pendingReconcile = reconciling; + await pull(); + // A reconcile that predates this call may be the request that got the 401, so it is not awaited. + if (reconciling !== pendingReconcile) { + await reconciling; + } + if (clerk.session || reconciling) { + return; + } + } + } catch (error) { + warn('Failed to refresh the client from native after an unauthenticated response.', error); + } finally { + isHandlingUnauthenticated = false; + } + return originalHandleUnauthenticated(options); + }; + + clerk.updateClient = updateClient; + clerk.handleUnauthenticated = handleUnauthenticated; + const removeClerkListener = clerk.addListener( + () => void toNative.run().catch(error => warn('Failed to refresh the native client.', error)), + ); + const subscription = native.addListener?.( + INVALIDATED_EVENT, + () => void fromNative.run().catch(error => warn('Failed to refresh the client from native.', error)), + ); + + const engine: Engine = { + settled: async () => { + while (toNative.current() || fromNative.current()) { + await Promise.all([toNative.current()?.catch(noop), fromNative.current()?.catch(noop)]); + } + }, + pull: fromNative.run, + refreshFailure: () => refreshFailure, + }; + activeEngine = engine; + + return () => { + removeClerkListener(); + subscription?.remove(); + if (clerk.updateClient === updateClient) { + clerk.updateClient = originalUpdateClient; + } + if (clerk.handleUnauthenticated === handleUnauthenticated) { + clerk.handleUnauthenticated = originalHandleUnauthenticated; + } + if (activeEngine === engine) { + activeEngine = null; + } + }; + }; + + return { tokenCache, attach }; +} + +/** + * Resolves once pending JS→native and native→JS syncs have settled. Rejects with the latest native + * refresh failure until a later refresh succeeds, or with `environment_unavailable` after a timeout. + */ +export async function idle(): Promise { + const engine = activeEngine; + if (!engine) { + return; + } + await withTimeout(engine.settled(), IDLE_TIMEOUT_MS, () => + Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { + code: 'environment_unavailable' as const, + }), + ); + const failure = engine.refreshFailure(); + if (failure) { + throw failure.error; + } +} + +/** Refetches the JS client after a native operation and resolves once it has been applied. */ +export function pullFromNative(): Promise { + return activeEngine?.pull() ?? Promise.resolve(); +} diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx deleted file mode 100644 index 5f40ed5287c..00000000000 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ /dev/null @@ -1,1296 +0,0 @@ -import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types'; -import { type MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Platform } from 'react-native'; - -import { MemoryTokenCache } from '../cache'; -import type { TokenCache } from '../cache/types'; -import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; -import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; -import { - registerNativeToJsSyncHandler, - synchronizeNativeClientToJs, - trackPendingJsToNativeSync, -} from './nativeClientSyncCoordinator'; - -const tokenCacheReadTimeoutMs = 1_000; -const nativeDeviceTokenPollIntervalMs = 100; -const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; -const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; -const unauthenticatedRecoveryCooldownMs = 5_000; -const nativeClientConfigurationMaxAttempts = 2; -const nativeClientConfigurationRetryDelayMs = 250; -const nativeClientBootstrapTimeoutMs = Platform.OS === 'android' ? 35_000 : 10_000; -const useNativeClientBootstrapEffect = Platform.OS === 'ios' || Platform.OS === 'android' ? useLayoutEffect : useEffect; - -export type SyncableClerkInstance = { - addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void; - addOnLoaded?: (listener: () => void) => void; - client?: ClientResource; - handleUnauthenticated?: (options?: { broadcast?: boolean }) => Promise; - loaded?: boolean; - off?: (event: 'status', listener: (status: string) => void) => void; - on?: (event: 'status', listener: (status: string) => void) => void; - session?: SignedInSessionResource | null; - status?: string; - setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise; - updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; - __internal_setActiveInProgress?: boolean; - __internal_reloadInitialResources?: () => void | Promise; -}; - -type RefreshableClientResource = ClientResource & { - fetch?: (options?: { fetchMaxTries?: number }) => Promise; - fromJSON?: (data: ClientJSONSnapshot) => ClientResource; -}; - -type NativeRefreshFromJsOptions = { - deviceToken?: string | null; - didChangeClient: boolean; - didChangeDeviceToken: boolean; -}; - -type NativeClientSyncCompletion = { - invalidateTracking: () => void; - promise: Promise; - resolve: () => void; -}; - -type NativeClientBootstrapRegistration = { - clerkInstance: SyncableClerkInstance | null | undefined; - generation: number; - invalidateTracking: () => void; - publishableKey: string; - tokenCache: TokenCache | undefined; -}; - -export type NativeRefreshFromJsController = { - cancel: () => void; - syncDeviceTokenToNative: (deviceToken: string | null) => void; -}; - -export type DeviceTokenCacheListener = (deviceToken: string | null) => void; - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function createNativeClientSyncCompletion(): NativeClientSyncCompletion { - let resolve!: () => void; - const promise = new Promise(innerResolve => { - resolve = innerResolve; - }); - return { invalidateTracking: () => undefined, promise, resolve }; -} - -export function useSyncableTokenCache({ - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, -}: { - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; -}): TokenCache | undefined { - return useMemo(() => { - const effectiveTokenCache = - tokenCache ?? (Platform.OS === 'ios' || Platform.OS === 'android' ? MemoryTokenCache : undefined); - if (!effectiveTokenCache) { - return undefined; - } - - let hasKnownDeviceToken = false; - let knownDeviceToken: string | null = null; - - const notifyDeviceTokenListeners = (deviceToken: string | null) => { - if (suppressTokenCacheNotificationsRef.current > 0) { - return; - } - - for (const listener of tokenCacheListenersRef.current) { - listener(deviceToken); - } - }; - - return { - getToken: async key => { - const token = await effectiveTokenCache.getToken(key); - if (key === CLERK_CLIENT_JWT_KEY && !hasKnownDeviceToken) { - hasKnownDeviceToken = true; - knownDeviceToken = token ?? null; - } - return token; - }, - saveToken: async (key, token) => { - await effectiveTokenCache.saveToken(key, token); - if (key === CLERK_CLIENT_JWT_KEY) { - const didChange = !hasKnownDeviceToken || knownDeviceToken !== token; - hasKnownDeviceToken = true; - knownDeviceToken = token; - if (didChange) { - notifyDeviceTokenListeners(token); - } - } - }, - clearToken: async key => { - await effectiveTokenCache.clearToken?.(key); - if (key === CLERK_CLIENT_JWT_KEY) { - const didChange = !hasKnownDeviceToken || knownDeviceToken !== null; - hasKnownDeviceToken = true; - knownDeviceToken = null; - if (didChange) { - notifyDeviceTokenListeners(null); - } - } - }, - }; - }, [suppressTokenCacheNotificationsRef, tokenCache, tokenCacheListenersRef]); -} - -async function readNativeDeviceToken({ waitForToken }: { waitForToken: boolean }): Promise { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo?.getClientToken) { - return null; - } - - const startedAt = Date.now(); - let remainingMs = nativeDeviceTokenAvailabilityTimeoutMs; - - do { - const nativeDeviceToken = await ClerkExpo.getClientToken(); - if (nativeDeviceToken) { - return nativeDeviceToken; - } - - if (!waitForToken) { - return null; - } - - remainingMs = nativeDeviceTokenAvailabilityTimeoutMs - (Date.now() - startedAt); - if (remainingMs <= 0) { - return null; - } - - await delay(Math.min(nativeDeviceTokenPollIntervalMs, remainingMs)); - } while (remainingMs > 0); - - return null; -} - -async function syncDeviceTokenToCache(tokenCache: TokenCache | undefined, deviceToken: string | null): Promise { - if (deviceToken) { - await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, deviceToken); - return; - } - - await tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY); -} - -async function syncDeviceTokenToCacheWithoutNotifying({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - deviceToken: string | null; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - suppressTokenCacheNotificationsRef.current += 1; - try { - await syncDeviceTokenToCache(tokenCache, deviceToken); - } finally { - suppressTokenCacheNotificationsRef.current = Math.max(0, suppressTokenCacheNotificationsRef.current - 1); - } -} - -async function syncNativeDeviceTokenToCache({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - deviceToken: string | null; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - if (suppressTokenCacheNotificationsRef) { - await syncDeviceTokenToCacheWithoutNotifying({ - deviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - return; - } - - await syncDeviceTokenToCache(tokenCache, deviceToken); -} - -function getDefaultSignedInSession(client: ClientResource | null | undefined): SignedInSessionResource | null { - if (!client) { - return null; - } - - if (client.lastActiveSessionId) { - const lastActiveSession = client.signedInSessions.find(session => session.id === client.lastActiveSessionId); - if (lastActiveSession) { - return lastActiveSession; - } - } - - return client.signedInSessions[0] ?? null; -} - -function canRefreshJsClientFromServer(clerkInstance: SyncableClerkInstance): boolean { - const client = clerkInstance.client as RefreshableClientResource | undefined; - - return typeof client?.fetch === 'function' && typeof clerkInstance.updateClient === 'function'; -} - -function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise { - const client = clerkInstance.client as RefreshableClientResource | undefined; - - if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') { - return Promise.resolve(null); - } - - return client.fetch({ fetchMaxTries: 1 }); -} - -type ClientStateSnapshot = { - id: string | null; - hasSignedInSession: boolean; - restore: (() => ClientResource) | null; -}; - -function snapshotClientState(client: ClientResource | null | undefined): ClientStateSnapshot { - const resource = client as RefreshableClientResource | undefined; - const fromJSON = resource?.fromJSON?.bind(resource); - let restore: ClientStateSnapshot['restore'] = null; - - if (resource && fromJSON) { - const state = resource.__internal_toSnapshot(); - restore = () => fromJSON(state); - } - - return { - id: client?.id ?? null, - hasSignedInSession: Boolean(client && getDefaultSignedInSession(client)), - restore, - }; -} - -// Client.fetch mutates the resource, so compare against pre-fetch values. -function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refreshedClient: ClientResource): boolean { - if (!previousSnapshot.id || !refreshedClient.id || previousSnapshot.id === refreshedClient.id) { - return false; - } - - return previousSnapshot.hasSignedInSession && refreshedClient.signedInSessions.length === 0; -} - -async function refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient = false, - reloadInitialResources, - shouldSyncDeviceToken = true, - suppressDeviceTokenRollbackNotification = false, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeDeviceToken: string | null; - previousDeviceToken?: string | null; - rejectForeignSessionlessClient?: boolean; - reloadInitialResources: boolean; - shouldSyncDeviceToken?: boolean; - suppressDeviceTokenRollbackNotification?: boolean; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const previousClientSnapshot = snapshotClientState(clerkInstance.client); - - const restorePreviousDeviceToken = async () => { - if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) { - return; - } - - // On the 401 path a rollback is part of recovery, not an external rotation, so it must not - // reopen the cooldown. The native-event path still notifies so native resyncs the restored token. - await syncNativeDeviceTokenToCache({ - deviceToken: previousDeviceToken, - suppressTokenCacheNotificationsRef: suppressDeviceTokenRollbackNotification - ? suppressTokenCacheNotificationsRef - : undefined, - tokenCache, - }); - }; - - let refreshedClient: ClientResource | null; - try { - if (shouldSyncDeviceToken) { - await syncNativeDeviceTokenToCache({ - deviceToken: nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } - - refreshedClient = await fetchRefreshedJsClient(clerkInstance); - } catch (error) { - await restorePreviousDeviceToken(); - throw error; - } - - if (refreshedClient) { - if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { - await restorePreviousDeviceToken(); - const restoredClient = previousClientSnapshot.restore?.(); - if (restoredClient) { - clerkInstance.updateClient?.(restoredClient); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - } - return 'restored'; - } - - clerkInstance.updateClient?.(refreshedClient); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return 'refreshed'; - } - - if (reloadInitialResources && typeof clerkInstance.__internal_reloadInitialResources === 'function') { - await clerkInstance.__internal_reloadInitialResources(); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return getDefaultSignedInSession(clerkInstance.client) ? 'refreshed' : false; - } - - return false; -} - -async function reloadJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeDeviceToken: string; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - await syncNativeDeviceTokenToCache({ - deviceToken: nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - - await clerkInstance.__internal_reloadInitialResources?.(); - await reconcileJsActiveSessionFromClient({ - clerkInstance, - }); - return Boolean(getDefaultSignedInSession(clerkInstance.client)); -} - -async function recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - error: unknown; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - if (!nativeDeviceToken) { - return false; - } - - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh JS client with native device token:', error); - } - - try { - return await reloadJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } catch (recoveryError) { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to recover JS client after unauthenticated state:', recoveryError); - } - return false; - } -} - -async function reconcileJsActiveSessionFromClient({ - clerkInstance, -}: { - clerkInstance: SyncableClerkInstance; -}): Promise { - const fallbackSession = getDefaultSignedInSession(clerkInstance.client); - if (!fallbackSession || typeof clerkInstance.setActive !== 'function') { - return; - } - - const currentSession = clerkInstance.session; - const currentSessionStillExists = currentSession - ? clerkInstance.client?.signedInSessions.some(session => session.id === currentSession.id) - : false; - - if (currentSessionStillExists && currentSession?.id === fallbackSession.id) { - return; - } - - await clerkInstance.setActive({ session: fallbackSession }); -} - -async function runWithSuppressedJsClientChanges( - suppressJsClientChangedRef: MutableRefObject | undefined, - task: () => Promise, -): Promise { - if (!suppressJsClientChangedRef) { - return task(); - } - - suppressJsClientChangedRef.current += 1; - try { - return await task(); - } finally { - suppressJsClientChangedRef.current = Math.max(0, suppressJsClientChangedRef.current - 1); - } -} - -function mergePendingNativeRefreshOptions( - current: NativeRefreshFromJsOptions | null, - next: NativeRefreshFromJsOptions, -): NativeRefreshFromJsOptions { - if (!current) { - return next; - } - - const merged: NativeRefreshFromJsOptions = { - didChangeClient: current.didChangeClient || next.didChangeClient, - didChangeDeviceToken: current.didChangeDeviceToken || next.didChangeDeviceToken, - }; - - if ('deviceToken' in current) { - merged.deviceToken = current.deviceToken ?? null; - } - - if ('deviceToken' in next) { - merged.deviceToken = next.deviceToken ?? null; - } - - return merged; -} - -const tokenCacheReadTimedOut = Symbol('tokenCacheReadTimedOut'); - -// `undefined` = read timed out, `null` = confirmed missing token. -async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise { - if (!tokenCache) { - return null; - } - - let timeoutId: ReturnType | undefined; - try { - const result = await Promise.race([ - tokenCache.getToken(CLERK_CLIENT_JWT_KEY), - new Promise(resolve => { - timeoutId = setTimeout(() => resolve(tokenCacheReadTimedOut), tokenCacheReadTimeoutMs); - }), - ]); - if (result === tokenCacheReadTimedOut) { - return undefined; - } - return result ?? null; - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - -async function syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - clerkInstance: SyncableClerkInstance; - nativeRefreshFromJsControllerRef?: MutableRefObject; - nativeClientEvent?: NativeClientEvent | null; - suppressJsClientChangedRef?: MutableRefObject; - suppressTokenCacheNotificationsRef?: MutableRefObject; - tokenCache: TokenCache | undefined; -}): Promise { - const didChangeClient = nativeClientEvent?.changed.client ?? true; - const didChangeDeviceToken = nativeClientEvent?.changed.deviceToken ?? true; - - if (!didChangeClient && !didChangeDeviceToken) { - return; - } - - const nativeDeviceToken = nativeClientEvent - ? nativeClientEvent.deviceToken - : await readNativeDeviceToken({ - waitForToken: true, - }); - - if (!nativeDeviceToken && !nativeClientEvent) { - return; - } - - const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined; - const hasSignedInJsClient = Boolean(getDefaultSignedInSession(clerkInstance.client)); - - if (didChangeDeviceToken && hasSignedInJsClient) { - // Timed-out cache read leaves no rollback snapshot, so keep JS authoritative. - if (previousDeviceToken === undefined) { - return; - } - - if (previousDeviceToken && !canRefreshJsClientFromServer(clerkInstance)) { - nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken); - return; - } - } - - await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - nativeRefreshFromJsControllerRef?.current?.cancel(); - - await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: true, - shouldSyncDeviceToken: didChangeDeviceToken, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - }); -} - -/** - * Syncs JS SDK client changes to the native Clerk SDK so native components - * (UserButton, UserProfileView) stay in sync after JS-owned resource changes. - * - * Must be rendered inside `ClerkReactProvider` so the Clerk instance has loaded - * resources to emit. - */ -export function NativeClientSync({ - enabled, - clerkInstance, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - tokenCacheListenersRef, -}: { - enabled: boolean; - clerkInstance: SyncableClerkInstance | null | undefined; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - tokenCacheListenersRef: MutableRefObject>; -}): null { - const isRefreshingNativeFromJsRef = useRef(false); - const nativeRefreshPromiseRef = useRef | null>(null); - const invalidateTrackedNativeRefreshRef = useRef<(() => void) | null>(null); - const pendingNativeRefreshRef = useRef(null); - const pendingNativeRefreshBeforeReadyRef = useRef(null); - const pendingNativeRefreshBeforeReadyCompletionRef = useRef(null); - const nativeRefreshGenerationRef = useRef(0); - const lastUnauthenticatedRecoveryRef = useRef(undefined); - const enabledRef = useRef(enabled); - enabledRef.current = enabled; - - const queueNativeRefreshBeforeReady = useCallback((options: NativeRefreshFromJsOptions) => { - pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( - pendingNativeRefreshBeforeReadyRef.current, - options, - ); - if (!pendingNativeRefreshBeforeReadyCompletionRef.current) { - const completion = createNativeClientSyncCompletion(); - pendingNativeRefreshBeforeReadyCompletionRef.current = completion; - completion.invalidateTracking = trackPendingJsToNativeSync(completion.promise); - } - }, []); - - const cancelNativeRefreshFromJs = useCallback(() => { - invalidateTrackedNativeRefreshRef.current?.(); - invalidateTrackedNativeRefreshRef.current = null; - pendingNativeRefreshRef.current = null; - pendingNativeRefreshBeforeReadyRef.current = null; - pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking(); - pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); - pendingNativeRefreshBeforeReadyCompletionRef.current = null; - nativeRefreshGenerationRef.current += 1; - isRefreshingNativeFromJsRef.current = false; - nativeRefreshPromiseRef.current = null; - }, []); - - useEffect(() => { - if ( - !clerkInstance || - typeof clerkInstance.updateClient !== 'function' || - typeof clerkInstance.setActive !== 'function' - ) { - return; - } - - const originalUpdateClient = clerkInstance.updateClient.bind(clerkInstance); - let isReconcilingRemovedActiveSession = false; - - const updateClient: SyncableClerkInstance['updateClient'] = (newClient, options) => { - const currentSessionId = clerkInstance.session?.id; - const fallbackSession = getDefaultSignedInSession(newClient); - const currentSessionWasRemoved = currentSessionId - ? !newClient.signedInSessions.some(session => session.id === currentSessionId) - : false; - const alreadyReconcilingRemovedActiveSession = isReconcilingRemovedActiveSession; - - if ((currentSessionWasRemoved || alreadyReconcilingRemovedActiveSession) && fallbackSession) { - // Clerk JS briefly emits signed-out when the active session disappears, - // even if the refreshed client still has another signed-in session. - // Keep that transient state internal so native session switching does - // not dismiss mounted native UI before setActive settles on JS. - originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true }); - - if (clerkInstance.__internal_setActiveInProgress || alreadyReconcilingRemovedActiveSession) { - return; - } - - isReconcilingRemovedActiveSession = true; - void runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - try { - await clerkInstance.setActive?.({ session: fallbackSession }); - } catch (error) { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to set remaining active JS session:', error); - } - originalUpdateClient(newClient, options); - } finally { - isReconcilingRemovedActiveSession = false; - } - }); - return; - } - - if (options) { - originalUpdateClient(newClient, options); - return; - } - - originalUpdateClient(newClient); - }; - - clerkInstance.updateClient = updateClient; - - return () => { - if (clerkInstance.updateClient === updateClient) { - clerkInstance.updateClient = originalUpdateClient; - } - }; - }, [clerkInstance, suppressJsClientChangedRef]); - - const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): Promise => { - if (isRefreshingNativeFromJsRef.current) { - pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options); - nativeRefreshGenerationRef.current += 1; - return nativeRefreshPromiseRef.current ?? Promise.resolve(); - } - - const initialGeneration = nativeRefreshGenerationRef.current + 1; - nativeRefreshGenerationRef.current = initialGeneration; - isRefreshingNativeFromJsRef.current = true; - - const refreshNativeFromJsClient = async ( - options: NativeRefreshFromJsOptions, - generation: number, - ): Promise => { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo || generation !== nativeRefreshGenerationRef.current) { - return; - } - - const deviceToken = options.didChangeDeviceToken ? (options.deviceToken ?? null) : null; - if (generation !== nativeRefreshGenerationRef.current) { - return; - } - - const sourceId = `${nativeClientSyncSourceIdPrefix}-${generation}`; - await ClerkExpo.syncClientStateFromJs( - deviceToken, - sourceId, - options.didChangeClient, - options.didChangeDeviceToken, - ); - }; - - const nativeRefreshPromise = (async () => { - let pendingOptions = options; - let generation = initialGeneration; - let refreshError: unknown; - let didRefreshFail = false; - do { - pendingNativeRefreshRef.current = null; - try { - await refreshNativeFromJsClient(pendingOptions, generation); - refreshError = undefined; - didRefreshFail = false; - } catch (error: unknown) { - refreshError = error; - didRefreshFail = true; - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); - } - if (pendingNativeRefreshRef.current) { - pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions( - pendingOptions, - pendingNativeRefreshRef.current, - ); - } - } - pendingOptions = pendingNativeRefreshRef.current ?? { - didChangeClient: false, - didChangeDeviceToken: false, - }; - if (pendingNativeRefreshRef.current !== null) { - generation = nativeRefreshGenerationRef.current + 1; - nativeRefreshGenerationRef.current = generation; - } - } while (pendingNativeRefreshRef.current !== null); - - if (didRefreshFail) { - throw refreshError; - } - })(); - const finishNativeRefresh = () => { - if (nativeRefreshPromiseRef.current === nativeRefreshPromise) { - isRefreshingNativeFromJsRef.current = false; - nativeRefreshPromiseRef.current = null; - invalidateTrackedNativeRefreshRef.current = null; - } - }; - - nativeRefreshPromiseRef.current = nativeRefreshPromise; - void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh); - invalidateTrackedNativeRefreshRef.current = trackPendingJsToNativeSync(nativeRefreshPromise); - return nativeRefreshPromise; - }, []); - - useEffect(() => { - nativeRefreshFromJsControllerRef.current = { - cancel: cancelNativeRefreshFromJs, - syncDeviceTokenToNative: deviceToken => { - void queueNativeRefreshFromJs({ - deviceToken, - didChangeClient: false, - didChangeDeviceToken: true, - }); - }, - }; - - return () => { - if (nativeRefreshFromJsControllerRef.current?.cancel === cancelNativeRefreshFromJs) { - nativeRefreshFromJsControllerRef.current = null; - } - }; - }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef, queueNativeRefreshFromJs]); - - useEffect(() => { - if (!enabled) { - return; - } - - if (pendingNativeRefreshBeforeReadyRef.current) { - const pendingOptions = pendingNativeRefreshBeforeReadyRef.current; - const pendingCompletion = pendingNativeRefreshBeforeReadyCompletionRef.current; - pendingNativeRefreshBeforeReadyRef.current = null; - pendingNativeRefreshBeforeReadyCompletionRef.current = null; - void queueNativeRefreshFromJs(pendingOptions).then(pendingCompletion?.resolve, pendingCompletion?.resolve); - } - }, [enabled, queueNativeRefreshFromJs]); - - useEffect(() => { - return cancelNativeRefreshFromJs; - }, [cancelNativeRefreshFromJs]); - - useEffect(() => { - const listener: DeviceTokenCacheListener = deviceToken => { - // A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown. - lastUnauthenticatedRecoveryRef.current = undefined; - - const options = { - deviceToken, - didChangeClient: false, - didChangeDeviceToken: true, - }; - - if (!enabledRef.current) { - if (clerkInstance?.loaded) { - queueNativeRefreshBeforeReady(options); - } - return; - } - - void queueNativeRefreshFromJs(options); - }; - const tokenCacheListeners = tokenCacheListenersRef.current; - - tokenCacheListeners.add(listener); - return () => { - tokenCacheListeners.delete(listener); - }; - }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, tokenCacheListenersRef]); - - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') { - return; - } - - const originalHandleUnauthenticated = clerkInstance.handleUnauthenticated.bind(clerkInstance); - let isHandlingUnauthenticated = false; - - const handleUnauthenticated: SyncableClerkInstance['handleUnauthenticated'] = async options => { - if (isHandlingUnauthenticated) { - return; - } - - isHandlingUnauthenticated = true; - try { - // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. - const lastRecovery = lastUnauthenticatedRecoveryRef.current; - if (lastRecovery !== undefined) { - const elapsed = Date.now() - lastRecovery; - // A backwards clock jump makes elapsed negative; treat it as expired instead of waiting out the gap. - if (elapsed >= 0 && elapsed < unauthenticatedRecoveryCooldownMs) { - return await originalHandleUnauthenticated(options); - } - } - lastUnauthenticatedRecoveryRef.current = Date.now(); - - try { - return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - try { - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - const previousDeviceToken = await getCachedDeviceToken(tokenCache); - // Native may have already moved the server-side client to a new - // active session. Refresh JS before allowing Clerk JS' stale-session - // 401 path to collapse the whole client to signed out. - const result = await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: false, - suppressDeviceTokenRollbackNotification: true, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - // The suppressed rollback write skips the listener that resyncs native, so the - // restored token must be pushed to native from here. - if (result === 'restored' && previousDeviceToken !== undefined) { - nativeRefreshFromJsControllerRef.current?.syncDeviceTokenToNative(previousDeviceToken); - } - if (result) { - return; - } - } catch (error) { - const didRecover = await recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; - } - } - - return originalHandleUnauthenticated(options); - }); - } finally { - // Slow attempts must not finish with a mostly spent window, so the stamp moves to settle - // time. A rotation mid-attempt cleared the ref to force a fresh attempt; keep it cleared. - if (lastUnauthenticatedRecoveryRef.current !== undefined) { - lastUnauthenticatedRecoveryRef.current = Date.now(); - } - } - } finally { - isHandlingUnauthenticated = false; - } - }; - - clerkInstance.handleUnauthenticated = handleUnauthenticated; - - return () => { - if (clerkInstance.handleUnauthenticated === handleUnauthenticated) { - clerkInstance.handleUnauthenticated = originalHandleUnauthenticated; - } - }; - }, [ - clerkInstance, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - ]); - - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { - return; - } - - const unsubscribe = clerkInstance.addListener( - () => { - if (suppressJsClientChangedRef.current > 0) { - return; - } - - if (!enabledRef.current) { - if (clerkInstance.loaded) { - queueNativeRefreshBeforeReady({ - didChangeClient: true, - didChangeDeviceToken: false, - }); - } - return; - } - - void queueNativeRefreshFromJs({ - didChangeClient: true, - didChangeDeviceToken: false, - }); - }, - { skipInitialEmit: true }, - ); - - return () => { - unsubscribe(); - }; - }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, suppressJsClientChangedRef]); - - return null; -} - -function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise { - if (clerkInstance.loaded) { - return Promise.resolve(); - } - - if (typeof clerkInstance.on === 'function' && typeof clerkInstance.off === 'function') { - return new Promise(resolve => { - let didSettle = false; - const settle = () => { - if (didSettle) { - return; - } - didSettle = true; - clerkInstance.off?.('status', handleStatus); - resolve(); - }; - const handleStatus = (status: string) => { - if (status === 'ready' || status === 'degraded' || status === 'error') { - settle(); - } - }; - - clerkInstance.on?.('status', handleStatus); - if ( - clerkInstance.loaded || - clerkInstance.status === 'ready' || - clerkInstance.status === 'degraded' || - clerkInstance.status === 'error' - ) { - settle(); - } - }); - } - - if (typeof clerkInstance.addOnLoaded === 'function') { - return new Promise(resolve => clerkInstance.addOnLoaded?.(resolve)); - } - - if (__DEV__) { - console.warn('[ClerkProvider] Clerk instance has no load status listener'); - } - return Promise.resolve(); -} - -export function useNativeClientBootstrap({ - enabled, - publishableKey, - nativeRefreshFromJsControllerRef, - suppressTokenCacheNotificationsRef, - tokenCache, - clerkInstance, -}: { - enabled: boolean; - publishableKey: string; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; - clerkInstance: SyncableClerkInstance | null | undefined; -}) { - const activeBootstrapRef = useRef(null); - const bootstrapGenerationRef = useRef(0); - const isMountedRef = useRef(true); - const [readyPublishableKey, setReadyPublishableKey] = useState(null); - - useNativeClientBootstrapEffect(() => { - isMountedRef.current = true; - const canBootstrap = enabled && (Platform.OS === 'ios' || Platform.OS === 'android') && Boolean(publishableKey); - const activeBootstrap = activeBootstrapRef.current; - const canReuseActiveBootstrap = - canBootstrap && - activeBootstrap?.publishableKey === publishableKey && - activeBootstrap.clerkInstance === clerkInstance && - activeBootstrap.tokenCache === tokenCache; - - if (activeBootstrap && !canReuseActiveBootstrap) { - activeBootstrap.invalidateTracking(); - activeBootstrapRef.current = null; - setReadyPublishableKey(null); - } - - if (canBootstrap && !activeBootstrapRef.current) { - const configuringPublishableKey = publishableKey; - const bootstrapRegistration: NativeClientBootstrapRegistration = { - clerkInstance, - generation: ++bootstrapGenerationRef.current, - invalidateTracking: () => undefined, - publishableKey: configuringPublishableKey, - tokenCache, - }; - activeBootstrapRef.current = bootstrapRegistration; - setReadyPublishableKey(null); - const isCurrentConfiguration = () => - isMountedRef.current && - activeBootstrapRef.current === bootstrapRegistration && - bootstrapGenerationRef.current === bootstrapRegistration.generation; - - const configureNativeClerk = async () => { - try { - const ClerkExpo = NativeClerkModule; - - if (ClerkExpo?.configure) { - if (clerkInstance) { - await waitForClerkInstanceLoad(clerkInstance); - - if (!isCurrentConfiguration()) { - return; - } - } - - let initialJsDeviceToken: string | null = null; - try { - initialJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; - } catch (e) { - if (__DEV__) { - console.warn('[ClerkProvider] Token cache read failed:', e); - } - } - - if (!isCurrentConfiguration()) { - return; - } - - await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); - - if (!isCurrentConfiguration()) { - return; - } - - if (clerkInstance) { - const currentJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - - if (!isCurrentConfiguration()) { - return; - } - - if (currentJsDeviceToken !== nativeDeviceToken) { - if ( - !nativeDeviceToken || - (initialJsDeviceToken !== null && currentJsDeviceToken !== initialJsDeviceToken) - ) { - nativeRefreshFromJsControllerRef.current?.cancel(); - await ClerkExpo.syncClientStateFromJs( - currentJsDeviceToken, - `${nativeClientSyncSourceIdPrefix}-bootstrap`, - true, - true, - ); - } else { - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent: { - changed: { client: true, deviceToken: true }, - deviceToken: nativeDeviceToken, - issuedAt: Date.now(), - }, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - } - } - } - - if (isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); - } - } - } catch (error) { - const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module'); - if (isNativeModuleNotFound) { - if (__DEV__) { - console.debug( - `[ClerkProvider] Native Clerk module not available. ` + - `To enable native features, add "@clerk/expo" to your app.json plugins array.`, - ); - } - } else if (__DEV__) { - console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); - } - throw error; - } - }; - const configureNativeClerkWithRetry = async () => { - for (let attempt = 1; attempt <= nativeClientConfigurationMaxAttempts; attempt++) { - try { - await configureNativeClerk(); - return; - } catch (error) { - const isNativeModuleNotFound = - error instanceof Error && error.message.includes('Cannot find native module'); - if ( - !isCurrentConfiguration() || - isNativeModuleNotFound || - attempt === nativeClientConfigurationMaxAttempts - ) { - if (isCurrentConfiguration()) { - nativeRefreshFromJsControllerRef.current?.cancel(); - } - throw error; - } - - await new Promise(resolve => setTimeout(resolve, nativeClientConfigurationRetryDelayMs)); - if (!isCurrentConfiguration()) { - return; - } - } - } - }; - const nativeClientBootstrap = configureNativeClerkWithRetry(); - bootstrapRegistration.invalidateTracking = trackPendingJsToNativeSync( - nativeClientBootstrap, - nativeClientBootstrapTimeoutMs, - ); - void nativeClientBootstrap; - } - - return () => { - isMountedRef.current = false; - const bootstrapRegistration = activeBootstrapRef.current; - queueMicrotask(() => { - if (!isMountedRef.current && activeBootstrapRef.current === bootstrapRegistration) { - bootstrapRegistration?.invalidateTracking(); - activeBootstrapRef.current = null; - } - }); - }; - }, [ - enabled, - publishableKey, - nativeRefreshFromJsControllerRef, - suppressTokenCacheNotificationsRef, - tokenCache, - clerkInstance, - ]); - - return { - isMountedRef, - isNativeClientReady: readyPublishableKey === publishableKey, - }; -} - -export function useNativeClientEventSync({ - enabled, - clerkInstance, - isMountedRef, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, -}: { - enabled: boolean; - clerkInstance: SyncableClerkInstance | null | undefined; - isMountedRef: MutableRefObject; - nativeRefreshFromJsControllerRef: MutableRefObject; - suppressJsClientChangedRef: MutableRefObject; - suppressTokenCacheNotificationsRef: MutableRefObject; - tokenCache: TokenCache | undefined; -}) { - const { nativeClientEvent } = useNativeClientEvents(enabled); - - useEffect(() => { - if (!clerkInstance) { - return; - } - - return registerNativeToJsSyncHandler(async event => { - if (!isMountedRef.current) { - throw new Error('ClerkProvider was unmounted before native client synchronization completed.'); - } - - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent: event, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - }); - }, [ - clerkInstance, - isMountedRef, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - ]); - - useEffect(() => { - if (!enabled || !nativeClientEvent || nativeClientEvent.sourceId?.startsWith(nativeClientSyncSourceIdPrefix)) { - return; - } - - const syncNativeClientStateToJs = async () => { - try { - await synchronizeNativeClientToJs(nativeClientEvent); - } catch (error) { - console.error(`[ClerkProvider] Failed to sync native client state:`, error); - } - }; - - void syncNativeClientStateToJs(); - }, [enabled, nativeClientEvent]); -} diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts deleted file mode 100644 index aa7de3b47fa..00000000000 --- a/packages/expo/src/provider/nativeClientSyncCoordinator.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { NativeClientEvent } from '../hooks/useNativeClientEvents'; - -type NativeToJsSyncHandler = (nativeClientEvent?: NativeClientEvent | null) => Promise; - -type NativeToJsSyncRegistration = { - handler: NativeToJsSyncHandler; - pendingEventSyncs: Set>; - pendingExplicitSync: Promise | null; - pendingExplicitSyncEvent: NativeClientEvent | null; - explicitSyncRequestGeneration: number; - explicitSyncCompletedGeneration: number; -}; - -const pendingJsToNativeSyncs = new Map, number>(); -const defaultPendingJsToNativeSyncTimeoutMs = 5_000; -let jsToNativeSyncGeneration = 0; -let latestSettledJsToNativeSyncGeneration = 0; -let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null; -let nativeToJsSyncRegistration: NativeToJsSyncRegistration | null = null; -let jsToNativeSyncEpoch = 0; - -function removePendingSync(pendingSyncs: Set>, sync: Promise): void { - pendingSyncs.delete(sync); -} - -function mergeNativeClientEvents(current: NativeClientEvent | null, next: NativeClientEvent): NativeClientEvent { - if (!current) { - return next; - } - - return { - ...next, - changed: { - client: current.changed.client || next.changed.client, - deviceToken: current.changed.deviceToken || next.changed.deviceToken, - }, - }; -} - -function createPendingJsToNativeSyncTimeoutError(): Error & { code: 'environment_unavailable' } { - return Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { - code: 'environment_unavailable' as const, - }); -} - -export function trackPendingJsToNativeSync( - sync: Promise, - timeoutMs = defaultPendingJsToNativeSyncTimeoutMs, -): () => void { - const epoch = jsToNativeSyncEpoch; - const generation = ++jsToNativeSyncGeneration; - let isInvalidated = false; - const trackedSync = sync.then( - () => { - if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { - latestSettledJsToNativeSyncGeneration = generation; - latestJsToNativeSyncFailure = null; - } - }, - error => { - if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { - latestSettledJsToNativeSyncGeneration = generation; - latestJsToNativeSyncFailure = { error, generation }; - } - }, - ); - - pendingJsToNativeSyncs.set(trackedSync, timeoutMs); - void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync)); - - return () => { - isInvalidated = true; - pendingJsToNativeSyncs.delete(trackedSync); - }; -} - -export async function waitForPendingJsToNativeSync(): Promise { - const waitStartedAt = Date.now(); - let deadline = waitStartedAt + defaultPendingJsToNativeSyncTimeoutMs; - while (pendingJsToNativeSyncs.size > 0) { - deadline = Math.max(deadline, waitStartedAt + Math.max(...pendingJsToNativeSyncs.values())); - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - throw createPendingJsToNativeSyncTimeoutError(); - } - - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => { - reject(createPendingJsToNativeSyncTimeoutError()); - }, remainingMs); - }); - - try { - await Promise.race([Promise.all(pendingJsToNativeSyncs.keys()), timeout]); - } finally { - clearTimeout(timeoutId); - } - } - - if (latestJsToNativeSyncFailure) { - throw latestJsToNativeSyncFailure.error; - } -} - -export function __internal_resetNativeClientSyncCoordinator(): void { - jsToNativeSyncEpoch += 1; - pendingJsToNativeSyncs.clear(); - jsToNativeSyncGeneration = 0; - latestSettledJsToNativeSyncGeneration = 0; - latestJsToNativeSyncFailure = null; - nativeToJsSyncRegistration = null; -} - -export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): () => void { - const registration = { - handler, - pendingEventSyncs: new Set>(), - pendingExplicitSync: null, - pendingExplicitSyncEvent: null, - explicitSyncRequestGeneration: 0, - explicitSyncCompletedGeneration: 0, - }; - nativeToJsSyncRegistration = registration; - - return () => { - if (nativeToJsSyncRegistration === registration) { - nativeToJsSyncRegistration = null; - } - }; -} - -export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEvent | null): Promise { - const registration = nativeToJsSyncRegistration; - if (!registration) { - return Promise.reject(new Error('Native Clerk client synchronization is not available.')); - } - - if (nativeClientEvent) { - if (registration.pendingExplicitSync) { - registration.pendingExplicitSyncEvent = mergeNativeClientEvents( - registration.pendingExplicitSyncEvent, - nativeClientEvent, - ); - registration.explicitSyncRequestGeneration += 1; - return registration.pendingExplicitSync; - } - - const pendingEvents = [...registration.pendingEventSyncs]; - const sync = Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))).then(() => - registration.handler(nativeClientEvent), - ); - registration.pendingEventSyncs.add(sync); - void sync.then( - () => removePendingSync(registration.pendingEventSyncs, sync), - () => removePendingSync(registration.pendingEventSyncs, sync), - ); - return sync; - } - - registration.explicitSyncRequestGeneration += 1; - if (registration.pendingExplicitSync) { - return registration.pendingExplicitSync; - } - - const pendingEvents = [...registration.pendingEventSyncs]; - const sync = (async () => { - await Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))); - - let firstError: unknown; - let didFail = false; - while (registration.explicitSyncCompletedGeneration < registration.explicitSyncRequestGeneration) { - const generation = registration.explicitSyncRequestGeneration; - const pendingEvent = registration.pendingExplicitSyncEvent; - registration.pendingExplicitSyncEvent = null; - try { - await (pendingEvent ? registration.handler(pendingEvent) : registration.handler()); - } catch (error) { - if (!didFail) { - firstError = error; - didFail = true; - } - } - registration.explicitSyncCompletedGeneration = generation; - } - - registration.pendingExplicitSync = null; - - if (didFail) { - throw firstError; - } - })(); - registration.pendingExplicitSync = sync; - return sync; -} diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 4f08c16bedd..3c45a2d4059 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -453,6 +453,33 @@ describe('createClerkInstance', () => { expect(latestTokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'fresh-token'); }); + test('writes a rotated client token against the token each request was sent with', async () => { + const tokenCache = { + getToken: vi.fn().mockResolvedValueOnce('token-a').mockResolvedValueOnce('token-b'), + saveToken: vi.fn(() => Promise.resolve()), + saveClientToken: vi.fn(() => Promise.resolve()), + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ publishableKey: 'pk_test_123', tokenCache }) as unknown as MockClerk; + + const beforeRequest = clerk.__internal_onBeforeRequest.mock.calls[0][0]; + const afterResponse = clerk.__internal_onAfterResponse.mock.calls[0][0]; + const firstRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/client') }; + const secondRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/me') }; + await beforeRequest(firstRequest); + await beforeRequest(secondRequest); + await afterResponse(secondRequest, { headers: new Headers({ authorization: 'token-c' }), payload: null }); + await afterResponse(firstRequest, { headers: new Headers({ authorization: 'token-d' }), payload: null }); + + expect(tokenCache.saveClientToken.mock.calls).toEqual([ + ['token-c', 'token-b'], + ['token-d', 'token-a'], + ]); + expect(tokenCache.saveToken).not.toHaveBeenCalled(); + }); + describe('initial resource recovery', () => { const setupUnavailableResources = async () => { const createClerkInstance = await loadCreateClerkInstance(); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 56230604852..b82c8e64278 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -25,6 +25,7 @@ import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { errorThrower } from '../../errorThrower'; import { assertValidProxyUrl } from '../../utils/errors'; import { isNative } from '../../utils/runtime'; +import type { ClientTokenCache } from '../nativeClientSync'; import type { BuildClerkOptions } from './types'; /** @@ -124,7 +125,13 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { } const getToken = (key: string) => __internal_tokenCache.getToken(key); - const saveToken = (key: string, token: string) => __internal_tokenCache.saveToken(key, token); + const saveClientToken = (token: string, requestToken: string | null) => { + const tokenCache = __internal_tokenCache as TokenCache & Partial; + return tokenCache.saveClientToken + ? tokenCache.saveClientToken(token, requestToken) + : tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); + }; + const requestTokens = new WeakMap(); __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; @@ -291,6 +298,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { requestInit.url?.searchParams.append('_is_native', '1'); const jwt = await getToken(CLERK_CLIENT_JWT_KEY); + requestTokens.set(requestInit, jwt ?? null); (requestInit.headers as Headers).set('authorization', jwt || ''); // Instructs the backend that the request is from a mobile device. @@ -303,10 +311,10 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { let nativeApiErrorShown = false; // @ts-expect-error - This is an internal API - __internal_clerk.__internal_onAfterResponse(async (_: FapiRequestInit, response: FapiResponse) => { + __internal_clerk.__internal_onAfterResponse(async (requestInit: FapiRequestInit, response: FapiResponse) => { const authHeader = response.headers.get('authorization'); if (authHeader) { - await saveToken(CLERK_CLIENT_JWT_KEY, authHeader); + await saveClientToken(authHeader, requestTokens.get(requestInit) ?? null); } if (__DEV__ && !nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') { diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index d5c59ceb1c9..6870d597df8 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,19 +1,15 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from './NativeClerkModule.types'; -interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule { - // Exposed by Expo Modules EventEmitter for internal native client change events. +interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule { + // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; } // Optional so it resolves to null in Expo Go instead of throwing at import time. diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 7e71393a9be..457fa431210 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,19 +1,15 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from './NativeClerkModule.types'; -export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule { - // Exposed by Expo Modules EventEmitter for internal native client change events. +export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule { + // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; } export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index fa3aa35b6e2..fb99d305e33 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -11,6 +11,17 @@ export type NativeAuthFlowModule = { getAuthFlowState(): Promise; }; +/** + * Native storage owns the device token. JS reads it before each FAPI request, writes rotated tokens + * back with compare-and-set, and refetches its own client on `clerkNativeClientInvalidated`. + */ +export type NativeClientSyncModule = { + configureNative(publishableKey: string, seedDeviceToken: string | null): Promise; + getDeviceToken(): Promise; + setDeviceToken(token: string | null, expected: string | null): Promise; + refreshClient(): Promise; +}; + export type NativeBiometricCredential = { id: string; object: 'trusted_device'; diff --git a/packages/expo/src/specs/__tests__/androidSpecs.test.ts b/packages/expo/src/specs/__tests__/androidSpecs.test.ts index bbebc8f11c9..6ec0234bdbf 100644 --- a/packages/expo/src/specs/__tests__/androidSpecs.test.ts +++ b/packages/expo/src/specs/__tests__/androidSpecs.test.ts @@ -39,7 +39,7 @@ describe('android native module specs', () => { }); test('resolve the module registered under the expected name (development build)', async () => { - const clerkExpoModule = { configure: vi.fn() }; + const clerkExpoModule = { configureNative: vi.fn() }; const googleSignInModule = { signIn: vi.fn() }; mocks.available = { ClerkExpo: clerkExpoModule, ClerkGoogleSignIn: googleSignInModule }; diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts index 70d95547ba5..cfe3882cdf7 100644 --- a/packages/expo/src/utils/__tests__/native-module.test.ts +++ b/packages/expo/src/utils/__tests__/native-module.test.ts @@ -10,9 +10,10 @@ const makeNativeModule = ({ includeEventMethods = true } = {}) => ({ addListener: vi.fn(), } : {}), - configure: vi.fn(), - getClientToken: vi.fn(), - syncClientStateFromJs: vi.fn(), + configureNative: vi.fn(), + getDeviceToken: vi.fn(), + setDeviceToken: vi.fn(), + refreshClient: vi.fn(), }); vi.mock('react-native', () => ({ @@ -54,7 +55,7 @@ describe('native module loader', () => { test('returns null when no native module satisfies the bootstrap contract', async () => { mocks.nativeModule = { - configure: vi.fn(), + configureNative: vi.fn(), }; const { ClerkExpoModule } = await importNativeModule(); diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 11751ec4114..e89da01bc8e 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,21 +1,18 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; - getClientToken(): Promise; - syncClientStateFromJs( - deviceToken: string | null, - sourceId: string | null, - didChangeClient: boolean, - didChangeDeviceToken: boolean, - ): Promise; -} & Partial; +} & NativeClientSyncModule & + Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') { @@ -24,9 +21,10 @@ function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { const maybeModule = module as Record; return ( - typeof maybeModule.configure === 'function' && - typeof maybeModule.getClientToken === 'function' && - typeof maybeModule.syncClientStateFromJs === 'function' + typeof maybeModule.configureNative === 'function' && + typeof maybeModule.getDeviceToken === 'function' && + typeof maybeModule.setDeviceToken === 'function' && + typeof maybeModule.refreshClient === 'function' ); } diff --git a/packages/shared/src/types/authConfig.ts b/packages/shared/src/types/authConfig.ts index e0919100434..a3156a02fc8 100644 --- a/packages/shared/src/types/authConfig.ts +++ b/packages/shared/src/types/authConfig.ts @@ -22,5 +22,20 @@ export interface AuthConfigResource extends ClerkResource { */ preferredChannels: Record | null; sessionMinter: boolean; + /** + * Native application settings, such as trusted device (biometric) sign-in. + * @experimental + */ + nativeSettings: NativeSettingsResource | null; __internal_toSnapshot: () => AuthConfigJSONSnapshot; } + +/** + * @experimental + */ +export interface NativeSettingsResource { + apiEnabled: boolean; + trustedDeviceSignInEnabled: boolean; + trustedDeviceEnrollmentPromptAfterSignInEnabled: boolean; + trustedDeviceEnrollmentPromptAfterSignUpEnabled: boolean; +} diff --git a/packages/shared/src/types/biometricCredential.ts b/packages/shared/src/types/biometricCredential.ts new file mode 100644 index 00000000000..25c7db319a4 --- /dev/null +++ b/packages/shared/src/types/biometricCredential.ts @@ -0,0 +1,98 @@ +import type { ClerkResource } from './resource'; +import type { BiometricCredentialJSONSnapshot } from './snapshots'; + +/** + * The signing algorithm used by trusted device (biometric) credentials. + * @experimental + */ +export type TrustedDeviceAlgorithm = 'ES256'; + +/** + * The native platform a biometric credential was enrolled on. + * @experimental + */ +export type BiometricCredentialPlatform = 'ios' | 'android'; + +/** + * @experimental + */ +export type BiometricCredentialStatus = 'active' | 'revoked'; + +/** + * A P-256 public key in JWK format. + * @experimental + */ +export type BiometricCredentialPublicKeyJWK = { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; +}; + +/** + * A challenge issued by Clerk that must be signed with the device's private key. + * @experimental + */ +export interface TrustedDeviceChallengeResource { + challenge: string; + challengeId: string; + /** + * The ID of the trusted device the challenge was issued for. Only present on sign-in challenges. + */ + trustedDeviceId: string | null; + /** + * The exact string that must be signed by the device's private key. + */ + clientData: string; + expiresAt: Date | null; + algorithm: TrustedDeviceAlgorithm; +} + +/** + * A biometric credential (trusted device) enrolled by the current user on a native device. + * @experimental This is an experimental API for native apps and is subject to change. + */ +export interface BiometricCredentialResource extends ClerkResource { + id: string; + platform: BiometricCredentialPlatform; + appIdentifier: string; + name: string | null; + algorithm: TrustedDeviceAlgorithm; + status: BiometricCredentialStatus; + lastUsedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; + updatedAt: Date; + __internal_toSnapshot: () => BiometricCredentialJSONSnapshot; +} + +/** + * @experimental + */ +export type PrepareBiometricCredentialParams = { + platform: BiometricCredentialPlatform; + /** + * The iOS bundle identifier or Android package name of the app. + */ + appIdentifier: string; + name?: string; + algorithm: TrustedDeviceAlgorithm; + /** + * The device public key as a JWK, or its JSON string. + */ + publicKeyJwk: BiometricCredentialPublicKeyJWK | string; +}; + +/** + * @experimental + */ +export type AttemptBiometricCredentialParams = PrepareBiometricCredentialParams & { + /** + * The `clientData` string from the challenge returned by the prepare step. + */ + clientData: string; + /** + * The base64url-encoded raw (r||s) signature of `clientData`. + */ + signature: string; +}; diff --git a/packages/shared/src/types/factors.ts b/packages/shared/src/types/factors.ts index 765f76fbcaf..3a3ecff1f1d 100644 --- a/packages/shared/src/types/factors.ts +++ b/packages/shared/src/types/factors.ts @@ -1,3 +1,4 @@ +import type { TrustedDeviceAlgorithm } from './biometricCredential'; import type { PublicKeyCredentialWithAuthenticatorAssertionResponse } from './passkey'; import type { PhoneCodeChannel } from './phoneCodeChannel'; import type { @@ -12,6 +13,7 @@ import type { ResetPasswordEmailCodeStrategy, ResetPasswordPhoneCodeStrategy, TOTPStrategy, + TrustedDeviceStrategy, Web3Strategy, } from './strategies'; @@ -333,6 +335,32 @@ export type PasskeyAttempt = { publicKeyCredential: PublicKeyCredentialWithAuthenticatorAssertionResponse; }; +/** + * @experimental + */ +export type TrustedDeviceAttempt = { + /** + * The strategy type. + */ + strategy: TrustedDeviceStrategy; + /** + * The ID of the trusted device (biometric credential) used to sign in. + */ + trustedDeviceId: string; + /** + * The `clientData` string from the sign-in's `firstFactorVerification.trustedDeviceChallenge`. + */ + clientData: string; + /** + * The base64url-encoded raw (r||s) signature of `clientData`. + */ + signature: string; + /** + * The signing algorithm. + */ + algorithm: TrustedDeviceAlgorithm; +}; + /** @generateWithEmptyComment */ export type Web3Attempt = { /** diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c8985107b0e..164dff618f8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -5,6 +5,7 @@ export type * from './authConfig'; export type * from './authObject'; export type * from './backupCode'; export type * from './billing'; +export type * from './biometricCredential'; export type * from './clerk'; export type * from './client'; export type * from './commerceSettings'; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 2128d95f8e2..9c0fd4e5cba 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -12,6 +12,11 @@ import type { BillingSubscriptionPlanPeriod, BillingSubscriptionStatus, } from './billing'; +import type { + BiometricCredentialPlatform, + BiometricCredentialStatus, + TrustedDeviceAlgorithm, +} from './biometricCredential'; import type { CommerceSettingsJSON } from './commerceSettings'; import type { DisplayConfigJSON } from './displayConfig'; import type { EnterpriseProtocol, EnterpriseProvider } from './enterpriseAccount'; @@ -360,6 +365,54 @@ export interface AuthConfigJSON extends ClerkResourceJSON { reverification: boolean; preferred_channels?: Record; session_minter?: boolean; + /** + * @experimental + */ + native_settings?: NativeSettingsJSON | null; +} + +/** + * @experimental + */ +export interface NativeSettingsJSON { + object: 'native_settings'; + api_enabled: boolean; + trusted_device_sign_in_enabled: boolean; + trusted_device_enrollment_prompt_after_sign_in_enabled: boolean; + trusted_device_enrollment_prompt_after_sign_up_enabled: boolean; +} + +/** + * @experimental + */ +export interface TrustedDeviceChallengeJSON { + object: 'trusted_device_challenge'; + challenge: string; + challenge_id: string; + trusted_device_id?: string; + client_data: string; + /** + * Unix timestamp in seconds. + */ + expires_at: number; + algorithm: TrustedDeviceAlgorithm; +} + +/** + * @experimental + */ +export interface BiometricCredentialJSON extends ClerkResourceJSON { + object: 'trusted_device'; + id: string; + platform: BiometricCredentialPlatform; + app_identifier: string; + name: string | null; + algorithm: TrustedDeviceAlgorithm; + status: BiometricCredentialStatus; + created_at: number; + updated_at: number; + last_used_at: number | null; + revoked_at: number | null; } export interface VerificationJSON extends ClerkResourceJSON { @@ -373,6 +426,10 @@ export interface VerificationJSON extends ClerkResourceJSON { expire_at: number; channel?: PhoneCodeChannel; error: ClerkAPIErrorJSON; + /** + * @experimental + */ + trusted_device_challenge?: TrustedDeviceChallengeJSON | null; } export interface SignUpVerificationsJSON { diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 844376484f2..922686321bc 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -28,6 +28,7 @@ import type { ResetPasswordPhoneCodeFactorConfig, TOTPAttempt, TOTPFactor, + TrustedDeviceAttempt, Web3Attempt, Web3SignatureConfig, Web3SignatureFactor, @@ -53,6 +54,7 @@ import type { ResetPasswordPhoneCodeStrategy, TicketStrategy, TOTPStrategy, + TrustedDeviceStrategy, Web3Strategy, } from './strategies'; import type { StartEmailLinkFlowParams } from './verification'; @@ -113,7 +115,8 @@ export type AttemptFirstFactorParams = | PasswordAttempt | Web3Attempt | ResetPasswordPhoneCodeAttempt - | ResetPasswordEmailCodeAttempt; + | ResetPasswordEmailCodeAttempt + | TrustedDeviceAttempt; export type PrepareSecondFactorParams = PhoneCodeSecondFactorConfig | EmailCodeSecondFactorConfig | EmailLinkConfig; @@ -146,6 +149,13 @@ export type SignInCreateParams = ( identifier: string; } | { strategy: PasskeyStrategy } + | { + /** + * @experimental + */ + strategy: TrustedDeviceStrategy; + trustedDeviceId: string; + } | { strategy: | PhoneCodeStrategy @@ -201,7 +211,8 @@ export type SignInStrategy = | TOTPStrategy | BackupCodeStrategy | OAuthStrategy - | EnterpriseSSOStrategy; + | EnterpriseSSOStrategy + | TrustedDeviceStrategy; export interface SignInAuthenticateWithSolanaParams { walletName: string; diff --git a/packages/shared/src/types/snapshots.ts b/packages/shared/src/types/snapshots.ts index a1d239c329f..fafb7c91264 100644 --- a/packages/shared/src/types/snapshots.ts +++ b/packages/shared/src/types/snapshots.ts @@ -5,6 +5,7 @@ import type { CommerceSettingsJSON } from './commerceSettings'; import type { DisplayConfigJSON } from './displayConfig'; import type { AuthConfigJSON, + BiometricCredentialJSON, ClientJSON, ClientTrustState, EmailAddressJSON, @@ -146,6 +147,8 @@ export type OrganizationSettingsJSONSnapshot = OrganizationSettingsJSON; export type OrganizationCreationDefaultsJSONSnapshot = OrganizationCreationDefaultsJSON; +export type BiometricCredentialJSONSnapshot = BiometricCredentialJSON; + export type PasskeyJSONSnapshot = Override; export type PhoneNumberJSONSnapshot = Override< diff --git a/packages/shared/src/types/strategies.ts b/packages/shared/src/types/strategies.ts index 2055c1a5056..b47b321a84b 100644 --- a/packages/shared/src/types/strategies.ts +++ b/packages/shared/src/types/strategies.ts @@ -7,6 +7,11 @@ export type GoogleOneTapStrategy = 'google_one_tap'; export type AppleIdTokenStrategy = 'oauth_token_apple'; /** @inline */ export type PasskeyStrategy = 'passkey'; +/** + * @experimental + * @inline + */ +export type TrustedDeviceStrategy = 'trusted_device'; /** @inline */ export type PasswordStrategy = 'password'; /** @inline */ diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 3bd639571d3..1d935a5ab98 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -1,5 +1,11 @@ import type { BackupCodeResource } from './backupCode'; import type { BillingPayerMethods } from './billing'; +import type { + AttemptBiometricCredentialParams, + BiometricCredentialResource, + PrepareBiometricCredentialParams, + TrustedDeviceChallengeResource, +} from './biometricCredential'; import type { DeletedObjectResource } from './deletedObject'; import type { EmailAddressResource } from './emailAddress'; import type { EnterpriseAccountResource } from './enterpriseAccount'; @@ -334,6 +340,30 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { * @returns A [`BackupCodeResource`](https://clerk.com/docs/reference/types/backup-code-resource) object. */ createBackupCode: () => Promise; + /** + * Lists the biometric credentials (trusted devices) enrolled by the user. + * @experimental This is an experimental API for native apps and is subject to change. + */ + __experimental_getBiometricCredentials: () => Promise; + /** + * Starts enrolling a biometric credential and returns the challenge the device must sign. Requires a recently verified session. + * @experimental This is an experimental API for native apps and is subject to change. + */ + __experimental_prepareBiometricCredential: ( + params: PrepareBiometricCredentialParams, + ) => Promise; + /** + * Completes enrolling a biometric credential with the signed challenge. + * @experimental This is an experimental API for native apps and is subject to change. + */ + __experimental_attemptBiometricCredential: ( + params: AttemptBiometricCredentialParams, + ) => Promise; + /** + * Revokes one of the user's biometric credentials. Requires a recently verified session. + * @experimental This is an experimental API for native apps and is subject to change. + */ + __experimental_revokeBiometricCredential: (biometricCredentialId: string) => Promise; get verifiedExternalAccounts(): ExternalAccountResource[]; diff --git a/packages/shared/src/types/verification.ts b/packages/shared/src/types/verification.ts index 4f1d3fe0863..8b4087099e4 100644 --- a/packages/shared/src/types/verification.ts +++ b/packages/shared/src/types/verification.ts @@ -1,3 +1,4 @@ +import type { TrustedDeviceChallengeResource } from './biometricCredential'; import type { ClerkAPIError } from './errors'; import type { PublicKeyCredentialCreationOptionsWithoutExtensions } from './passkey'; import type { PhoneCodeChannel } from './phoneCodeChannel'; @@ -16,6 +17,11 @@ export interface VerificationResource extends ClerkResource { verifiedAtClient: string | null; verifiedFromTheSameClient: () => boolean; channel?: PhoneCodeChannel; + /** + * The challenge to sign when the verification uses the `trusted_device` strategy. + * @experimental + */ + trustedDeviceChallenge?: TrustedDeviceChallengeResource | null; __internal_toSnapshot: () => VerificationJSONSnapshot; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2b32a7f6a..d37b3a454fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,6 +662,9 @@ importers: '@clerk/expo-google-signin': specifier: workspace:* version: link:../expo-google-signin + '@clerk/expo-native-components': + specifier: workspace:* + version: link:../expo-native-components '@clerk/expo-passkeys': specifier: workspace:* version: link:../expo-passkeys @@ -708,6 +711,28 @@ importers: specifier: ~54.0.36 version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) + packages/expo-native-components: + dependencies: + '@clerk/react': + specifier: workspace:^ + version: link:../react + expo: + specifier: catalog:peer-expo + version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) + react: + specifier: 18.3.1 + version: 18.3.1 + tslib: + specifier: catalog:repo + version: 2.8.1 + devDependencies: + '@expo/config-plugins': + specifier: ^54.0.4 + version: 54.0.5 + react-native: + specifier: ^0.86.0 + version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10) + packages/expo-passkeys: dependencies: '@clerk/shared':