Skip to content

Commit fafa76f

Browse files
authored
feat(clerk-js): Adds experimental support for registering a passkey (clerk#2884)
* feat(clerk-js): Adds experimental support for registering a passkey * chore(clerk-js): Add changelog * fix(clerk-js): Align endpoints and payload * chore(clerk-js): Support create endpoint for passkeys * test(clerk-js): Test transformations for webauthn payloads * chore(remix): Minor refactor * chore(clerk-js): Update experimental prefix * chore(clerk-js): Use nonce and convert it to publicKey - Remove prepare verification step - Update environment to handle passkey attribute * chore(clerk-js): Improve comments
1 parent 7c66796 commit fafa76f

14 files changed

Lines changed: 519 additions & 2 deletions

File tree

.changeset/late-insects-doubt.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Experimental support for a user to register a passkey for their account.
7+
Usage: `await clerk.user.__experimental__createPasskey()`
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { PasskeyJSON, PasskeyResource, PasskeyVerificationResource } from '@clerk/types';
2+
3+
import { unixEpochToDate } from '../../utils/date';
4+
import type { PublicKeyCredentialWithAuthenticatorAttestationResponse } from '../../utils/passkeys';
5+
import {
6+
isWebAuthnPlatformAuthenticatorSupported,
7+
serializePublicKeyCredential,
8+
webAuthnCreateCredential,
9+
} from '../../utils/passkeys';
10+
import { BaseResource, ClerkRuntimeError, PasskeyVerification } from './internal';
11+
12+
export class Passkey extends BaseResource implements PasskeyResource {
13+
id!: string;
14+
pathRoot = '/me/passkeys';
15+
credentialId: string | null = null;
16+
verification: PasskeyVerificationResource | null = null;
17+
name: string | null = null;
18+
lastUsedAt: Date | null = null;
19+
createdAt!: Date;
20+
updatedAt!: Date;
21+
22+
public constructor(data: PasskeyJSON) {
23+
super();
24+
this.fromJSON(data);
25+
}
26+
27+
private static async create() {
28+
return BaseResource._fetch({
29+
path: `/me/passkeys`,
30+
method: 'POST',
31+
}).then(res => new Passkey(res?.response as PasskeyJSON));
32+
}
33+
34+
private static async attemptVerification(
35+
passkeyId: string,
36+
credential: PublicKeyCredentialWithAuthenticatorAttestationResponse,
37+
) {
38+
const jsonPublicKeyCredential = serializePublicKeyCredential(credential);
39+
return BaseResource._fetch({
40+
path: `/me/passkeys/${passkeyId}/attempt_verification`,
41+
method: 'POST',
42+
body: { strategy: 'passkey', publicKeyCredential: JSON.stringify(jsonPublicKeyCredential) } as any,
43+
}).then(res => new Passkey(res?.response as PasskeyJSON));
44+
}
45+
46+
/**
47+
* TODO-PASSKEYS: Implement this later
48+
*
49+
* GET /v1/me/passkeys
50+
*/
51+
static async get() {}
52+
53+
/**
54+
* Developers should not be able to create a new Passkeys from an already instanced object
55+
*/
56+
static async registerPasskey() {
57+
/**
58+
* The UI should always prevent from this method being called if WebAuthn is not supported.
59+
* As a precaution we need to check if WebAuthn is supported.
60+
*/
61+
62+
/**
63+
* TODO-PASSKEYS: First simply check if webauthn is supported and check for this only when
64+
* publicKey?.authenticatorSelection.authenticatorAttachment === 'platform'
65+
*/
66+
if (!(await isWebAuthnPlatformAuthenticatorSupported())) {
67+
throw new ClerkRuntimeError('Platform authenticator is not supported', {
68+
code: 'passkeys_unsupported_platform_authenticator',
69+
});
70+
}
71+
72+
const passkey = await this.create();
73+
74+
const { verification } = passkey;
75+
76+
const publicKey = verification?.publicKey;
77+
78+
// This should never occur such a fail-safe
79+
if (!publicKey) {
80+
// TODO-PASSKEYS: Implement this later
81+
throw 'Missing key';
82+
}
83+
84+
// Invoke the WebAuthn create() method.
85+
const { publicKeyCredential, error } = await webAuthnCreateCredential(publicKey);
86+
87+
if (!publicKeyCredential) {
88+
throw error;
89+
}
90+
91+
return this.attemptVerification(passkey.id, publicKeyCredential);
92+
}
93+
94+
/**
95+
* TODO-PASSKEYS: Implement this later
96+
*
97+
* PATCH /v1/me/passkeys/{passkeyIdentificationID}
98+
*/
99+
update = (): Promise<PasskeyResource> => this._basePatch();
100+
101+
/**
102+
* TODO-PASSKEYS: Implement this later
103+
*
104+
* DELETE /v1/me/passkeys/{passkeyIdentificationID}
105+
*/
106+
destroy = (): Promise<void> => this._baseDelete();
107+
108+
/**
109+
* TODO-PASSKEYS: Implement this later
110+
*
111+
* GET /v1/me/passkeys/{passkeyIdentificationID}
112+
*/
113+
reload = () => this._baseGet();
114+
115+
protected fromJSON(data: PasskeyJSON | null): this {
116+
if (!data) {
117+
return this;
118+
}
119+
120+
this.id = data.id;
121+
this.credentialId = data.credential_id;
122+
this.name = data.name;
123+
this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null;
124+
this.createdAt = unixEpochToDate(data.created_at);
125+
this.updatedAt = unixEpochToDate(data.updated_at);
126+
127+
if (data.verification) {
128+
this.verification = new PasskeyVerification(data.verification);
129+
}
130+
return this;
131+
}
132+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
GetUserOrganizationSuggestionsParams,
1616
ImageResource,
1717
OrganizationMembershipResource,
18+
PasskeyResource,
1819
PhoneNumberResource,
1920
RemoveUserPasswordParams,
2021
SamlAccountResource,
@@ -41,6 +42,7 @@ import {
4142
Image,
4243
OrganizationMembership,
4344
OrganizationSuggestion,
45+
Passkey,
4446
PhoneNumber,
4547
SamlAccount,
4648
SessionWithActivities,
@@ -124,6 +126,14 @@ export class User extends BaseResource implements UserResource {
124126
).create();
125127
};
126128

129+
/**
130+
* @experimental
131+
* This method is experimental, avoid using this in production applications
132+
*/
133+
__experimental_createPasskey = (): Promise<PasskeyResource> => {
134+
return Passkey.registerPasskey();
135+
};
136+
127137
createPhoneNumber = (params: CreatePhoneNumberParams): Promise<PhoneNumberResource> => {
128138
const { phoneNumber } = params || {};
129139
return new PhoneNumber(

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { parseError } from '@clerk/shared/error';
22
import type {
33
ClerkAPIError,
4+
PasskeyVerificationResource,
5+
PublicKeyCredentialCreationOptionsJSON,
6+
PublicKeyCredentialCreationOptionsWithoutExtensions,
47
SignUpVerificationJSON,
58
SignUpVerificationResource,
69
SignUpVerificationsJSON,
@@ -11,6 +14,7 @@ import type {
1114
} from '@clerk/types';
1215

1316
import { unixEpochToDate } from '../../utils/date';
17+
import { convertJSONToPublicKeyCreateOptions } from '../../utils/passkeys';
1418
import { BaseResource } from './internal';
1519

1620
export class Verification extends BaseResource implements VerificationResource {
@@ -53,6 +57,27 @@ export class Verification extends BaseResource implements VerificationResource {
5357
}
5458
}
5559

60+
export class PasskeyVerification extends Verification implements PasskeyVerificationResource {
61+
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions | null = null;
62+
63+
constructor(data: VerificationJSON | null) {
64+
super(data);
65+
this.fromJSON(data);
66+
}
67+
68+
/**
69+
* Transform base64url encoded strings to ArrayBuffer
70+
*/
71+
protected fromJSON(data: VerificationJSON | null): this {
72+
if (data?.nonce) {
73+
this.publicKey = convertJSONToPublicKeyCreateOptions(
74+
JSON.parse(data.nonce) as PublicKeyCredentialCreationOptionsJSON,
75+
);
76+
}
77+
return this;
78+
}
79+
}
80+
5681
export class SignUpVerifications implements SignUpVerificationsResource {
5782
emailAddress: SignUpVerificationResource;
5883
phoneNumber: SignUpVerificationResource;

packages/clerk-js/src/core/resources/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from './OrganizationMembershipRequest';
2020
export * from './OrganizationSuggestion';
2121
export * from './SamlAccount';
2222
export * from './Session';
23+
export * from './Passkey';
2324
export * from './PublicUserData';
2425
export * from './SessionWithActivities';
2526
export * from './SignIn';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { PublicKeyCredentialCreationOptionsJSON } from '@clerk/types';
2+
3+
import type { PublicKeyCredentialWithAuthenticatorAttestationResponse } from '../passkeys';
4+
import { bufferToBase64Url, convertJSONToPublicKeyCreateOptions, serializePublicKeyCredential } from '../passkeys';
5+
6+
describe('Passkey utils', () => {
7+
describe('serialization', () => {
8+
it('convertJSONToPublicKeyCreateOptions()', () => {
9+
const pkCreateOptions: PublicKeyCredentialCreationOptionsJSON = {
10+
rp: {
11+
name: 'clerk.com',
12+
id: 'clerk.com',
13+
},
14+
user: {
15+
name: 'clerkUser',
16+
displayName: 'Clerk User',
17+
id: 'dXNlcl8xMjM', // user_123 encoded as base64url
18+
},
19+
excludeCredentials: [
20+
{
21+
type: 'public-key',
22+
id: 'cmFuZG9tX2lk',
23+
},
24+
],
25+
authenticatorSelection: {
26+
requireResidentKey: true,
27+
residentKey: 'required',
28+
userVerification: 'required',
29+
},
30+
attestation: 'none',
31+
pubKeyCredParams: [
32+
{
33+
type: 'public-key',
34+
alg: -7,
35+
},
36+
],
37+
timeout: 10000,
38+
challenge: 'Y2hhbGxlbmdlXzEyMw', // challenge_123 encoded as base64url
39+
};
40+
41+
const result = convertJSONToPublicKeyCreateOptions(pkCreateOptions);
42+
43+
expect(result.rp).toEqual({
44+
name: 'clerk.com',
45+
id: 'clerk.com',
46+
});
47+
48+
expect(result.attestation).toEqual('none');
49+
expect(result.authenticatorSelection).toEqual({
50+
requireResidentKey: true,
51+
residentKey: 'required',
52+
userVerification: 'required',
53+
});
54+
55+
expect(bufferToBase64Url(result.user.id)).toEqual(pkCreateOptions.user.id);
56+
57+
expect(bufferToBase64Url(result.excludeCredentials[0].id)).toEqual(pkCreateOptions.excludeCredentials[0].id);
58+
});
59+
60+
it('serializePublicKeyCredential()', () => {
61+
const publicKeyCredential: PublicKeyCredentialWithAuthenticatorAttestationResponse = {
62+
type: 'public-key',
63+
id: 'credentialId_123',
64+
rawId: new Uint8Array([99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 73, 100, 95, 49, 50, 51]),
65+
authenticatorAttachment: 'cross-platform',
66+
response: {
67+
clientDataJSON: new Uint8Array([110, 116, 105, 97]),
68+
attestationObject: new Uint8Array([108, 73, 100, 95, 49]),
69+
getTransports: () => ['usb'],
70+
},
71+
};
72+
73+
const result = serializePublicKeyCredential(publicKeyCredential);
74+
75+
expect(result.type).toEqual('public-key');
76+
expect(result.id).toEqual('credentialId_123');
77+
expect(result.rawId).toEqual('Y3JlZGVudGlhbElkXzEyMw');
78+
79+
expect(result.response.clientDataJSON).toEqual('bnRpYQ');
80+
expect(result.response.attestationObject).toEqual('bElkXzE');
81+
expect(result.response.transports).toEqual(['usb']);
82+
});
83+
});
84+
});

0 commit comments

Comments
 (0)