Skip to content
7 changes: 7 additions & 0 deletions .changeset/purple-hounds-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/clerk-js": minor
"@clerk/localizations": minor
"@clerk/types": minor
---

Support passkeys as a first factor strategy for reverification
70 changes: 65 additions & 5 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { is4xxError } from '@clerk/shared/error';
import { ClerkWebAuthnError, is4xxError } from '@clerk/shared/error';
import { retry } from '@clerk/shared/retry';
import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn';
import type {
ActJWTClaim,
CheckAuthorization,
Expand All @@ -25,7 +26,12 @@ import type {
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { clerkInvalidStrategy } from '../errors';
import {
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredentialAssertion,
webAuthnGetCredential as webAuthnGetCredentialOnWindow,
} from '../../utils/passkeys';
import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors';
import { eventBus, events } from '../events';
import { SessionTokenCache } from '../tokenCache';
import { BaseResource, PublicUserData, Token, User } from './internal';
Expand Down Expand Up @@ -150,6 +156,9 @@ export class Session extends BaseResource implements SessionResource {
default: factor.default,
} as PhoneCodeConfig;
break;
case 'passkey':
config = {};
break;
default:
clerkInvalidStrategy('Session.prepareFirstFactorVerification', (factor as any).strategy);
}
Expand All @@ -171,17 +180,68 @@ export class Session extends BaseResource implements SessionResource {
attemptFirstFactorVerification = async (
attemptFactor: SessionVerifyAttemptFirstFactorParams,
): Promise<SessionVerificationResource> => {
let config;
switch (attemptFactor.strategy) {
case 'passkey': {
config = {
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)),
};
break;
}
default:
config = { ...attemptFactor };
}

const json = (
await BaseResource._fetch({
method: 'POST',
path: `/client/sessions/${this.id}/verify/attempt_first_factor`,
body: { ...attemptFactor, strategy: attemptFactor.strategy } as any,
body: { ...config, strategy: attemptFactor.strategy } as any,
})
)?.response as unknown as SessionVerificationJSON;

return new SessionVerification(json);
};

verifyWithPasskey = async (): Promise<SessionVerificationResource> => {
const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' });

const { nonce = null } = prepareResponse.firstFactorVerification;

/**
* 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.
*/
const isWebAuthnSupported = Session.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow;
const webAuthnGetCredential = Session.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow;

if (!isWebAuthnSupported()) {
throw new ClerkWebAuthnError('Passkeys are not supported', {
code: 'passkey_not_supported',
});
}

const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;

if (!publicKeyOptions) {
clerkMissingWebAuthnPublicKeyOptions('get');
}

const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: false,
});

if (!publicKeyCredential) {
throw error;
}

return this.attemptFirstFactorVerification({
strategy: 'passkey',
publicKeyCredential,
});
};

prepareSecondFactorVerification = async (
params: SessionVerifyPrepareSecondFactorParams,
): Promise<SessionVerificationResource> => {
Expand All @@ -197,13 +257,13 @@ export class Session extends BaseResource implements SessionResource {
};

attemptSecondFactorVerification = async (
params: SessionVerifyAttemptSecondFactorParams,
attemptFactor: SessionVerifyAttemptSecondFactorParams,
): Promise<SessionVerificationResource> => {
const json = (
await BaseResource._fetch({
method: 'POST',
path: `/client/sessions/${this.id}/verify/attempt_second_factor`,
body: params as any,
body: attemptFactor as any,
})
)?.response as unknown as SessionVerificationJSON;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { LocalizationKey } from '../../customizables';
import { Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables';
import { ArrowBlockButton, BackLink, Card, Header } from '../../elements';
import { useCardState } from '../../elements/contexts';
import { ChatAltIcon, Email, LockClosedIcon } from '../../icons';
import { ChatAltIcon, Email, Fingerprint, LockClosedIcon } from '../../icons';
import { formatSafeIdentifier } from '../../utils';
import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies';
import { useUserVerificationSession } from './useUserVerificationSession';
Expand Down Expand Up @@ -111,6 +111,8 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza
});
case 'password':
return localizationKeys('reverification.alternativeMethods.blockButton__password');
case 'passkey':
return localizationKeys('reverification.alternativeMethods.blockButton__passkey');
default:
throw new Error(`Invalid sign in strategy: "${(factor as any).strategy}"`);
}
Expand All @@ -121,6 +123,7 @@ export function getButtonIcon(factor: SessionVerificationFirstFactor) {
email_code: Email,
phone_code: ChatAltIcon,
password: LockClosedIcon,
passkey: Fingerprint,
} as const;

return icons[factor.strategy];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useClerk, useSession } from '@clerk/shared/react';
import React from 'react';

import { Button, Col, descriptors, localizationKeys } from '../../customizables';
import { Card, Form, Header, useCardState } from '../../elements';
import { handleError } from '../../utils';
import { useAfterVerification } from './use-after-verification';

type UVFactorOnePasskeysCard = {
onShowAlternativeMethodsClicked?: React.MouseEventHandler;
};

export const UVFactorOnePasskeysCard = (props: UVFactorOnePasskeysCard) => {
const { onShowAlternativeMethodsClicked } = props;
const { session } = useSession();
// @ts-expect-error - This is not a public API
const { __internal_isWebAuthnSupported } = useClerk();
const { handleVerificationResponse } = useAfterVerification();

const card = useCardState();

const handlePasskeysAttempt = () => {
session
?.verifyWithPasskey()
.then(response => {
return handleVerificationResponse(response);
})
.catch(err => handleError(err, [], card.setError));

return;
};

return (
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={localizationKeys('reverification.passkey.title')} />
<Header.Subtitle localizationKey={localizationKeys('reverification.passkey.subtitle')} />
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
<Col
elementDescriptor={descriptors.main}
gap={8}
>
<Form.Root>
<Col gap={3}>
<Button
type='button'
onClick={e => {
e.preventDefault();
handlePasskeysAttempt();
}}
localizationKey={localizationKeys('reverification.passkey.blockButton__passkey')}
hasArrow
/>
<Card.Action elementId='alternativeMethods'>
{onShowAlternativeMethodsClicked && (
<Card.ActionLink
localizationKey={localizationKeys('footerActionLink__useAnotherMethod')}
onClick={onShowAlternativeMethodsClicked}
/>
)}
</Card.Action>
</Col>
</Form.Root>
</Col>
</Card.Content>

<Card.Footer />
</Card.Root>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useReverificationAlternativeStrategies } from './useReverificationAlter
import { UserVerificationFactorOnePasswordCard } from './UserVerificationFactorOnePassword';
import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession';
import { UVFactorOneEmailCodeCard } from './UVFactorOneEmailCodeCard';
import { UVFactorOnePasskeysCard } from './UVFactorOnePasskeysCard';
import { UVFactorOnePhoneCodeCard } from './UVFactorOnePhoneCodeCard';

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

export function _UserVerificationFactorOne(): JSX.Element | null {
export function UserVerificationFactorOneInternal(): JSX.Element | null {
const { data } = useUserVerificationSession();
const card = useCardState();
const { navigate } = useRouter();
Expand Down Expand Up @@ -55,7 +56,12 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const toggleAllStrategies = hasAnyStrategy ? () => setShowAllStrategies(s => !s) : undefined;
const toggleAllStrategies = hasAnyStrategy
? () => {
card.setError(undefined);
setShowAllStrategies(s => !s);
}
: undefined;

const handleFactorPrepare = () => {
lastPreparedFactorKeyRef.current = factorKey(currentFactor);
Expand Down Expand Up @@ -129,11 +135,13 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
showAlternativeMethods={hasFirstParty}
/>
);
case 'passkey':
return <UVFactorOnePasskeysCard onShowAlternativeMethodsClicked={toggleAllStrategies} />;
default:
return <LoadingCard />;
}
}

export const UserVerificationFactorOne = withUserVerificationSessionGuard(
withCardStateProvider(_UserVerificationFactorOne),
withCardStateProvider(UserVerificationFactorOneInternal),
);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isWebAuthnSupported } from '@clerk/shared/webauthn';
import type { SignInFactor, SignInFirstFactor, SignInSecondFactor } from '@clerk/types';
import { useMemo } from 'react';

Expand Down
7 changes: 7 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export const enUS: LocalizationResource = {
actionLink: 'Get help',
actionText: 'Don’t have any of these?',
blockButton__backupCode: 'Use a backup code',
blockButton__passkey: 'Use your passkey',
blockButton__emailCode: 'Email code to {{identifier}}',
blockButton__password: 'Continue with your password',
blockButton__phoneCode: 'Send SMS code to {{identifier}}',
Expand All @@ -282,6 +283,12 @@ export const enUS: LocalizationResource = {
subtitle: 'Enter the backup code you received when setting up two-step authentication',
title: 'Enter a backup code',
},
passkey: {
subtitle:
'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
title: 'Use your passkey',
blockButton__passkey: 'Use your passkey',
},
emailCode: {
formTitle: 'Verification code',
resendButton: "Didn't receive a code? Resend",
Expand Down
6 changes: 6 additions & 0 deletions packages/types/src/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,11 @@ type _LocalizationResource = {
title: LocalizationValue;
subtitle: LocalizationValue;
};
passkey: {
title: LocalizationValue;
subtitle: LocalizationValue;
blockButton__passkey: LocalizationValue;
};
alternativeMethods: {
title: LocalizationValue;
subtitle: LocalizationValue;
Expand All @@ -355,6 +360,7 @@ type _LocalizationResource = {
blockButton__phoneCode: LocalizationValue;
blockButton__password: LocalizationValue;
blockButton__totp: LocalizationValue;
blockButton__passkey: LocalizationValue;
blockButton__backupCode: LocalizationValue;
getHelp: {
title: LocalizationValue;
Expand Down
11 changes: 9 additions & 2 deletions packages/types/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
BackupCodeAttempt,
EmailCodeAttempt,
EmailCodeConfig,
PasskeyAttempt,
PassKeyConfig,
PasswordAttempt,
PhoneCodeAttempt,
PhoneCodeConfig,
Expand Down Expand Up @@ -152,6 +154,7 @@ export interface SessionResource extends ClerkResource {
attemptSecondFactorVerification: (
params: SessionVerifyAttemptSecondFactorParams,
) => Promise<SessionVerificationResource>;
verifyWithPasskey: () => Promise<SessionVerificationResource>;
__internal_toSnapshot: () => SessionJSONSnapshot;
}

Expand Down Expand Up @@ -236,8 +239,12 @@ export type SessionVerifyCreateParams = {
level: SessionVerificationLevel;
};

export type SessionVerifyPrepareFirstFactorParams = EmailCodeConfig | PhoneCodeConfig;
export type SessionVerifyAttemptFirstFactorParams = EmailCodeAttempt | PhoneCodeAttempt | PasswordAttempt;
export type SessionVerifyPrepareFirstFactorParams = EmailCodeConfig | PhoneCodeConfig | PassKeyConfig;
export type SessionVerifyAttemptFirstFactorParams =
| EmailCodeAttempt
| PhoneCodeAttempt
| PasswordAttempt
| PasskeyAttempt;

export type SessionVerifyPrepareSecondFactorParams = PhoneCodeSecondFactorConfig;
export type SessionVerifyAttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt;
11 changes: 9 additions & 2 deletions packages/types/src/sessionVerification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { BackupCodeFactor, EmailCodeFactor, PasswordFactor, PhoneCodeFactor, TOTPFactor } from './factors';
import type {
BackupCodeFactor,
EmailCodeFactor,
PasskeyFactor,
PasswordFactor,
PhoneCodeFactor,
TOTPFactor,
} from './factors';
import type { ClerkResource } from './resource';
import type { SessionResource } from './session';
import type { VerificationResource } from './verification';
Expand Down Expand Up @@ -27,5 +34,5 @@ export type ReverificationConfig =
export type SessionVerificationLevel = 'first_factor' | 'second_factor' | 'multi_factor';
export type SessionVerificationAfterMinutes = number;

export type SessionVerificationFirstFactor = EmailCodeFactor | PhoneCodeFactor | PasswordFactor;
export type SessionVerificationFirstFactor = EmailCodeFactor | PhoneCodeFactor | PasswordFactor | PasskeyFactor;
export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor;
Loading