Skip to content

Commit 3c225d9

Browse files
octoperpanteliselef
andauthoredMar 11, 2025··
feat(clerk-js,localizations,types): Support passkeys for first factor re-verification (#5242)
Co-authored-by: panteliselef <panteliselef@outlook.com>
1 parent 8a77e9b commit 3c225d9

File tree

10 files changed

+191
-13
lines changed

10 files changed

+191
-13
lines changed
 

‎.changeset/purple-hounds-tie.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/localizations": minor
4+
"@clerk/types": minor
5+
---
6+
7+
Support passkeys as a first factor strategy for reverification

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

+65-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createCheckAuthorization } from '@clerk/shared/authorization';
2-
import { is4xxError } from '@clerk/shared/error';
2+
import { ClerkWebAuthnError, is4xxError } from '@clerk/shared/error';
33
import { retry } from '@clerk/shared/retry';
4+
import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn';
45
import type {
56
ActJWTClaim,
67
CheckAuthorization,
@@ -25,7 +26,12 @@ import type {
2526
} from '@clerk/types';
2627

2728
import { unixEpochToDate } from '../../utils/date';
28-
import { clerkInvalidStrategy } from '../errors';
29+
import {
30+
convertJSONToPublicKeyRequestOptions,
31+
serializePublicKeyCredentialAssertion,
32+
webAuthnGetCredential as webAuthnGetCredentialOnWindow,
33+
} from '../../utils/passkeys';
34+
import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
2935
import { eventBus, events } from '../events';
3036
import { SessionTokenCache } from '../tokenCache';
3137
import { BaseResource, PublicUserData, Token, User } from './internal';
@@ -150,6 +156,9 @@ export class Session extends BaseResource implements SessionResource {
150156
default: factor.default,
151157
} as PhoneCodeConfig;
152158
break;
159+
case 'passkey':
160+
config = {};
161+
break;
153162
default:
154163
clerkInvalidStrategy('Session.prepareFirstFactorVerification', (factor as any).strategy);
155164
}
@@ -171,17 +180,68 @@ export class Session extends BaseResource implements SessionResource {
171180
attemptFirstFactorVerification = async (
172181
attemptFactor: SessionVerifyAttemptFirstFactorParams,
173182
): Promise<SessionVerificationResource> => {
183+
let config;
184+
switch (attemptFactor.strategy) {
185+
case 'passkey': {
186+
config = {
187+
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)),
188+
};
189+
break;
190+
}
191+
default:
192+
config = { ...attemptFactor };
193+
}
194+
174195
const json = (
175196
await BaseResource._fetch({
176197
method: 'POST',
177198
path: `/client/sessions/${this.id}/verify/attempt_first_factor`,
178-
body: { ...attemptFactor, strategy: attemptFactor.strategy } as any,
199+
body: { ...config, strategy: attemptFactor.strategy } as any,
179200
})
180201
)?.response as unknown as SessionVerificationJSON;
181202

182203
return new SessionVerification(json);
183204
};
184205

206+
verifyWithPasskey = async (): Promise<SessionVerificationResource> => {
207+
const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' });
208+
209+
const { nonce = null } = prepareResponse.firstFactorVerification;
210+
211+
/**
212+
* The UI should always prevent from this method being called if WebAuthn is not supported.
213+
* As a precaution we need to check if WebAuthn is supported.
214+
*/
215+
const isWebAuthnSupported = Session.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow;
216+
const webAuthnGetCredential = Session.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow;
217+
218+
if (!isWebAuthnSupported()) {
219+
throw new ClerkWebAuthnError('Passkeys are not supported', {
220+
code: 'passkey_not_supported',
221+
});
222+
}
223+
224+
const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
225+
226+
if (!publicKeyOptions) {
227+
clerkMissingWebAuthnPublicKeyOptions('get');
228+
}
229+
230+
const { publicKeyCredential, error } = await webAuthnGetCredential({
231+
publicKeyOptions,
232+
conditionalUI: false,
233+
});
234+
235+
if (!publicKeyCredential) {
236+
throw error;
237+
}
238+
239+
return this.attemptFirstFactorVerification({
240+
strategy: 'passkey',
241+
publicKeyCredential,
242+
});
243+
};
244+
185245
prepareSecondFactorVerification = async (
186246
params: SessionVerifyPrepareSecondFactorParams,
187247
): Promise<SessionVerificationResource> => {
@@ -197,13 +257,13 @@ export class Session extends BaseResource implements SessionResource {
197257
};
198258

199259
attemptSecondFactorVerification = async (
200-
params: SessionVerifyAttemptSecondFactorParams,
260+
attemptFactor: SessionVerifyAttemptSecondFactorParams,
201261
): Promise<SessionVerificationResource> => {
202262
const json = (
203263
await BaseResource._fetch({
204264
method: 'POST',
205265
path: `/client/sessions/${this.id}/verify/attempt_second_factor`,
206-
body: params as any,
266+
body: attemptFactor as any,
207267
})
208268
)?.response as unknown as SessionVerificationJSON;
209269

‎packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { LocalizationKey } from '../../customizables';
55
import { Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables';
66
import { ArrowBlockButton, BackLink, Card, Header } from '../../elements';
77
import { useCardState } from '../../elements/contexts';
8-
import { ChatAltIcon, Email, LockClosedIcon } from '../../icons';
8+
import { ChatAltIcon, Email, Fingerprint, LockClosedIcon } from '../../icons';
99
import { formatSafeIdentifier } from '../../utils';
1010
import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies';
1111
import { useUserVerificationSession } from './useUserVerificationSession';
@@ -111,6 +111,8 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza
111111
});
112112
case 'password':
113113
return localizationKeys('reverification.alternativeMethods.blockButton__password');
114+
case 'passkey':
115+
return localizationKeys('reverification.alternativeMethods.blockButton__passkey');
114116
default:
115117
throw new Error(`Invalid sign in strategy: "${(factor as any).strategy}"`);
116118
}
@@ -121,6 +123,7 @@ export function getButtonIcon(factor: SessionVerificationFirstFactor) {
121123
email_code: Email,
122124
phone_code: ChatAltIcon,
123125
password: LockClosedIcon,
126+
passkey: Fingerprint,
124127
} as const;
125128

126129
return icons[factor.strategy];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useClerk, useSession } from '@clerk/shared/react';
2+
import React from 'react';
3+
4+
import { Button, Col, descriptors, localizationKeys } from '../../customizables';
5+
import { Card, Form, Header, useCardState } from '../../elements';
6+
import { handleError } from '../../utils';
7+
import { useAfterVerification } from './use-after-verification';
8+
9+
type UVFactorOnePasskeysCard = {
10+
onShowAlternativeMethodsClicked?: React.MouseEventHandler;
11+
};
12+
13+
export const UVFactorOnePasskeysCard = (props: UVFactorOnePasskeysCard) => {
14+
const { onShowAlternativeMethodsClicked } = props;
15+
const { session } = useSession();
16+
// @ts-expect-error - This is not a public API
17+
const { __internal_isWebAuthnSupported } = useClerk();
18+
const { handleVerificationResponse } = useAfterVerification();
19+
20+
const card = useCardState();
21+
22+
const handlePasskeysAttempt = () => {
23+
session
24+
?.verifyWithPasskey()
25+
.then(response => {
26+
return handleVerificationResponse(response);
27+
})
28+
.catch(err => handleError(err, [], card.setError));
29+
30+
return;
31+
};
32+
33+
return (
34+
<Card.Root>
35+
<Card.Content>
36+
<Header.Root showLogo>
37+
<Header.Title localizationKey={localizationKeys('reverification.passkey.title')} />
38+
<Header.Subtitle localizationKey={localizationKeys('reverification.passkey.subtitle')} />
39+
</Header.Root>
40+
<Card.Alert>{card.error}</Card.Alert>
41+
<Col
42+
elementDescriptor={descriptors.main}
43+
gap={8}
44+
>
45+
<Form.Root>
46+
<Col gap={3}>
47+
<Button
48+
type='button'
49+
onClick={e => {
50+
e.preventDefault();
51+
handlePasskeysAttempt();
52+
}}
53+
localizationKey={localizationKeys('reverification.passkey.blockButton__passkey')}
54+
hasArrow
55+
/>
56+
<Card.Action elementId='alternativeMethods'>
57+
{onShowAlternativeMethodsClicked && (
58+
<Card.ActionLink
59+
localizationKey={localizationKeys('footerActionLink__useAnotherMethod')}
60+
onClick={onShowAlternativeMethodsClicked}
61+
/>
62+
)}
63+
</Card.Action>
64+
</Col>
65+
</Form.Root>
66+
</Col>
67+
</Card.Content>
68+
69+
<Card.Footer />
70+
</Card.Root>
71+
);
72+
};

‎packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOne.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useReverificationAlternativeStrategies } from './useReverificationAlter
1111
import { UserVerificationFactorOnePasswordCard } from './UserVerificationFactorOnePassword';
1212
import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession';
1313
import { UVFactorOneEmailCodeCard } from './UVFactorOneEmailCodeCard';
14+
import { UVFactorOnePasskeysCard } from './UVFactorOnePasskeysCard';
1415
import { UVFactorOnePhoneCodeCard } from './UVFactorOnePhoneCodeCard';
1516

1617
const factorKey = (factor: SignInFactor | null | undefined) => {
@@ -27,7 +28,7 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
2728
return key;
2829
};
2930

30-
export function _UserVerificationFactorOne(): JSX.Element | null {
31+
export function UserVerificationFactorOneInternal(): JSX.Element | null {
3132
const { data } = useUserVerificationSession();
3233
const card = useCardState();
3334
const { navigate } = useRouter();
@@ -55,7 +56,12 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
5556
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
5657
);
5758

58-
const toggleAllStrategies = hasAnyStrategy ? () => setShowAllStrategies(s => !s) : undefined;
59+
const toggleAllStrategies = hasAnyStrategy
60+
? () => {
61+
card.setError(undefined);
62+
setShowAllStrategies(s => !s);
63+
}
64+
: undefined;
5965

6066
const handleFactorPrepare = () => {
6167
lastPreparedFactorKeyRef.current = factorKey(currentFactor);
@@ -129,11 +135,13 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
129135
showAlternativeMethods={hasFirstParty}
130136
/>
131137
);
138+
case 'passkey':
139+
return <UVFactorOnePasskeysCard onShowAlternativeMethodsClicked={toggleAllStrategies} />;
132140
default:
133141
return <LoadingCard />;
134142
}
135143
}
136144

137145
export const UserVerificationFactorOne = withUserVerificationSessionGuard(
138-
withCardStateProvider(_UserVerificationFactorOne),
146+
withCardStateProvider(UserVerificationFactorOneInternal),
139147
);

‎packages/clerk-js/src/ui/components/UserVerification/useReverificationAlternativeStrategies.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isWebAuthnSupported } from '@clerk/shared/webauthn';
12
import type { SignInFactor, SignInFirstFactor, SignInSecondFactor } from '@clerk/types';
23
import { useMemo } from 'react';
34

‎packages/localizations/src/en-US.ts

+7
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export const enUS: LocalizationResource = {
265265
actionLink: 'Get help',
266266
actionText: 'Don’t have any of these?',
267267
blockButton__backupCode: 'Use a backup code',
268+
blockButton__passkey: 'Use your passkey',
268269
blockButton__emailCode: 'Email code to {{identifier}}',
269270
blockButton__password: 'Continue with your password',
270271
blockButton__phoneCode: 'Send SMS code to {{identifier}}',
@@ -282,6 +283,12 @@ export const enUS: LocalizationResource = {
282283
subtitle: 'Enter the backup code you received when setting up two-step authentication',
283284
title: 'Enter a backup code',
284285
},
286+
passkey: {
287+
subtitle:
288+
'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
289+
title: 'Use your passkey',
290+
blockButton__passkey: 'Use your passkey',
291+
},
285292
emailCode: {
286293
formTitle: 'Verification code',
287294
resendButton: "Didn't receive a code? Resend",

‎packages/types/src/localization.ts

+6
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ type _LocalizationResource = {
346346
title: LocalizationValue;
347347
subtitle: LocalizationValue;
348348
};
349+
passkey: {
350+
title: LocalizationValue;
351+
subtitle: LocalizationValue;
352+
blockButton__passkey: LocalizationValue;
353+
};
349354
alternativeMethods: {
350355
title: LocalizationValue;
351356
subtitle: LocalizationValue;
@@ -355,6 +360,7 @@ type _LocalizationResource = {
355360
blockButton__phoneCode: LocalizationValue;
356361
blockButton__password: LocalizationValue;
357362
blockButton__totp: LocalizationValue;
363+
blockButton__passkey: LocalizationValue;
358364
blockButton__backupCode: LocalizationValue;
359365
getHelp: {
360366
title: LocalizationValue;

‎packages/types/src/session.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type {
22
BackupCodeAttempt,
33
EmailCodeAttempt,
44
EmailCodeConfig,
5+
PasskeyAttempt,
6+
PassKeyConfig,
57
PasswordAttempt,
68
PhoneCodeAttempt,
79
PhoneCodeConfig,
@@ -152,6 +154,7 @@ export interface SessionResource extends ClerkResource {
152154
attemptSecondFactorVerification: (
153155
params: SessionVerifyAttemptSecondFactorParams,
154156
) => Promise<SessionVerificationResource>;
157+
verifyWithPasskey: () => Promise<SessionVerificationResource>;
155158
__internal_toSnapshot: () => SessionJSONSnapshot;
156159
}
157160

@@ -236,8 +239,12 @@ export type SessionVerifyCreateParams = {
236239
level: SessionVerificationLevel;
237240
};
238241

239-
export type SessionVerifyPrepareFirstFactorParams = EmailCodeConfig | PhoneCodeConfig;
240-
export type SessionVerifyAttemptFirstFactorParams = EmailCodeAttempt | PhoneCodeAttempt | PasswordAttempt;
242+
export type SessionVerifyPrepareFirstFactorParams = EmailCodeConfig | PhoneCodeConfig | PassKeyConfig;
243+
export type SessionVerifyAttemptFirstFactorParams =
244+
| EmailCodeAttempt
245+
| PhoneCodeAttempt
246+
| PasswordAttempt
247+
| PasskeyAttempt;
241248

242249
export type SessionVerifyPrepareSecondFactorParams = PhoneCodeSecondFactorConfig;
243250
export type SessionVerifyAttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt;

‎packages/types/src/sessionVerification.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { BackupCodeFactor, EmailCodeFactor, PasswordFactor, PhoneCodeFactor, TOTPFactor } from './factors';
1+
import type {
2+
BackupCodeFactor,
3+
EmailCodeFactor,
4+
PasskeyFactor,
5+
PasswordFactor,
6+
PhoneCodeFactor,
7+
TOTPFactor,
8+
} from './factors';
29
import type { ClerkResource } from './resource';
310
import type { SessionResource } from './session';
411
import type { VerificationResource } from './verification';
@@ -27,5 +34,5 @@ export type ReverificationConfig =
2734
export type SessionVerificationLevel = 'first_factor' | 'second_factor' | 'multi_factor';
2835
export type SessionVerificationAfterMinutes = number;
2936

30-
export type SessionVerificationFirstFactor = EmailCodeFactor | PhoneCodeFactor | PasswordFactor;
37+
export type SessionVerificationFirstFactor = EmailCodeFactor | PhoneCodeFactor | PasswordFactor | PasskeyFactor;
3138
export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor;

0 commit comments

Comments
 (0)
Please sign in to comment.