Skip to content

Commit f6937c8

Browse files
mikepitreclaudewobsorianoswolfand
authored
feat(expo): move biometric credentials to JS with @clerk/expo-biometrics (#9989)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Robert Soriano <sorianorobertc@gmail.com> Co-authored-by: sam <sam@clerk.dev>
1 parent 4eac21f commit f6937c8

81 files changed

Lines changed: 7665 additions & 874 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
---
5+
6+
Add experimental APIs for biometric sign-in in native apps. This includes the `trusted_device` strategy for sign-in and session reverification, `nativeSettings` on the auth config, and `__experimental_` methods on `User` for managing biometric credentials. These may change in minor releases.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@clerk/expo': minor
3+
'@clerk/expo-biometrics': minor
4+
---
5+
6+
Introduce `@clerk/expo-biometrics`, a new package that `useBiometricCredentials()` now requires. Install it and rebuild your app:
7+
8+
```sh
9+
npx expo install @clerk/expo-biometrics
10+
```
11+
12+
If it's missing, the hook throws an error with these same steps.
13+
14+
Import the hook from `@clerk/expo/biometrics`:
15+
16+
```ts
17+
import { useBiometricCredentials } from '@clerk/expo/biometrics';
18+
```
19+
20+
Importing it from `@clerk/expo` still works, but it logs a deprecation warning and will be removed in the next major version.

‎.github/workflows/expo-native-build.yml‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- 'integration/templates/expo-native/**'
1111
- 'integration/tests/expo-native/**'
1212
- 'packages/expo/**'
13+
- 'packages/expo-biometrics/**'
1314
- 'packages/expo-google-signin/**'
1415
workflow_dispatch:
1516

@@ -84,6 +85,7 @@ jobs:
8485
turbo.json \
8586
packages/clerk-js \
8687
packages/expo \
88+
packages/expo-biometrics \
8789
packages/expo-google-signin \
8890
packages/react \
8991
packages/shared \
@@ -121,10 +123,11 @@ jobs:
121123
- name: Build and pack Clerk packages
122124
if: steps.native-build-cache.outputs.cache-hit != 'true'
123125
run: |
124-
pnpm --filter @clerk/expo... build
126+
pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build
125127
mkdir -p "$SDK_PACK_DIR"
126128
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
127129
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
130+
pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"
128131
129132
- name: Install fixture dependencies
130133
if: steps.native-build-cache.outputs.cache-hit != 'true'
@@ -138,7 +141,8 @@ jobs:
138141
# [0-9] keeps this glob off the clerk-expo-google-signin tarball.
139142
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
140143
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
141-
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w
144+
BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
145+
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$BIOMETRICS_TARBALL" -w
142146
# expo-dev-client makes even release builds boot into the dev
143147
# launcher (unreachable Metro in CI), which stalls every Maestro
144148
# flow on a blank screen. Skip it on e2e jobs only.
@@ -209,6 +213,15 @@ jobs:
209213
working-directory: ${{ env.FIXTURE_DIR }}
210214
run: pnpm build:android
211215

216+
# The module's Robolectric tests need an Expo host project for expo-modules-core, so they run in the fixture.
217+
# The packed module leaves out its tests, so they are copied in from the repo.
218+
- name: Test expo-biometrics Android module
219+
if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true'
220+
working-directory: ${{ env.FIXTURE_DIR }}/android
221+
run: |
222+
cp -R "$GITHUB_WORKSPACE/packages/expo-biometrics/android/src/test" "$(readlink -f ../node_modules/@clerk/expo-biometrics)/android/src/"
223+
./gradlew :clerk-expo-biometrics:testDebugUnitTest
224+
212225
- name: Prebuild iOS fixture
213226
if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true'
214227
working-directory: ${{ env.FIXTURE_DIR }}

‎integration/templates/expo-native/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tokenCache } from '@clerk/expo/token-cache';
44
import { useState } from 'react';
55
import { Button, Modal, StyleSheet, Text, View } from 'react-native';
66

7+
import { BiometricAvailabilityButton } from './components/BiometricAvailabilityButton';
78
import { GoogleSignInButton } from './components/GoogleSignInButton';
89

910
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
@@ -64,6 +65,7 @@ function NativeBuildFixture() {
6465
onPress={() => setIsAuthOpen(true)}
6566
/>
6667
{!isSignedIn && <GoogleSignInButton />}
68+
{!isSignedIn && <BiometricAvailabilityButton />}
6769
{isSignedIn && (
6870
<Button
6971
testID='open-embedded-profile-button'
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useBiometricCredentials } from '@clerk/expo/biometrics';
2+
import { useState } from 'react';
3+
import { Button, Text } from 'react-native';
4+
5+
export function BiometricAvailabilityButton() {
6+
const { getAvailability } = useBiometricCredentials();
7+
const [result, setResult] = useState<string | null>(null);
8+
9+
return (
10+
<>
11+
<Button
12+
testID='biometric-availability-button'
13+
title='Check biometric availability'
14+
onPress={() => {
15+
void getAvailability().then(
16+
availability => setResult(`biometric availability: ${availability.unavailableReason ?? 'available'}`),
17+
(error: unknown) => {
18+
const message = error instanceof Error ? error.message : String(error);
19+
setResult(`biometric availability failed: ${message.replace(/\s+/g, ' ')}`);
20+
},
21+
);
22+
}}
23+
/>
24+
{result && <Text testID='biometric-availability-result'>{result}</Text>}
25+
</>
26+
);
27+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
appId: com.clerk.exponativebuildfixture
2+
name: useBiometricCredentials reaches the expo-biometrics native module
3+
---
4+
- runFlow: subflows/open-app.yaml
5+
- tapOn:
6+
id: 'biometric-availability-button'
7+
- extendedWaitUntil:
8+
visible: 'biometric availability: .*'
9+
timeout: 15000

‎packages/clerk-js/bundlewatch.config.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "554KB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
5-
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "556KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "83KB" },
5+
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
77
{ "path": "./dist/clerk.native.js", "maxSize": "82KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },

‎packages/clerk-js/src/core/resources/AuthConfig.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { AuthConfigJSON, AuthConfigJSONSnapshot, AuthConfigResource, PhoneCodeChannel } from '@clerk/shared/types';
1+
import type {
2+
AuthConfigJSON,
3+
AuthConfigJSONSnapshot,
4+
AuthConfigResource,
5+
NativeSettingsJSON,
6+
NativeSettingsResource,
7+
PhoneCodeChannel,
8+
} from '@clerk/shared/types';
29

310
import { unixEpochToDate } from '../../utils/date';
411
import { BaseResource } from './internal';
@@ -9,6 +16,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
916
singleSessionMode: boolean = false;
1017
preferredChannels: Record<string, PhoneCodeChannel> | null = null;
1118
sessionMinter: boolean = false;
19+
nativeSettings: NativeSettingsResource | null = null;
1220

1321
public constructor(data: Partial<AuthConfigJSON> | null = null) {
1422
super();
@@ -25,6 +33,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
2533
this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode);
2634
this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels);
2735
this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter);
36+
this.nativeSettings = this.withDefault(nativeSettingsFromJSON(data.native_settings), this.nativeSettings);
2837
return this;
2938
}
3039

@@ -36,6 +45,32 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
3645
reverification: this.reverification,
3746
single_session_mode: this.singleSessionMode,
3847
session_minter: this.sessionMinter,
48+
native_settings: nativeSettingsToJSON(this.nativeSettings),
3949
};
4050
}
4151
}
52+
53+
function nativeSettingsFromJSON(data: NativeSettingsJSON | null | undefined): NativeSettingsResource | null {
54+
if (!data) {
55+
return null;
56+
}
57+
return {
58+
apiEnabled: data.api_enabled,
59+
trustedDeviceSignInEnabled: data.trusted_device_sign_in_enabled,
60+
trustedDeviceEnrollmentPromptAfterSignInEnabled: data.trusted_device_enrollment_prompt_after_sign_in_enabled,
61+
trustedDeviceEnrollmentPromptAfterSignUpEnabled: data.trusted_device_enrollment_prompt_after_sign_up_enabled,
62+
};
63+
}
64+
65+
function nativeSettingsToJSON(settings: NativeSettingsResource | null): NativeSettingsJSON | null {
66+
if (!settings) {
67+
return null;
68+
}
69+
return {
70+
object: 'native_settings',
71+
api_enabled: settings.apiEnabled,
72+
trusted_device_sign_in_enabled: settings.trustedDeviceSignInEnabled,
73+
trusted_device_enrollment_prompt_after_sign_in_enabled: settings.trustedDeviceEnrollmentPromptAfterSignInEnabled,
74+
trusted_device_enrollment_prompt_after_sign_up_enabled: settings.trustedDeviceEnrollmentPromptAfterSignUpEnabled,
75+
};
76+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type {
2+
AttemptBiometricCredentialParams,
3+
BiometricCredentialJSON,
4+
BiometricCredentialJSONSnapshot,
5+
BiometricCredentialPlatform,
6+
BiometricCredentialResource,
7+
BiometricCredentialStatus,
8+
PrepareBiometricCredentialParams,
9+
TrustedDeviceAlgorithm,
10+
TrustedDeviceChallengeJSON,
11+
TrustedDeviceChallengeResource,
12+
} from '@clerk/shared/types';
13+
14+
import { unixEpochToDate } from '../../utils/date';
15+
import { BaseResource } from './internal';
16+
import { trustedDeviceChallengeFromJSON } from './TrustedDeviceChallenge';
17+
18+
const PATH_ROOT = '/me/biometric_credentials';
19+
20+
function toEnrollmentBody(params: PrepareBiometricCredentialParams | AttemptBiometricCredentialParams) {
21+
const { publicKeyJwk, ...rest } = params;
22+
return {
23+
...rest,
24+
publicKeyJwk: typeof publicKeyJwk === 'string' ? publicKeyJwk : JSON.stringify(publicKeyJwk),
25+
};
26+
}
27+
28+
export class BiometricCredential extends BaseResource implements BiometricCredentialResource {
29+
id!: string;
30+
pathRoot = PATH_ROOT;
31+
platform!: BiometricCredentialPlatform;
32+
appIdentifier!: string;
33+
name: string | null = null;
34+
algorithm!: TrustedDeviceAlgorithm;
35+
status!: BiometricCredentialStatus;
36+
lastUsedAt: Date | null = null;
37+
revokedAt: Date | null = null;
38+
createdAt!: Date;
39+
updatedAt!: Date;
40+
41+
public constructor(data: BiometricCredentialJSON | BiometricCredentialJSONSnapshot) {
42+
super();
43+
this.fromJSON(data);
44+
}
45+
46+
static async list(): Promise<BiometricCredentialResource[]> {
47+
const json = (await BaseResource._fetch({ path: PATH_ROOT, method: 'GET' }))
48+
?.response as unknown as BiometricCredentialJSON[];
49+
return (json || []).map(credential => new BiometricCredential(credential));
50+
}
51+
52+
static async prepare(params: PrepareBiometricCredentialParams): Promise<TrustedDeviceChallengeResource> {
53+
const json = (
54+
await BaseResource._fetch({
55+
path: `${PATH_ROOT}/prepare`,
56+
method: 'POST',
57+
body: toEnrollmentBody(params) as any,
58+
})
59+
)?.response as unknown as TrustedDeviceChallengeJSON;
60+
return trustedDeviceChallengeFromJSON(json) as TrustedDeviceChallengeResource;
61+
}
62+
63+
static async attempt(params: AttemptBiometricCredentialParams): Promise<BiometricCredentialResource> {
64+
const json = (
65+
await BaseResource._fetch({
66+
path: `${PATH_ROOT}/attempt`,
67+
method: 'POST',
68+
body: toEnrollmentBody(params) as any,
69+
})
70+
)?.response as unknown as BiometricCredentialJSON;
71+
return new BiometricCredential(json);
72+
}
73+
74+
static async revoke(biometricCredentialId: string): Promise<BiometricCredentialResource> {
75+
const json = (
76+
await BaseResource._fetch({
77+
path: `${PATH_ROOT}/${biometricCredentialId}`,
78+
method: 'DELETE',
79+
})
80+
)?.response as unknown as BiometricCredentialJSON;
81+
return new BiometricCredential(json);
82+
}
83+
84+
protected fromJSON(data: BiometricCredentialJSON | BiometricCredentialJSONSnapshot | null): this {
85+
if (!data) {
86+
return this;
87+
}
88+
89+
this.id = data.id;
90+
this.platform = data.platform;
91+
this.appIdentifier = data.app_identifier;
92+
this.name = data.name ?? null;
93+
this.algorithm = data.algorithm;
94+
this.status = data.status;
95+
this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null;
96+
this.revokedAt = data.revoked_at ? unixEpochToDate(data.revoked_at) : null;
97+
this.createdAt = unixEpochToDate(data.created_at);
98+
this.updatedAt = unixEpochToDate(data.updated_at);
99+
return this;
100+
}
101+
102+
public __internal_toSnapshot(): BiometricCredentialJSONSnapshot {
103+
return {
104+
object: 'trusted_device',
105+
id: this.id,
106+
platform: this.platform,
107+
app_identifier: this.appIdentifier,
108+
name: this.name,
109+
algorithm: this.algorithm,
110+
status: this.status,
111+
last_used_at: this.lastUsedAt?.getTime() ?? null,
112+
revoked_at: this.revokedAt?.getTime() ?? null,
113+
created_at: this.createdAt.getTime(),
114+
updated_at: this.updatedAt.getTime(),
115+
};
116+
}
117+
}

‎packages/clerk-js/src/core/resources/Session.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ export class Session extends BaseResource implements SessionResource {
270270
case 'passkey':
271271
config = {};
272272
break;
273+
case 'trusted_device':
274+
config = { trustedDeviceId: factor.trustedDeviceId };
275+
break;
273276
case 'enterprise_sso':
274277
config = {
275278
emailAddressId: factor.emailAddressId,

0 commit comments

Comments
 (0)