Skip to content

Commit ebf9be7

Browse files
authoredMar 19, 2024··
feat(clerk-js): Use passkey as first factor in <SignIn/> (#3000)
* feat(clerk-js): WIP SignIn with UI * chore(clerk-js): Cleanup * chore(clerk-js): Refactor shared code * chore(clerk-js): Bring back isWebAuthnAutofillSupported * chore(clerk-js): Display "Use passkey instead" * chore(clerk-js): Improve conditional calling of `create` * chore(clerk-js): Remove preferredSignInStrategy mock * feat(clerk-js): Add fingerprint icon * chore(clerk-js): Add changeset * fix(clerk-js): Add localized text * chore(clerk-js): Improve abort error * chore(clerk-js): Fix build * fix(clerk-js): Remove mocks to allow tests to run * fix(clerk-js): Remove mocks to allow tests to run * feat(clerk-js): Add passkey settings from environment * fix(clerk-js): Correctly call authenticatePasskey for the appropriate flow
1 parent 31570f1 commit ebf9be7

23 files changed

+323
-21
lines changed
 

‎.changeset/weak-adults-juggle.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Allow users to authenticate with passkeys via the `<SignIn/>`.

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

+27-14
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,11 @@ export class SignIn extends BaseResource implements SignInResource {
261261
});
262262
};
263263

264-
public __experimental_authenticateWithPasskey = async (): Promise<SignInResource> => {
264+
public __experimental_authenticateWithPasskey = async (params?: {
265+
flow?: 'autofill' | 'discoverable';
266+
}): Promise<SignInResource> => {
267+
const { flow } = params || {};
268+
265269
/**
266270
* The UI should always prevent from this method being called if WebAuthn is not supported.
267271
* As a precaution we need to check if WebAuthn is supported.
@@ -272,24 +276,23 @@ export class SignIn extends BaseResource implements SignInResource {
272276
});
273277
}
274278

275-
if (!this.firstFactorVerification.nonce) {
279+
if (flow === 'autofill' || flow === 'discoverable') {
276280
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
277281
await this.create({ strategy: 'passkey' });
278-
}
279-
280-
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
281-
const passKeyFactor = this.supportedFirstFactors.find(
282+
} else {
282283
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
283-
f => f.strategy === 'passkey',
284-
) as __experimental_PasskeyFactor;
284+
const passKeyFactor = this.supportedFirstFactors.find(
285+
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
286+
f => f.strategy === 'passkey',
287+
) as __experimental_PasskeyFactor;
285288

286-
if (!passKeyFactor) {
287-
clerkVerifyPasskeyCalledBeforeCreate();
289+
if (!passKeyFactor) {
290+
clerkVerifyPasskeyCalledBeforeCreate();
291+
}
292+
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
293+
await this.prepareFirstFactor(passKeyFactor);
288294
}
289295

290-
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
291-
await this.prepareFirstFactor(passKeyFactor);
292-
293296
const { nonce } = this.firstFactorVerification;
294297
const publicKey = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;
295298

@@ -298,10 +301,20 @@ export class SignIn extends BaseResource implements SignInResource {
298301
throw 'Missing key';
299302
}
300303

304+
let canUseConditionalUI = false;
305+
306+
if (flow === 'autofill') {
307+
/**
308+
* If autofill is not supported gracefully handle the result, we don't need to throw.
309+
* The caller should always check this before calling this method.
310+
*/
311+
canUseConditionalUI = await isWebAuthnAutofillSupported();
312+
}
313+
301314
// Invoke the WebAuthn get() method.
302315
const { publicKeyCredential, error } = await webAuthnGetCredential({
303316
publicKeyOptions: publicKey,
304-
conditionalUI: await isWebAuthnAutofillSupported(),
317+
conditionalUI: canUseConditionalUI,
305318
});
306319

307320
if (!publicKeyCredential) {

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

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
Attributes,
33
OAuthProviders,
44
OAuthStrategy,
5+
PasskeySettingsData,
56
PasswordSettingsData,
67
SamlSettings,
78
SignInData,
@@ -35,6 +36,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
3536
signIn!: SignInData;
3637
signUp!: SignUpData;
3738
passwordSettings!: PasswordSettingsData;
39+
passkeySettings!: PasskeySettingsData;
3840

3941
socialProviderStrategies: OAuthStrategy[] = [];
4042
authenticatableSocialStrategies: OAuthStrategy[] = [];
@@ -79,6 +81,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
7981
? defaultMaxPasswordLength
8082
: Math.min(data?.password_settings?.max_length, defaultMaxPasswordLength),
8183
};
84+
this.passkeySettings = data.passkey_settings;
8285
this.socialProviderStrategies = this.getSocialProviderStrategies(data.social);
8386
this.authenticatableSocialStrategies = this.getAuthenticatableSocialStrategies(data.social);
8487
this.web3FirstFactors = this.getWeb3FirstFactors(this.attributes);

‎packages/clerk-js/src/ui/common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const getIdentifierControlDisplayValues = (
7272
};
7373

7474
export const PREFERRED_SIGN_IN_STRATEGIES = Object.freeze({
75+
Passkey: 'passkey',
7576
Password: 'password',
7677
OTP: 'otp',
7778
});

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Button, Col, descriptors, Flex, Flow, localizationKeys } from '../../cu
66
import { ArrowBlockButton, BackLink, Card, Divider, Header } from '../../elements';
77
import { useCardState } from '../../elements/contexts';
88
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
9-
import { ChatAltIcon, Email, LinkIcon, LockClosedIcon, RequestAuthIcon } from '../../icons';
9+
import { ChatAltIcon, Email, Fingerprint, LinkIcon, LockClosedIcon, RequestAuthIcon } from '../../icons';
1010
import { formatSafeIdentifier } from '../../utils';
1111
import { SignInSocialButtons } from './SignInSocialButtons';
1212
import { useResetPasswordFactor } from './useResetPasswordFactor';
@@ -136,6 +136,9 @@ export function getButtonLabel(factor: SignInFactor): LocalizationKey {
136136
});
137137
case 'password':
138138
return localizationKeys('signIn.alternativeMethods.blockButton__password');
139+
// @ts-ignore
140+
case 'passkey':
141+
return localizationKeys('signIn.alternativeMethods.blockButton__passkey');
139142
case 'reset_password_email_code':
140143
return localizationKeys('signIn.forgotPasswordAlternativeMethods.blockButton__resetPassword');
141144
case 'reset_password_phone_code':
@@ -153,6 +156,7 @@ export function getButtonIcon(factor: SignInFactor) {
153156
reset_password_email_code: RequestAuthIcon,
154157
reset_password_phone_code: RequestAuthIcon,
155158
password: LockClosedIcon,
159+
passkey: Fingerprint,
156160
} as const;
157161

158162
return icons[factor.strategy as keyof typeof icons];

‎packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AlternativeMethods } from './AlternativeMethods';
1111
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
1212
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
1313
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
14+
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
1415
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
1516
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
1617
import { useResetPasswordFactor } from './useResetPasswordFactor';
@@ -112,6 +113,14 @@ export function _SignInFactorOne(): JSX.Element {
112113
}
113114

114115
switch (currentFactor?.strategy) {
116+
// @ts-ignore
117+
case 'passkey':
118+
return (
119+
<SignInFactorOnePasskey
120+
onFactorPrepare={handleFactorPrepare}
121+
onShowAlternativeMethodsClick={toggleAllStrategies}
122+
/>
123+
);
115124
case 'password':
116125
return (
117126
<SignInFactorOnePasswordCard
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ResetPasswordCodeFactor } from '@clerk/types';
2+
import React from 'react';
3+
4+
import { useCoreSignIn } from '../../contexts';
5+
import { descriptors, Flex, Flow, Icon, localizationKeys } from '../../customizables';
6+
import { Card, Form, Header, IdentityPreview, useCardState } from '../../elements';
7+
import { Fingerprint } from '../../icons';
8+
import { useRouter } from '../../router/RouteContext';
9+
import { HavingTrouble } from './HavingTrouble';
10+
import { useHandleAuthenticateWithPasskey } from './shared';
11+
12+
type SignInFactorOnePasswordProps = {
13+
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
14+
onFactorPrepare: (f: ResetPasswordCodeFactor) => void;
15+
};
16+
17+
export const SignInFactorOnePasskey = (props: SignInFactorOnePasswordProps) => {
18+
const { onShowAlternativeMethodsClick } = props;
19+
const card = useCardState();
20+
const signIn = useCoreSignIn();
21+
const { navigate } = useRouter();
22+
const [showHavingTrouble, setShowHavingTrouble] = React.useState(false);
23+
const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]);
24+
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();
25+
26+
const goBack = () => {
27+
return navigate('../');
28+
};
29+
30+
const handleSubmit: React.FormEventHandler = e => {
31+
e.preventDefault();
32+
return authenticateWithPasskey();
33+
};
34+
35+
if (showHavingTrouble) {
36+
return <HavingTrouble onBackLinkClick={toggleHavingTrouble} />;
37+
}
38+
39+
return (
40+
<Flow.Part part='password'>
41+
<Card.Root>
42+
<Card.Content>
43+
<Header.Root showLogo>
44+
<Icon
45+
elementDescriptor={descriptors.passkeyIcon}
46+
icon={Fingerprint}
47+
sx={t => ({
48+
color: t.colors.$neutralAlpha500,
49+
marginInline: 'auto',
50+
paddingBottom: t.sizes.$1,
51+
width: t.sizes.$12,
52+
height: t.sizes.$12,
53+
})}
54+
/>
55+
<Header.Title localizationKey={localizationKeys('signIn.passkey.title')} />
56+
<Header.Subtitle localizationKey={localizationKeys('signIn.passkey.subtitle')} />
57+
<IdentityPreview
58+
identifier={signIn.identifier}
59+
avatarUrl={signIn.userData.imageUrl}
60+
onClick={goBack}
61+
/>
62+
</Header.Root>
63+
<Card.Alert>{card.error}</Card.Alert>
64+
<Flex
65+
direction='col'
66+
elementDescriptor={descriptors.main}
67+
gap={4}
68+
>
69+
<Form.Root
70+
onSubmit={handleSubmit}
71+
gap={8}
72+
>
73+
<Form.SubmitButton hasArrow />
74+
</Form.Root>
75+
<Card.Action elementId={onShowAlternativeMethodsClick ? 'alternativeMethods' : 'havingTrouble'}>
76+
<Card.ActionLink
77+
localizationKey={localizationKeys(
78+
onShowAlternativeMethodsClick
79+
? 'footerActionLink__useAnotherMethod'
80+
: 'signIn.alternativeMethods.actionLink',
81+
)}
82+
onClick={onShowAlternativeMethodsClick || toggleHavingTrouble}
83+
/>
84+
</Card.Action>
85+
</Flex>
86+
</Card.Content>
87+
<Card.Footer />
88+
</Card.Root>
89+
</Flow.Part>
90+
);
91+
};

‎packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx

+45-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
55
import { ERROR_CODES } from '../../../core/constants';
66
import { clerkInvalidFAPIResponse } from '../../../core/errors';
77
import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils';
8+
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '../../../utils/passkeys';
89
import type { SignInStartIdentifier } from '../../common';
910
import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn } from '../../common';
1011
import { buildSSOCallbackURL } from '../../common/redirects';
@@ -24,8 +25,36 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
2425
import { useRouter } from '../../router';
2526
import type { FormControlState } from '../../utils';
2627
import { buildRequest, handleError, isMobileDevice, useFormControl } from '../../utils';
28+
import { useHandleAuthenticateWithPasskey } from './shared';
2729
import { SignInSocialButtons } from './SignInSocialButtons';
2830

31+
const useAutoFillPasskey = () => {
32+
const [isSupported, setIsSupported] = useState(false);
33+
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();
34+
const { userSettings } = useEnvironment();
35+
const { passkeySettings } = userSettings;
36+
37+
useEffect(() => {
38+
async function runAutofillPasskey() {
39+
const _isSupported = await isWebAuthnAutofillSupported();
40+
setIsSupported(_isSupported);
41+
if (!_isSupported) {
42+
return;
43+
}
44+
45+
await authenticateWithPasskey({ flow: 'autofill' });
46+
}
47+
48+
if (passkeySettings.allow_autofill) {
49+
runAutofillPasskey();
50+
}
51+
}, []);
52+
53+
return {
54+
isWebAuthnAutofillSupported: isSupported,
55+
};
56+
};
57+
2958
export function _SignInStart(): JSX.Element {
3059
const card = useCardState();
3160
const clerk = useClerk();
@@ -41,6 +70,13 @@ export function _SignInStart(): JSX.Element {
4170
[userSettings.enabledFirstFactorIdentifiers],
4271
);
4372

73+
/**
74+
* Passkeys
75+
*/
76+
const { isWebAuthnAutofillSupported } = useAutoFillPasskey();
77+
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();
78+
const isWebSupported = isWebAuthnSupported();
79+
4480
const onlyPhoneNumberInitialValueExists =
4581
!!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username);
4682
const shouldStartWithPhoneNumberIdentifier =
@@ -318,6 +354,7 @@ export function _SignInStart(): JSX.Element {
318354
onActionClicked={switchToNextIdentifier}
319355
{...identifierFieldProps}
320356
autoFocus={shouldAutofocus}
357+
autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined}
321358
/>
322359
</Form.ControlRow>
323360
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
@@ -326,9 +363,16 @@ export function _SignInStart(): JSX.Element {
326363
</Form.Root>
327364
) : null}
328365
</SocialButtonsReversibleContainerWithDivider>
366+
{userSettings.passkeySettings.show_sign_in_button && isWebSupported && (
367+
<Card.Action elementId={'usePasskey'}>
368+
<Card.ActionLink
369+
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
370+
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
371+
/>
372+
</Card.Action>
373+
)}
329374
</Col>
330375
</Card.Content>
331-
332376
<Card.Footer>
333377
<Card.Action elementId='signIn'>
334378
<Card.ActionText localizationKey={localizationKeys('signIn.start.actionText')} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isClerkRuntimeError, isUserLockedError } from '@clerk/shared/error';
2+
import { useClerk } from '@clerk/shared/react';
3+
import { useCallback, useEffect } from 'react';
4+
5+
import { clerkInvalidFAPIResponse } from '../../../core/errors';
6+
import { __internal_WebAuthnAbortService } from '../../../utils/passkeys';
7+
import { useCoreSignIn, useSignInContext } from '../../contexts';
8+
import { useCardState } from '../../elements';
9+
import { useSupportEmail } from '../../hooks/useSupportEmail';
10+
import { useRouter } from '../../router';
11+
import { handleError } from '../../utils';
12+
13+
function useHandleAuthenticateWithPasskey() {
14+
const card = useCardState();
15+
const { setActive } = useClerk();
16+
const { navigate } = useRouter();
17+
const supportEmail = useSupportEmail();
18+
const { navigateAfterSignIn } = useSignInContext();
19+
const { __experimental_authenticateWithPasskey } = useCoreSignIn();
20+
21+
useEffect(() => {
22+
return () => {
23+
__internal_WebAuthnAbortService.abort();
24+
};
25+
}, []);
26+
27+
return useCallback(async (...args: Parameters<typeof __experimental_authenticateWithPasskey>) => {
28+
try {
29+
const res = await __experimental_authenticateWithPasskey(...args);
30+
switch (res.status) {
31+
case 'complete':
32+
return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn });
33+
case 'needs_second_factor':
34+
return navigate('../factor-two');
35+
default:
36+
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
37+
}
38+
} catch (err) {
39+
// In case of autofill, if retrieval of credentials is aborted just return to avoid updating state of unmounted components.
40+
if (isClerkRuntimeError(err) && err.code === 'passkey_retrieval_aborted') {
41+
return;
42+
}
43+
if (isUserLockedError(err)) {
44+
// @ts-expect-error -- private method for the time being
45+
return clerk.__internal_navigateWithError('..', err.errors[0]);
46+
}
47+
handleError(err, [], card.setError);
48+
}
49+
}, []);
50+
}
51+
52+
export { useHandleAuthenticateWithPasskey };

‎packages/clerk-js/src/ui/components/SignIn/utils.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ export function determineStartingSignInFactor(
9090
return null;
9191
}
9292

93+
//TODO: Create proper function like `determineStrategyWhenOTPIsPreferred`
94+
if (preferredSignInStrategy === PREFERRED_SIGN_IN_STRATEGIES.Passkey) {
95+
// @ts-ignore
96+
return firstFactors.find(f => f.strategy === PREFERRED_SIGN_IN_STRATEGIES.Passkey);
97+
}
9398
return preferredSignInStrategy === PREFERRED_SIGN_IN_STRATEGIES.Password
9499
? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier)
95100
: determineStrategyWhenOTPIsPreferred(firstFactors, identifier);
@@ -103,7 +108,8 @@ export function determineSalutation(signIn: Partial<SignInResource>): string {
103108
return titleize(signIn.userData?.firstName) || titleize(signIn.userData?.lastName) || signIn?.identifier || '';
104109
}
105110

106-
const localStrategies: SignInStrategy[] = ['email_code', 'password', 'phone_code', 'email_link'];
111+
// @ts-ignore
112+
const localStrategies: SignInStrategy[] = ['passkey', 'email_code', 'password', 'phone_code', 'email_link'];
107113

108114
export function factorHasLocalStrategy(factor: SignInFactor | undefined | null): boolean {
109115
if (!factor) {

‎packages/clerk-js/src/ui/customizables/elementDescriptors.ts

+2
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
172172
'identityPreviewEditButton',
173173
'identityPreviewEditButtonIcon',
174174

175+
'passkeyIcon',
176+
175177
'accountSwitcherActionButton',
176178
'accountSwitcherActionButtonIconBox',
177179
'accountSwitcherActionButtonIcon',
Loading

‎packages/clerk-js/src/ui/icons/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ export { default as CaretLeft } from './caret-left.svg';
6060
export { default as CaretRight } from './caret-right.svg';
6161
export { default as Organization } from './organization.svg';
6262
export { default as Users } from './users.svg';
63+
export { default as Fingerprint } from './fingerprint.svg';

‎packages/clerk-js/src/ui/utils/factorSorting.ts

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const makeSortingOrderMap = <T extends string>(arr: T[]): Record<T, number> =>
77
}, {} as Record<T, number>);
88

99
const STRATEGY_SORT_ORDER_PASSWORD_PREF = makeSortingOrderMap([
10+
'passkey',
1011
'password',
1112
'email_link',
1213
'email_code',
@@ -17,13 +18,15 @@ const STRATEGY_SORT_ORDER_OTP_PREF = makeSortingOrderMap([
1718
'email_link',
1819
'email_code',
1920
'phone_code',
21+
'passkey',
2022
'password',
2123
] as SignInStrategy[]);
2224

2325
const STRATEGY_SORT_ORDER_ALL_STRATEGIES_BUTTONS = makeSortingOrderMap([
2426
'email_link',
2527
'email_code',
2628
'phone_code',
29+
'passkey',
2730
'password',
2831
] as SignInStrategy[]);
2932

‎packages/clerk-js/src/ui/utils/test/fixtures.ts

+6
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ const createBaseUserSettings = (): UserSettingsJSON => {
169169
min_zxcvbn_strength: 0,
170170
} as UserSettingsJSON['password_settings'];
171171

172+
const passkeySettingsConfig = {
173+
allow_autofill: false,
174+
show_sign_in_button: false,
175+
} as UserSettingsJSON['passkey_settings'];
176+
172177
return {
173178
attributes: { ...attributeConfig },
174179
actions: { delete_self: false, create_organization: false },
@@ -193,6 +198,7 @@ const createBaseUserSettings = (): UserSettingsJSON => {
193198
},
194199
},
195200
password_settings: passwordSettingsConfig,
201+
passkey_settings: passkeySettingsConfig,
196202
};
197203
};
198204

‎packages/clerk-js/src/utils/passkeys.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type WebAuthnGetCredentialReturn =
2626

2727
type ClerkWebAuthnErrorCode =
2828
| 'passkey_exists'
29+
| 'passkey_retrieval_aborted'
30+
| 'passkey_retrieval_cancelled'
2931
| 'passkey_registration_cancelled'
3032
| 'passkey_credential_create_failed'
3133
| 'passkey_credential_get_failed';
@@ -102,6 +104,33 @@ async function webAuthnCreateCredential(
102104
}
103105
}
104106

107+
class WebAuthnAbortService {
108+
private controller: AbortController | undefined;
109+
110+
private __abort() {
111+
if (!this.controller) {
112+
return;
113+
}
114+
const abortError = new Error();
115+
abortError.name = 'AbortError';
116+
this.controller.abort(abortError);
117+
}
118+
119+
createAbortSignal() {
120+
this.__abort();
121+
const newController = new AbortController();
122+
this.controller = newController;
123+
return newController.signal;
124+
}
125+
126+
abort() {
127+
this.__abort();
128+
this.controller = undefined;
129+
}
130+
}
131+
132+
const __internal_WebAuthnAbortService = new WebAuthnAbortService();
133+
105134
async function webAuthnGetCredential({
106135
publicKeyOptions,
107136
conditionalUI,
@@ -114,6 +143,7 @@ async function webAuthnGetCredential({
114143
const credential = (await navigator.credentials.get({
115144
publicKey: publicKeyOptions,
116145
mediation: conditionalUI ? 'conditional' : 'optional',
146+
signal: __internal_WebAuthnAbortService.createAbortSignal(),
117147
})) as __experimental_PublicKeyCredentialWithAuthenticatorAssertionResponse | null;
118148

119149
if (!credential) {
@@ -135,6 +165,7 @@ async function webAuthnGetCredential({
135165
*/
136166
function handlePublicKeyCreateError(error: Error): ClerkWebAuthnError | ClerkRuntimeError | Error {
137167
if (error.name === 'InvalidStateError') {
168+
// Note: Firefox will throw 'NotAllowedError' when passkeys exists
138169
return new ClerkWebAuthnError(error.message, { code: 'passkey_exists' });
139170
} else if (error.name === 'NotAllowedError') {
140171
return new ClerkWebAuthnError(error.message, { code: 'passkey_registration_cancelled' });
@@ -147,8 +178,12 @@ function handlePublicKeyCreateError(error: Error): ClerkWebAuthnError | ClerkRun
147178
* @param error
148179
*/
149180
function handlePublicKeyGetError(error: Error): ClerkWebAuthnError | ClerkRuntimeError | Error {
181+
if (error.name === 'AbortError') {
182+
return new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_aborted' });
183+
}
184+
150185
if (error.name === 'NotAllowedError') {
151-
return new ClerkWebAuthnError(error.message, { code: 'passkey_registration_cancelled' });
186+
return new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_cancelled' });
152187
}
153188
return error;
154189
}
@@ -252,4 +287,5 @@ export {
252287
convertJSONToPublicKeyRequestOptions,
253288
serializePublicKeyCredential,
254289
serializePublicKeyCredentialAssertion,
290+
__internal_WebAuthnAbortService,
255291
};

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

+6
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export const enUS: LocalizationResource = {
271271
blockButton__emailCode: 'Email code to {{identifier}}',
272272
blockButton__emailLink: 'Email link to {{identifier}}',
273273
blockButton__password: 'Sign in with your password',
274+
blockButton__passkey: 'Sign in with your passkey',
274275
blockButton__phoneCode: 'Send SMS code to {{identifier}}',
275276
blockButton__totp: 'Use your authenticator app',
276277
getHelp: {
@@ -346,6 +347,10 @@ export const enUS: LocalizationResource = {
346347
subtitle: 'Enter the password associated with your account',
347348
title: 'Enter your password',
348349
},
350+
passkey: {
351+
title: 'Use your passkey',
352+
subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.",
353+
},
349354
phoneCode: {
350355
formTitle: 'Verification code',
351356
resendButton: "Didn't receive a code? Resend",
@@ -371,6 +376,7 @@ export const enUS: LocalizationResource = {
371376
actionLink: 'Sign up',
372377
actionLink__use_email: 'Use email',
373378
actionLink__use_email_username: 'Use email or username',
379+
actionLink__use_passkey: 'Use passkey instead',
374380
actionLink__use_phone: 'Use phone',
375381
actionLink__use_username: 'Use username',
376382
actionText: 'Don’t have an account?',

‎packages/types/src/appearance.ts

+2
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ export type ElementsConfig = {
296296
identityPreviewEditButton: WithOptions;
297297
identityPreviewEditButtonIcon: WithOptions;
298298

299+
passkeyIcon: WithOptions<'firstFactor'>;
300+
299301
accountSwitcherActionButton: WithOptions<'addAccount' | 'signOutAll'>;
300302
accountSwitcherActionButtonIconBox: WithOptions<'addAccount' | 'signOutAll'>;
301303
accountSwitcherActionButtonIcon: WithOptions<'addAccount' | 'signOutAll'>;

‎packages/types/src/displayConfig.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DisplayThemeJSON } from './json';
22
import type { ClerkResource } from './resource';
33

4-
export type PreferredSignInStrategy = 'password' | 'otp';
4+
export type PreferredSignInStrategy = 'passkey' | 'password' | 'otp';
55

66
export interface DisplayConfigJSON {
77
object: 'display_config';

‎packages/types/src/elementIds.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export type OrganizationPreviewId =
4747
| 'organizationSwitcherListedOrganization'
4848
| 'organizationSwitcherActiveOrganization';
4949

50-
export type CardActionId = 'havingTrouble' | 'alternativeMethods' | 'signUp' | 'signIn';
50+
export type CardActionId = 'havingTrouble' | 'alternativeMethods' | 'signUp' | 'signIn' | 'usePasskey';
5151

5252
export type MenuId = 'invitation' | 'member' | ProfileSectionId;
5353
export type SelectId = 'countryCode' | 'role';

‎packages/types/src/localization.ts

+6
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,17 @@ type _LocalizationResource = {
150150
actionLink__use_phone: LocalizationValue;
151151
actionLink__use_username: LocalizationValue;
152152
actionLink__use_email_username: LocalizationValue;
153+
actionLink__use_passkey: LocalizationValue;
153154
};
154155
password: {
155156
title: LocalizationValue;
156157
subtitle: LocalizationValue;
157158
actionLink: LocalizationValue;
158159
};
160+
passkey: {
161+
title: LocalizationValue;
162+
subtitle: LocalizationValue;
163+
};
159164
forgotPasswordAlternativeMethods: {
160165
title: LocalizationValue;
161166
label__alternativeMethods: LocalizationValue;
@@ -245,6 +250,7 @@ type _LocalizationResource = {
245250
blockButton__emailCode: LocalizationValue;
246251
blockButton__phoneCode: LocalizationValue;
247252
blockButton__password: LocalizationValue;
253+
blockButton__passkey: LocalizationValue;
248254
blockButton__totp: LocalizationValue;
249255
blockButton__backupCode: LocalizationValue;
250256
getHelp: {

‎packages/types/src/signIn.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export interface SignInResource extends ClerkResource {
9090

9191
authenticateWithMetamask: () => Promise<SignInResource>;
9292

93-
__experimental_authenticateWithPasskey: () => Promise<SignInResource>;
93+
__experimental_authenticateWithPasskey: (params?: { flow?: 'autofill' | 'discoverable' }) => Promise<SignInResource>;
9494

9595
createEmailLinkFlow: () => CreateEmailLinkFlowReturn<SignInStartEmailLinkFlowParams, SignInResource>;
9696

‎packages/types/src/userSettings.ts

+7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export type PasswordSettingsData = {
6464
min_zxcvbn_strength: number;
6565
};
6666

67+
export type PasskeySettingsData = {
68+
allow_autofill: boolean;
69+
show_sign_in_button: boolean;
70+
};
71+
6772
export type OAuthProviders = {
6873
[provider in OAuthStrategy]: OAuthProviderSettings;
6974
};
@@ -97,6 +102,7 @@ export interface UserSettingsJSON extends ClerkResourceJSON {
97102
sign_in: SignInData;
98103
sign_up: SignUpData;
99104
password_settings: PasswordSettingsData;
105+
passkey_settings: PasskeySettingsData;
100106
}
101107

102108
export interface UserSettingsResource extends ClerkResource {
@@ -110,6 +116,7 @@ export interface UserSettingsResource extends ClerkResource {
110116
signIn: SignInData;
111117
signUp: SignUpData;
112118
passwordSettings: PasswordSettingsData;
119+
passkeySettings: PasskeySettingsData;
113120
socialProviderStrategies: OAuthStrategy[];
114121
authenticatableSocialStrategies: OAuthStrategy[];
115122
web3FirstFactors: Web3Strategy[];

0 commit comments

Comments
 (0)
Please sign in to comment.