Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/experimental-trusted-device-resources.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions .changeset/expo-native-components-moved.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/expo-native-single-token-sync-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .changeset/expo-native-sync-behavior-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/introduce-expo-native.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .changeset/remove-legacy-expo-native-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/simplify-expo-native-client-sync.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 27 additions & 26 deletions .claude/skills/clerk-monorepo/references/package-map.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/api-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/expo-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
- 'packages/expo-native-components/**'
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -85,6 +86,7 @@ jobs:
packages/clerk-js \
packages/expo \
packages/expo-google-signin \
packages/expo-native-components \
packages/react \
packages/shared \
"$FIXTURE_DIR" |
Expand Down Expand Up @@ -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'
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/expo-native/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/resources/AuthConfig.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +16,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
singleSessionMode: boolean = false;
preferredChannels: Record<string, PhoneCodeChannel> | null = null;
sessionMinter: boolean = false;
nativeSettings: NativeSettingsResource | null = null;

public constructor(data: Partial<AuthConfigJSON> | null = null) {
super();
Expand All @@ -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;
}

Expand All @@ -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,
};
}
117 changes: 117 additions & 0 deletions packages/clerk-js/src/core/resources/BiometricCredential.ts
Original file line number Diff line number Diff line change
@@ -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<BiometricCredentialResource[]> {
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<TrustedDeviceChallengeResource> {
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<BiometricCredentialResource> {
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<BiometricCredentialResource> {
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(),
};
}
}
35 changes: 35 additions & 0 deletions packages/clerk-js/src/core/resources/TrustedDeviceChallenge.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading