From c5e76d62f5426d86b44db92913b16a00b4daf61b Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Thu, 27 Feb 2025 14:41:48 +0200 Subject: [PATCH 1/9] feat(clerk-js,localization,types): Support passkeys for first factor re-verification --- .../clerk-js/src/core/resources/Session.ts | 80 ++++++++++++- .../UserVerification/AlternativeMethods.tsx | 6 +- .../UVFactorOnePasskeysCard.tsx | 105 ++++++++++++++++++ .../UserVerificationFactorOne.tsx | 14 ++- packages/localizations/src/en-US.ts | 5 + packages/types/src/localization.ts | 5 + packages/types/src/session.ts | 18 ++- packages/types/src/sessionVerification.ts | 11 +- 8 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/UserVerification/UVFactorOnePasskeysCard.tsx diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea7739f8bee..d63c27ff2db 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -14,6 +14,7 @@ import type { SessionStatus, SessionTask, SessionVerificationJSON, + SessionVerificationLevel, SessionVerificationResource, SessionVerifyAttemptFirstFactorParams, SessionVerifyAttemptSecondFactorParams, @@ -25,7 +26,12 @@ import type { } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; -import { clerkInvalidStrategy } from '../errors'; +import { + convertJSONToPublicKeyRequestOptions, + serializePublicKeyCredentialAssertion, + webAuthnGetCredential, +} from '../../utils/passkeys'; +import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; @@ -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); } @@ -171,17 +180,69 @@ export class Session extends BaseResource implements SessionResource { attemptFirstFactorVerification = async ( attemptFactor: SessionVerifyAttemptFirstFactorParams, ): Promise => { + 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); }; + attemptFirstFactorPasskeyVerification = async (nonce: string | null): Promise => { + return this.#attemptPasskeyVerification(nonce, 'first_factor'); + }; + + attemptSecondFactorPasskeyVerification = async (nonce: string | null): Promise => { + return this.#attemptPasskeyVerification(nonce, 'second_factor'); + }; + + #attemptPasskeyVerification = async ( + nonce: string | null, + level: Omit, + ): Promise => { + const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; + + if (!publicKeyOptions) { + clerkMissingWebAuthnPublicKeyOptions('get'); + } + + // Invoke the navigator.create.get() method. + const { publicKeyCredential, error } = await webAuthnGetCredential({ + publicKeyOptions, + conditionalUI: false, + }); + + if (!publicKeyCredential) { + throw error; + } + + if (level === 'first_factor') { + return this.attemptFirstFactorVerification({ + strategy: 'passkey', + publicKeyCredential, + }); + } + + return this.attemptSecondFactorVerification({ + strategy: 'passkey', + publicKeyCredential, + }); + }; + prepareSecondFactorVerification = async ( params: SessionVerifyPrepareSecondFactorParams, ): Promise => { @@ -197,13 +258,24 @@ export class Session extends BaseResource implements SessionResource { }; attemptSecondFactorVerification = async ( - params: SessionVerifyAttemptSecondFactorParams, + attemptFactor: SessionVerifyAttemptSecondFactorParams, ): Promise => { + 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_second_factor`, - body: params as any, + body: { ...config, strategy: attemptFactor.strategy } as any, }) )?.response as unknown as SessionVerificationJSON; diff --git a/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx b/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx index 7bf5bf65a75..d86534994c7 100644 --- a/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx +++ b/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx @@ -5,7 +5,8 @@ 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 { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; +import { ChatAltIcon, Email, Fingerprint, LockClosedIcon } from '../../icons'; import { formatSafeIdentifier } from '../../utils'; import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies'; import { useUserVerificationSession } from './useUserVerificationSession'; @@ -111,6 +112,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}"`); } @@ -121,6 +124,7 @@ export function getButtonIcon(factor: SessionVerificationFirstFactor) { email_code: Email, phone_code: ChatAltIcon, password: LockClosedIcon, + passkey: Fingerprint, } as const; return icons[factor.strategy]; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorOnePasskeysCard.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOnePasskeysCard.tsx new file mode 100644 index 00000000000..1b23816be58 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOnePasskeysCard.tsx @@ -0,0 +1,105 @@ +import { ClerkWebAuthnError } from '@clerk/shared/error'; +import { useClerk, useSession } from '@clerk/shared/react'; +import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; +import type { SessionVerificationResource } from '@clerk/types'; +import React, { useEffect } 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(); + const { handleVerificationResponse } = useAfterVerification(); + // @ts-expect-error - This is not a public API + const { __internal_isWebAuthnSupported } = useClerk(); + const [prepareResponse, setPrepareResponse] = React.useState(null); + + const card = useCardState(); + + const isWebAuthnSupported = __internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow; + + const prepare = async () => { + // TODO: This should be moved when checking if we will show the strategy + if (!isWebAuthnSupported()) { + throw new ClerkWebAuthnError('Passkeys are not supported', { + code: 'passkey_not_supported', + }); + } + + const response = await session?.prepareFirstFactorVerification({ strategy: 'passkey' }); + + if (response) { + setPrepareResponse(response); + } + }; + + useEffect(() => { + prepare().catch(err => handleError(err, [], card.setError)); + }, []); + + const handlePasskeysAttempt = () => { + if (!prepareResponse) { + return; + } + + const { nonce = null } = prepareResponse.firstFactorVerification; + + if (!nonce) { + return; + } + + session! + .attemptFirstFactorPasskeyVerification(nonce) + .then(handleVerificationResponse) + .catch(err => handleError(err, [], card.setError)); + + return; + }; + + return ( + + + + + + + {card.error} + + + +