Skip to content

Commit 03f95ab

Browse files
mikepitreclaude
andcommitted
feat(clerk-js,shared): add experimental trusted device resources
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 441f8a7 commit 03f95ab

22 files changed

Lines changed: 843 additions & 4 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
---
5+
6+
Add experimental support for trusted device (biometric credential) resources in native apps:
7+
8+
- `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`.
9+
- `User` gains `__experimental_getBiometricCredentials()`, `__experimental_prepareBiometricCredential()`, `__experimental_attemptBiometricCredential()` and `__experimental_revokeBiometricCredential()` for managing a user's enrolled biometric credentials.
10+
- The environment's auth config now exposes `nativeSettings`, indicating whether trusted device sign-in and enrollment prompts are enabled.
11+
12+
These APIs are experimental and may change in future minor releases.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
7-
{ "path": "./dist/clerk.native.js", "maxSize": "80KB" },
7+
{ "path": "./dist/clerk.native.js", "maxSize": "82KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
1010
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },

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

Lines changed: 38 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,9 @@ 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+
if (data.native_settings !== undefined) {
37+
this.nativeSettings = nativeSettingsFromJSON(data.native_settings);
38+
}
2839
return this;
2940
}
3041

@@ -36,6 +47,32 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
3647
reverification: this.reverification,
3748
single_session_mode: this.singleSessionMode,
3849
session_minter: this.sessionMinter,
50+
native_settings: nativeSettingsToJSON(this.nativeSettings),
3951
};
4052
}
4153
}
54+
55+
function nativeSettingsFromJSON(data: NativeSettingsJSON | null): NativeSettingsResource | null {
56+
if (!data) {
57+
return null;
58+
}
59+
return {
60+
apiEnabled: !!data.api_enabled,
61+
trustedDeviceSignInEnabled: !!data.trusted_device_sign_in_enabled,
62+
trustedDeviceEnrollmentPromptAfterSignInEnabled: !!data.trusted_device_enrollment_prompt_after_sign_in_enabled,
63+
trustedDeviceEnrollmentPromptAfterSignUpEnabled: !!data.trusted_device_enrollment_prompt_after_sign_up_enabled,
64+
};
65+
}
66+
67+
function nativeSettingsToJSON(settings: NativeSettingsResource | null): NativeSettingsJSON | null {
68+
if (!settings) {
69+
return null;
70+
}
71+
return {
72+
object: 'native_settings',
73+
api_enabled: settings.apiEnabled,
74+
trusted_device_sign_in_enabled: settings.trustedDeviceSignInEnabled,
75+
trusted_device_enrollment_prompt_after_sign_in_enabled: settings.trustedDeviceEnrollmentPromptAfterSignInEnabled,
76+
trusted_device_enrollment_prompt_after_sign_up_enabled: settings.trustedDeviceEnrollmentPromptAfterSignUpEnabled,
77+
};
78+
}
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+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { TrustedDeviceChallengeJSON, TrustedDeviceChallengeResource } from '@clerk/shared/types';
2+
3+
export function trustedDeviceChallengeFromJSON(
4+
data: TrustedDeviceChallengeJSON | null | undefined,
5+
): TrustedDeviceChallengeResource | null {
6+
if (!data) {
7+
return null;
8+
}
9+
return {
10+
challenge: data.challenge,
11+
challengeId: data.challenge_id,
12+
trustedDeviceId: data.trusted_device_id ?? null,
13+
clientData: data.client_data,
14+
// FAPI returns this timestamp in seconds, unlike most resource timestamps.
15+
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
16+
algorithm: data.algorithm,
17+
};
18+
}
19+
20+
export function trustedDeviceChallengeToJSON(
21+
challenge: TrustedDeviceChallengeResource | null,
22+
): TrustedDeviceChallengeJSON | null {
23+
if (!challenge) {
24+
return null;
25+
}
26+
return {
27+
object: 'trusted_device_challenge',
28+
challenge: challenge.challenge,
29+
challenge_id: challenge.challengeId,
30+
...(challenge.trustedDeviceId ? { trusted_device_id: challenge.trustedDeviceId } : {}),
31+
client_data: challenge.clientData,
32+
expires_at: challenge.expiresAt ? Math.floor(challenge.expiresAt.getTime() / 1000) : 0,
33+
algorithm: challenge.algorithm,
34+
};
35+
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { getFullName } from '@clerk/shared/internal/clerk-js/user';
22
import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys';
33
import type {
4+
AttemptBiometricCredentialParams,
45
BackupCodeJSON,
56
BackupCodeResource,
7+
BiometricCredentialResource,
68
CreateEmailAddressParams,
79
CreateExternalAccountParams,
810
CreatePhoneNumberParams,
@@ -23,10 +25,12 @@ import type {
2325
OrganizationMembershipResource,
2426
PasskeyResource,
2527
PhoneNumberResource,
28+
PrepareBiometricCredentialParams,
2629
RemoveUserPasswordParams,
2730
SetProfileImageParams,
2831
TOTPJSON,
2932
TOTPResource,
33+
TrustedDeviceChallengeResource,
3034
UpdateUserMetadataParams,
3135
UpdateUserParams,
3236
UpdateUserPasswordParams,
@@ -45,6 +49,7 @@ import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '..
4549
import { BackupCode } from './BackupCode';
4650
import {
4751
BaseResource,
52+
BiometricCredential,
4853
DeletedObject,
4954
EmailAddress,
5055
EnterpriseAccount,
@@ -229,6 +234,26 @@ export class User extends BaseResource implements UserResource {
229234
return new BackupCode(json);
230235
};
231236

237+
__experimental_getBiometricCredentials = (): Promise<BiometricCredentialResource[]> => {
238+
return BiometricCredential.list();
239+
};
240+
241+
__experimental_prepareBiometricCredential = (
242+
params: PrepareBiometricCredentialParams,
243+
): Promise<TrustedDeviceChallengeResource> => {
244+
return BiometricCredential.prepare(params);
245+
};
246+
247+
__experimental_attemptBiometricCredential = (
248+
params: AttemptBiometricCredentialParams,
249+
): Promise<BiometricCredentialResource> => {
250+
return BiometricCredential.attempt(params);
251+
};
252+
253+
__experimental_revokeBiometricCredential = (biometricCredentialId: string): Promise<BiometricCredentialResource> => {
254+
return BiometricCredential.revoke(biometricCredentialId);
255+
};
256+
232257
update = async (params: UpdateUserParams): Promise<UserResource> => {
233258
const { unsafeMetadata, ...rest } = params;
234259
const hasMetadata = unsafeMetadata !== undefined;

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
SignUpVerificationsJSON,
1212
SignUpVerificationsJSONSnapshot,
1313
SignUpVerificationsResource,
14+
TrustedDeviceChallengeResource,
1415
VerificationJSON,
1516
VerificationJSONSnapshot,
1617
VerificationResource,
@@ -19,6 +20,7 @@ import type {
1920

2021
import { unixEpochToDate } from '../../utils/date';
2122
import { BaseResource } from './internal';
23+
import { trustedDeviceChallengeFromJSON, trustedDeviceChallengeToJSON } from './TrustedDeviceChallenge';
2224

2325
export class Verification extends BaseResource implements VerificationResource {
2426
pathRoot = '';
@@ -33,6 +35,7 @@ export class Verification extends BaseResource implements VerificationResource {
3335
error: ClerkAPIError | null = null;
3436
verifiedAtClient: string | null = null;
3537
channel?: PhoneCodeChannel;
38+
trustedDeviceChallenge: TrustedDeviceChallengeResource | null = null;
3639

3740
constructor(data: VerificationJSON | VerificationJSONSnapshot | null) {
3841
super();
@@ -59,6 +62,7 @@ export class Verification extends BaseResource implements VerificationResource {
5962
this.expireAt = unixEpochToDate(data.expire_at || undefined);
6063
this.error = data.error ? new ClerkAPIError(data.error) : null;
6164
this.channel = data.channel || undefined;
65+
this.trustedDeviceChallenge = trustedDeviceChallengeFromJSON(data.trusted_device_challenge);
6266
}
6367
return this;
6468
}
@@ -76,6 +80,9 @@ export class Verification extends BaseResource implements VerificationResource {
7680
expire_at: this.expireAt?.getTime() || null,
7781
error: errorToJSON(this.error),
7882
verified_at_client: this.verifiedAtClient,
83+
...(this.trustedDeviceChallenge && {
84+
trusted_device_challenge: trustedDeviceChallengeToJSON(this.trustedDeviceChallenge),
85+
}),
7986
};
8087
}
8188
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('AuthConfig', () => {
1414
expect(authConfig.claimedAt).toBeNull();
1515
expect(authConfig.reverification).toBe(false);
1616
expect(authConfig.singleSessionMode).toBe(false);
17+
expect(authConfig.nativeSettings).toBeNull();
1718
});
1819

1920
it('initializes with provided values', () => {
@@ -47,6 +48,43 @@ describe('AuthConfig', () => {
4748
reverification: true,
4849
single_session_mode: true,
4950
session_minter: false,
51+
native_settings: null,
52+
});
53+
});
54+
55+
describe('native_settings', () => {
56+
const nativeSettingsJSON = {
57+
object: 'native_settings' as const,
58+
api_enabled: true,
59+
trusted_device_sign_in_enabled: true,
60+
trusted_device_enrollment_prompt_after_sign_in_enabled: false,
61+
trusted_device_enrollment_prompt_after_sign_up_enabled: true,
62+
};
63+
64+
it('parses native settings', () => {
65+
const authConfig = new AuthConfig({ native_settings: nativeSettingsJSON });
66+
67+
expect(authConfig.nativeSettings).toEqual({
68+
apiEnabled: true,
69+
trustedDeviceSignInEnabled: true,
70+
trustedDeviceEnrollmentPromptAfterSignInEnabled: false,
71+
trustedDeviceEnrollmentPromptAfterSignUpEnabled: true,
72+
});
73+
});
74+
75+
it('round-trips native settings through the snapshot', () => {
76+
const snapshot = new AuthConfig({ native_settings: nativeSettingsJSON }).__internal_toSnapshot();
77+
78+
expect(snapshot.native_settings).toEqual(nativeSettingsJSON);
79+
expect(new AuthConfig(snapshot).nativeSettings).toEqual(
80+
new AuthConfig({ native_settings: nativeSettingsJSON }).nativeSettings,
81+
);
82+
});
83+
84+
it('treats null native settings as null', () => {
85+
const authConfig = new AuthConfig({ native_settings: null });
86+
87+
expect(authConfig.nativeSettings).toBeNull();
5088
});
5189
});
5290
});

0 commit comments

Comments
 (0)