Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/weak-adults-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Allow users to authenticate with passkeys via the `<SignIn/>`.
41 changes: 27 additions & 14 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

public __experimental_authenticateWithPasskey = async (): Promise<SignInResource> => {
public __experimental_authenticateWithPasskey = async (params?: {
flow?: 'autofill' | 'discoverable';
}): Promise<SignInResource> => {
const { flow } = params || {};

/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
* As a precaution we need to check if WebAuthn is supported.
Expand All @@ -272,24 +276,23 @@ export class SignIn extends BaseResource implements SignInResource {
});
}

if (!this.firstFactorVerification.nonce) {
if (flow === 'autofill' || flow === 'discoverable') {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.create({ strategy: 'passkey' });
}

// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
const passKeyFactor = this.supportedFirstFactors.find(
} else {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
f => f.strategy === 'passkey',
) as __experimental_PasskeyFactor;
const passKeyFactor = this.supportedFirstFactors.find(
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
f => f.strategy === 'passkey',
) as __experimental_PasskeyFactor;

if (!passKeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
if (!passKeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
}
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.prepareFirstFactor(passKeyFactor);
}

// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.prepareFirstFactor(passKeyFactor);

const { nonce } = this.firstFactorVerification;
const publicKey = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;

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

let canUseConditionalUI = false;

if (flow === 'autofill') {
/**
* If autofill is not supported gracefully handle the result, we don't need to throw.
* The caller should always check this before calling this method.
*/
canUseConditionalUI = await isWebAuthnAutofillSupported();
}

// Invoke the WebAuthn get() method.
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions: publicKey,
conditionalUI: await isWebAuthnAutofillSupported(),
conditionalUI: canUseConditionalUI,
});

if (!publicKeyCredential) {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Attributes,
OAuthProviders,
OAuthStrategy,
PasskeySettingsData,
PasswordSettingsData,
SamlSettings,
SignInData,
Expand Down Expand Up @@ -35,6 +36,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
signIn!: SignInData;
signUp!: SignUpData;
passwordSettings!: PasswordSettingsData;
passkeySettings!: PasskeySettingsData;

socialProviderStrategies: OAuthStrategy[] = [];
authenticatableSocialStrategies: OAuthStrategy[] = [];
Expand Down Expand Up @@ -79,6 +81,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
? defaultMaxPasswordLength
: Math.min(data?.password_settings?.max_length, defaultMaxPasswordLength),
};
this.passkeySettings = data.passkey_settings;
this.socialProviderStrategies = this.getSocialProviderStrategies(data.social);
this.authenticatableSocialStrategies = this.getAuthenticatableSocialStrategies(data.social);
this.web3FirstFactors = this.getWeb3FirstFactors(this.attributes);
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const getIdentifierControlDisplayValues = (
};

export const PREFERRED_SIGN_IN_STRATEGIES = Object.freeze({
Passkey: 'passkey',
Password: 'password',
OTP: 'otp',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button, Col, descriptors, Flex, Flow, localizationKeys } from '../../cu
import { ArrowBlockButton, BackLink, Card, Divider, Header } from '../../elements';
import { useCardState } from '../../elements/contexts';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { ChatAltIcon, Email, LinkIcon, LockClosedIcon, RequestAuthIcon } from '../../icons';
import { ChatAltIcon, Email, Fingerprint, LinkIcon, LockClosedIcon, RequestAuthIcon } from '../../icons';
import { formatSafeIdentifier } from '../../utils';
import { SignInSocialButtons } from './SignInSocialButtons';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand Down Expand Up @@ -136,6 +136,9 @@ export function getButtonLabel(factor: SignInFactor): LocalizationKey {
});
case 'password':
return localizationKeys('signIn.alternativeMethods.blockButton__password');
// @ts-ignore
case 'passkey':
return localizationKeys('signIn.alternativeMethods.blockButton__passkey');
case 'reset_password_email_code':
return localizationKeys('signIn.forgotPasswordAlternativeMethods.blockButton__resetPassword');
case 'reset_password_phone_code':
Expand All @@ -153,6 +156,7 @@ export function getButtonIcon(factor: SignInFactor) {
reset_password_email_code: RequestAuthIcon,
reset_password_phone_code: RequestAuthIcon,
password: LockClosedIcon,
passkey: Fingerprint,
} as const;

return icons[factor.strategy as keyof typeof icons];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AlternativeMethods } from './AlternativeMethods';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand Down Expand Up @@ -112,6 +113,14 @@ export function _SignInFactorOne(): JSX.Element {
}

switch (currentFactor?.strategy) {
// @ts-ignore
case 'passkey':
return (
<SignInFactorOnePasskey
onFactorPrepare={handleFactorPrepare}
onShowAlternativeMethodsClick={toggleAllStrategies}
/>
);
case 'password':
return (
<SignInFactorOnePasswordCard
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';

import { useCoreSignIn } from '../../contexts';
import { descriptors, Flex, Flow, Icon, localizationKeys } from '../../customizables';
import { Card, Form, Header, IdentityPreview, useCardState } from '../../elements';
import { Fingerprint } from '../../icons';
import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useHandleAuthenticateWithPasskey } from './shared';

type SignInFactorOnePasswordProps = {
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onFactorPrepare: (f: ResetPasswordCodeFactor) => void;
};

export const SignInFactorOnePasskey = (props: SignInFactorOnePasswordProps) => {
const { onShowAlternativeMethodsClick } = props;
const card = useCardState();
const signIn = useCoreSignIn();
const { navigate } = useRouter();
const [showHavingTrouble, setShowHavingTrouble] = React.useState(false);
const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]);
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();

const goBack = () => {
return navigate('../');
};

const handleSubmit: React.FormEventHandler = e => {
e.preventDefault();
return authenticateWithPasskey();
};

if (showHavingTrouble) {
return <HavingTrouble onBackLinkClick={toggleHavingTrouble} />;
}

return (
<Flow.Part part='password'>
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Icon
elementDescriptor={descriptors.passkeyIcon}
icon={Fingerprint}
sx={t => ({
color: t.colors.$neutralAlpha500,
marginInline: 'auto',
paddingBottom: t.sizes.$1,
width: t.sizes.$12,
height: t.sizes.$12,
})}
Comment on lines +44 to +53
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@desiprisg @anagstef Please let me know if we have new patterns that this code should align with.

In the designs this icon have a size of 48x48 which is not covered by the current Icon implementation and just adding an xl variant does not feel right as the lg represents a 20x20 so the difference would be quite large between them.

We may add a new Header.Icon component, but as long as this is used in only one place do we need them to ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the Header.Icon will make sense in order to have a proper descriptor instead of the passkeyIcon descriptor. WDYT ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's ok to keep the width and height values.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say we don't add a Header.Icon yet. Let's keep the Icon as is for now. :)

/>
<Header.Title localizationKey={localizationKeys('signIn.passkey.title')} />
<Header.Subtitle localizationKey={localizationKeys('signIn.passkey.subtitle')} />
<IdentityPreview
identifier={signIn.identifier}
avatarUrl={signIn.userData.imageUrl}
onClick={goBack}
/>
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
<Flex
direction='col'
elementDescriptor={descriptors.main}
gap={4}
>
<Form.Root
onSubmit={handleSubmit}
gap={8}
>
<Form.SubmitButton hasArrow />
</Form.Root>
<Card.Action elementId={onShowAlternativeMethodsClick ? 'alternativeMethods' : 'havingTrouble'}>
<Card.ActionLink
localizationKey={localizationKeys(
onShowAlternativeMethodsClick
? 'footerActionLink__useAnotherMethod'
: 'signIn.alternativeMethods.actionLink',
)}
onClick={onShowAlternativeMethodsClick || toggleHavingTrouble}
/>
</Card.Action>
</Flex>
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
46 changes: 45 additions & 1 deletion packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ERROR_CODES } from '../../../core/constants';
import { clerkInvalidFAPIResponse } from '../../../core/errors';
import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '../../../utils/passkeys';
import type { SignInStartIdentifier } from '../../common';
import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn } from '../../common';
import { buildSSOCallbackURL } from '../../common/redirects';
Expand All @@ -24,8 +25,36 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
import type { FormControlState } from '../../utils';
import { buildRequest, handleError, isMobileDevice, useFormControl } from '../../utils';
import { useHandleAuthenticateWithPasskey } from './shared';
import { SignInSocialButtons } from './SignInSocialButtons';

const useAutoFillPasskey = () => {
const [isSupported, setIsSupported] = useState(false);
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();
const { userSettings } = useEnvironment();
const { passkeySettings } = userSettings;

useEffect(() => {
async function runAutofillPasskey() {
const _isSupported = await isWebAuthnAutofillSupported();
setIsSupported(_isSupported);
if (!_isSupported) {
return;
}

await authenticateWithPasskey({ flow: 'autofill' });
}

if (passkeySettings.allow_autofill) {
runAutofillPasskey();
}
}, []);

return {
isWebAuthnAutofillSupported: isSupported,
};
};

export function _SignInStart(): JSX.Element {
const card = useCardState();
const clerk = useClerk();
Expand All @@ -41,6 +70,13 @@ export function _SignInStart(): JSX.Element {
[userSettings.enabledFirstFactorIdentifiers],
);

/**
* Passkeys
*/
const { isWebAuthnAutofillSupported } = useAutoFillPasskey();
const authenticateWithPasskey = useHandleAuthenticateWithPasskey();
const isWebSupported = isWebAuthnSupported();

const onlyPhoneNumberInitialValueExists =
!!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username);
const shouldStartWithPhoneNumberIdentifier =
Expand Down Expand Up @@ -318,6 +354,7 @@ export function _SignInStart(): JSX.Element {
onActionClicked={switchToNextIdentifier}
{...identifierFieldProps}
autoFocus={shouldAutofocus}
autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
Expand All @@ -326,9 +363,16 @@ export function _SignInStart(): JSX.Element {
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{userSettings.passkeySettings.show_sign_in_button && isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
</Card.Content>

<Card.Footer>
<Card.Action elementId='signIn'>
<Card.ActionText localizationKey={localizationKeys('signIn.start.actionText')} />
Expand Down
52 changes: 52 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isClerkRuntimeError, isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import { useCallback, useEffect } from 'react';

import { clerkInvalidFAPIResponse } from '../../../core/errors';
import { __internal_WebAuthnAbortService } from '../../../utils/passkeys';
import { useCoreSignIn, useSignInContext } from '../../contexts';
import { useCardState } from '../../elements';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
import { handleError } from '../../utils';

function useHandleAuthenticateWithPasskey() {
const card = useCardState();
const { setActive } = useClerk();
const { navigate } = useRouter();
const supportEmail = useSupportEmail();
const { navigateAfterSignIn } = useSignInContext();
const { __experimental_authenticateWithPasskey } = useCoreSignIn();

useEffect(() => {
return () => {
__internal_WebAuthnAbortService.abort();
};
}, []);

return useCallback(async (...args: Parameters<typeof __experimental_authenticateWithPasskey>) => {
try {
const res = await __experimental_authenticateWithPasskey(...args);
switch (res.status) {
case 'complete':
return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn });
case 'needs_second_factor':
return navigate('../factor-two');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
} catch (err) {
// In case of autofill, if retrieval of credentials is aborted just return to avoid updating state of unmounted components.
if (isClerkRuntimeError(err) && err.code === 'passkey_retrieval_aborted') {
return;
}
if (isUserLockedError(err)) {
// @ts-expect-error -- private method for the time being
return clerk.__internal_navigateWithError('..', err.errors[0]);
}
handleError(err, [], card.setError);
}
}, []);
}

export { useHandleAuthenticateWithPasskey };
Loading