diff --git a/.changeset/fuzzy-bees-doubt.md b/.changeset/fuzzy-bees-doubt.md new file mode 100644 index 00000000000..6b51d405b97 --- /dev/null +++ b/.changeset/fuzzy-bees-doubt.md @@ -0,0 +1,52 @@ +--- +'@clerk/elements': minor +--- + +Support passkeys in `` flows. + +APIs introduced: +- `` +- `` +- `` +- Detects the usage of `webauthn` to trigger passkey autofill `` + +Usage examples: +- `` + ```tsx + + + + {isLoading => (isLoading ? : 'Use passkey instead')}. + + + + ``` + +- `` + ```tsx + + + + ``` + +- `` + ```tsx + +

+ Welcome back ! +

+ + Continue with Passkey +
+ ``` + +- Passkey Autofill + ```tsx + + + Email + + + + + ``` diff --git a/.changeset/plenty-eels-pay.md b/.changeset/plenty-eels-pay.md new file mode 100644 index 00000000000..e32eff4adaf --- /dev/null +++ b/.changeset/plenty-eels-pay.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Move `isWebAuthnSupported`, `isWebAuthnAutofillSupported`, `isWebAuthnPlatformAuthenticatorSupported` to `@clerk/shared/webauthn`. diff --git a/packages/clerk-js/src/core/resources/Passkey.ts b/packages/clerk-js/src/core/resources/Passkey.ts index a2aae7bc162..3dc160ac63b 100644 --- a/packages/clerk-js/src/core/resources/Passkey.ts +++ b/packages/clerk-js/src/core/resources/Passkey.ts @@ -1,3 +1,4 @@ +import { isWebAuthnPlatformAuthenticatorSupported, isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { DeletedObjectJSON, DeletedObjectResource, @@ -9,13 +10,7 @@ import type { } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; -import { - ClerkWebAuthnError, - isWebAuthnPlatformAuthenticatorSupported, - isWebAuthnSupported, - serializePublicKeyCredential, - webAuthnCreateCredential, -} from '../../utils/passkeys'; +import { ClerkWebAuthnError, serializePublicKeyCredential, webAuthnCreateCredential } from '../../utils/passkeys'; import { clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { BaseResource, DeletedObject, PasskeyVerification } from './internal'; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 62d099bdfda..614080077d4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1,4 +1,5 @@ import { deepSnakeToCamel, Poller } from '@clerk/shared'; +import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { AttemptFirstFactorParams, AttemptSecondFactorParams, @@ -34,8 +35,6 @@ import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } import { ClerkWebAuthnError, convertJSONToPublicKeyRequestOptions, - isWebAuthnAutofillSupported, - isWebAuthnSupported, serializePublicKeyCredentialAssertion, webAuthnGetCredential, } from '../../utils/passkeys'; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 94c9c8cc225..187571b5d95 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -1,11 +1,11 @@ import { useClerk } from '@clerk/shared/react'; +import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { ClerkAPIError, SignInCreateParams } from '@clerk/types'; 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'; diff --git a/packages/clerk-js/src/ui/components/SignIn/utils.ts b/packages/clerk-js/src/ui/components/SignIn/utils.ts index 9799aa91da6..7b743908ff8 100644 --- a/packages/clerk-js/src/ui/components/SignIn/utils.ts +++ b/packages/clerk-js/src/ui/components/SignIn/utils.ts @@ -1,7 +1,7 @@ import { titleize } from '@clerk/shared'; +import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { PreferredSignInStrategy, SignInFactor, SignInResource, SignInStrategy } from '@clerk/types'; -import { isWebAuthnSupported } from '../../../utils/passkeys'; import { PREFERRED_SIGN_IN_STRATEGIES } from '../../common/constants'; import { otpPrefFactorComparator, passwordPrefFactorComparator } from '../../utils/factorSorting'; diff --git a/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts b/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts index a422f05c677..15e87769a99 100644 --- a/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts +++ b/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts @@ -1,6 +1,6 @@ +import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { SignInFactor } from '@clerk/types'; -import { isWebAuthnSupported } from '../../utils/passkeys'; import { factorHasLocalStrategy, isResetPasswordStrategy } from '../components/SignIn/utils'; import { useCoreSignIn } from '../contexts'; import { allStrategiesButtonsComparator } from '../utils'; diff --git a/packages/clerk-js/src/utils/passkeys.ts b/packages/clerk-js/src/utils/passkeys.ts index 3ad06219b65..26a3cbc5d0f 100644 --- a/packages/clerk-js/src/utils/passkeys.ts +++ b/packages/clerk-js/src/utils/passkeys.ts @@ -1,4 +1,3 @@ -import { isValidBrowser } from '@clerk/shared/browser'; import { ClerkRuntimeError } from '@clerk/shared/error'; import type { PublicKeyCredentialCreationOptionsJSON, @@ -36,33 +35,6 @@ type ClerkWebAuthnErrorCode = | 'passkey_registration_cancelled' | 'passkey_registration_failed'; -function isWebAuthnSupported() { - return ( - isValidBrowser() && - // Check if `PublicKeyCredential` is a constructor - typeof window.PublicKeyCredential === 'function' - ); -} - -async function isWebAuthnAutofillSupported(): Promise { - try { - return isWebAuthnSupported() && (await window.PublicKeyCredential.isConditionalMediationAvailable()); - } catch (e) { - return false; - } -} - -async function isWebAuthnPlatformAuthenticatorSupported(): Promise { - try { - return ( - typeof window !== 'undefined' && - (await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) - ); - } catch (e) { - return false; - } -} - class Base64Converter { static encode(buffer: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(buffer))) @@ -284,9 +256,6 @@ export class ClerkWebAuthnError extends ClerkRuntimeError { } export { - isWebAuthnPlatformAuthenticatorSupported, - isWebAuthnAutofillSupported, - isWebAuthnSupported, base64UrlToBuffer, bufferToBase64Url, handlePublicKeyCreateError, diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index dfaafd36a21..ce12219385e 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -175,6 +175,10 @@ export default function SignInPage() { Continue with Google + + {isLoading => (isLoading ? : 'Use passkey instead')} + + {continueWithEmail ? ( <> Send a code to your phone
+ + + + + +

+ Welcome back ! +

+ + Continue with Passkey +
+

Welcome back ! diff --git a/packages/elements/src/internals/machines/sign-in/router.machine.ts b/packages/elements/src/internals/machines/sign-in/router.machine.ts index fc69ed0c42a..61cdef76336 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -1,7 +1,8 @@ import { joinURL } from '@clerk/shared/url'; +import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn'; import type { SignInStatus } from '@clerk/types'; import type { NonReducibleUnknown } from 'xstate'; -import { and, assign, enqueueActions, not, or, raise, sendTo, setup } from 'xstate'; +import { and, assign, enqueueActions, fromPromise, not, or, raise, sendTo, setup } from 'xstate'; import { ERROR_CODES, @@ -48,6 +49,7 @@ export const SignInRouterMachine = setup({ startMachine: SignInStartMachine, secondFactorMachine: SignInSecondFactorMachine, thirdPartyMachine: ThirdPartyMachine, + webAuthnAutofillSupport: fromPromise(() => isWebAuthnAutofillSupported()), }, actions: { clearFormErrors: sendTo(({ context }) => context.formRef, { type: 'ERRORS.CLEAR' }), @@ -220,6 +222,13 @@ export const SignInRouterMachine = setup({ }, states: { Idle: { + invoke: { + id: 'webAuthnAutofill', + src: 'webAuthnAutofillSupport', + onDone: { + actions: assign({ webAuthnAutofillSupport: ({ event }) => event.output }), + }, + }, on: { INIT: { actions: assign(({ event }) => ({ @@ -319,6 +328,12 @@ export const SignInRouterMachine = setup({ target: 'Start', reenter: true, }, + 'AUTHENTICATE.PASSKEY': { + actions: sendTo('start', ({ event }) => event), + }, + 'AUTHENTICATE.PASSKEY.AUTOFILL': { + actions: sendTo('start', ({ event }) => event), + }, NEXT: [ { guard: 'isComplete', diff --git a/packages/elements/src/internals/machines/sign-in/router.types.ts b/packages/elements/src/internals/machines/sign-in/router.types.ts index d1ea1209eab..2f2b277a79a 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -63,6 +63,10 @@ export type SignInRouterResetStepEvent = BaseRouterResetStepEvent; export type SignInRouterLoadingEvent = BaseRouterLoadingEvent<'start' | 'verifications' | 'reset-password'>; export type SignInRouterSetClerkEvent = BaseRouterSetClerkEvent; export type SignInRouterSubmitEvent = { type: 'SUBMIT' }; +export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; +export type SignInRouterPasskeyAutofillEvent = { + type: 'AUTHENTICATE.PASSKEY.AUTOFILL'; +}; export interface SignInRouterInitEvent extends BaseRouterInput { type: 'INIT'; @@ -89,7 +93,9 @@ export type SignInRouterEvents = | SignInVerificationFactorUpdateEvent | SignInRouterLoadingEvent | SignInRouterSetClerkEvent - | SignInRouterSubmitEvent; + | SignInRouterSubmitEvent + | SignInRouterPasskeyEvent + | SignInRouterPasskeyAutofillEvent; // ---------------------------------- Context ---------------------------------- // @@ -99,6 +105,7 @@ export interface SignInRouterContext extends BaseRouterContext { formRef: ActorRefFrom; loading: SignInRouterLoadingContext; signUpPath: string; + webAuthnAutofillSupport: boolean; } // ---------------------------------- Input ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-in/start.machine.ts b/packages/elements/src/internals/machines/sign-in/start.machine.ts index 0d54def11a7..fac82a42ec7 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -15,6 +15,14 @@ export const SignInStartMachineId = 'SignInStart'; export const SignInStartMachine = setup({ actors: { + attemptPasskey: fromPromise< + SignInResource, + { parent: SignInRouterMachineActorRef; flow: 'autofill' | 'discoverable' | undefined } + >(({ input: { parent, flow } }) => { + return parent.getSnapshot().context.clerk.client.signIn.authenticateWithPasskey({ + flow, + }); + }), attempt: fromPromise( ({ input: { fields, parent } }) => { const clerk = parent.getSnapshot().context.clerk; @@ -76,6 +84,16 @@ export const SignInStartMachine = setup({ target: 'Attempting', reenter: true, }, + 'AUTHENTICATE.PASSKEY': { + guard: not('isExampleMode'), + target: 'AttemptingPasskey', + reenter: true, + }, + 'AUTHENTICATE.PASSKEY.AUTOFILL': { + guard: not('isExampleMode'), + target: 'AttemptingPasskeyAutoFill', + reenter: false, + }, }, }, Attempting: { @@ -97,5 +115,53 @@ export const SignInStartMachine = setup({ }, }, }, + AttemptingPasskey: { + tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', + invoke: { + id: 'attemptPasskey', + src: 'attemptPasskey', + input: ({ context }) => ({ + parent: context.parent, + flow: 'discoverable', + }), + onDone: { + actions: ['sendToNext', 'sendToLoading'], + }, + onError: { + actions: ['setFormErrors', 'sendToLoading'], + target: 'Pending', + }, + }, + }, + AttemptingPasskeyAutoFill: { + on: { + 'AUTHENTICATE.PASSKEY': { + guard: not('isExampleMode'), + target: 'AttemptingPasskey', + reenter: true, + }, + SUBMIT: { + guard: not('isExampleMode'), + target: 'Attempting', + reenter: true, + }, + }, + invoke: { + id: 'attemptPasskeyAutofill', + src: 'attemptPasskey', + input: ({ context }) => ({ + parent: context.parent, + flow: 'autofill', + }), + onDone: { + actions: ['sendToNext'], + }, + onError: { + actions: ['setFormErrors'], + target: 'Pending', + }, + }, + }, }, }); diff --git a/packages/elements/src/internals/machines/sign-in/start.types.ts b/packages/elements/src/internals/machines/sign-in/start.types.ts index 2ecc624e54d..7f935eb84b9 100644 --- a/packages/elements/src/internals/machines/sign-in/start.types.ts +++ b/packages/elements/src/internals/machines/sign-in/start.types.ts @@ -12,8 +12,15 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load // ---------------------------------- Events ---------------------------------- // export type SignInStartSubmitEvent = { type: 'SUBMIT' }; - -export type SignInStartEvents = ErrorActorEvent | SignInStartSubmitEvent | DoneActorEvent; +export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; +export type SignInStartPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }; + +export type SignInStartEvents = + | ErrorActorEvent + | SignInStartSubmitEvent + | SignInStartPasskeyEvent + | SignInStartPasskeyAutofillEvent + | DoneActorEvent; // ---------------------------------- Input ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-in/utils/starting-factors.ts b/packages/elements/src/internals/machines/sign-in/utils/starting-factors.ts index 52806c20769..0ee87be6375 100644 --- a/packages/elements/src/internals/machines/sign-in/utils/starting-factors.ts +++ b/packages/elements/src/internals/machines/sign-in/utils/starting-factors.ts @@ -1,9 +1,10 @@ // These utilities are ported from: packages/clerk-js/src/ui/components/SignIn/utils.ts // They should be functionally identical. +import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { PreferredSignInStrategy, SignInFactor } from '@clerk/types'; -const ORDER_WHEN_PASSWORD_PREFERRED = ['password', 'email_link', 'email_code', 'phone_code'] as const; -const ORDER_WHEN_OTP_PREFERRED = ['email_link', 'email_code', 'phone_code', 'password'] as const; +const ORDER_WHEN_PASSWORD_PREFERRED = ['passkey', 'password', 'email_link', 'email_code', 'phone_code'] as const; +const ORDER_WHEN_OTP_PREFERRED = ['email_link', 'email_code', 'phone_code', 'passkey', 'password'] as const; // const ORDER_ALL_STRATEGIES = ['email_link', 'email_code', 'phone_code', 'password'] as const; const findFactorForIdentifier = (i: string | null) => (f: SignInFactor) => { @@ -26,10 +27,26 @@ export function determineStartingSignInFactor( : determineStrategyWhenOTPIsPreferred(firstFactors, identifier); } +function findPasskeyStrategy(factors: SignInFactor[]): SignInFactor | null { + if (isWebAuthnSupported()) { + const passkeyFactor = factors.find(({ strategy }) => strategy === 'passkey'); + + if (passkeyFactor) { + return passkeyFactor; + } + } + return null; +} + function determineStrategyWhenPasswordIsPreferred( factors: SignInFactor[], identifier: string | null, ): SignInFactor | null { + const passkeyFactor = findPasskeyStrategy(factors); + if (passkeyFactor) { + return passkeyFactor; + } + // Prefer the password factor if it's available const passwordFactor = factors.find(factor => factor.strategy === 'password'); if (passwordFactor) { @@ -53,6 +70,11 @@ function determineStrategyWhenPasswordIsPreferred( } function determineStrategyWhenOTPIsPreferred(factors: SignInFactor[], identifier: string | null): SignInFactor | null { + const passkeyFactor = findPasskeyStrategy(factors); + if (passkeyFactor) { + return passkeyFactor; + } + const factorForIdentifier = factors.find(findFactorForIdentifier(identifier)); if (factorForIdentifier) { return factorForIdentifier; diff --git a/packages/elements/src/internals/machines/sign-in/verification.machine.ts b/packages/elements/src/internals/machines/sign-in/verification.machine.ts index eab8a60ccfe..e5f383f3b55 100644 --- a/packages/elements/src/internals/machines/sign-in/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/verification.machine.ts @@ -54,6 +54,14 @@ export type AttemptSecondFactorInput = { currentFactor: SignInSecondFactor | null; }; +const isNonPreperableStrategy = (strategy?: SignInFirstFactor['strategy'] | SignInSecondFactor['strategy']) => { + if (!strategy) { + return false; + } + + return ['passkey', 'password'].includes(strategy); +}; + export const SignInVerificationMachineId = 'SignInVerification'; const SignInVerificationMachine = setup({ @@ -155,7 +163,7 @@ Please open an issue if you continue to run into this issue.`); }, guards: { isResendable: ({ context }) => context.resendable || context.resendableAfter === 0, - isNeverResendable: ({ context }) => context.currentFactor?.strategy === 'password', + isNeverResendable: ({ context }) => isNonPreperableStrategy(context.currentFactor?.strategy), }, delays: SignInVerificationDelays, types: {} as SignInVerificationSchema, @@ -358,7 +366,7 @@ export const SignInFirstFactorMachine = SignInVerificationMachine.provide({ const currentVerificationExpiration = clerk.client.signIn.firstFactorVerification.expireAt; const needsPrepare = resendable || !currentVerificationExpiration || currentVerificationExpiration < new Date(); - if (!params?.strategy || params.strategy === 'password' || !needsPrepare) { + if (isNonPreperableStrategy(params?.strategy) || !needsPrepare) { return Promise.resolve(clerk.client.signIn); } @@ -378,6 +386,9 @@ export const SignInFirstFactorMachine = SignInVerificationMachine.provide({ const password = fields.get('password')?.value as string | undefined; switch (strategy) { + case 'passkey': { + return await parent.getSnapshot().context.clerk.client.signIn.authenticateWithPasskey(); + } case 'password': { assertIsDefined(password, 'Password'); diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index e67a3957005..9267b11a4b4 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -1,4 +1,5 @@ import { useClerk } from '@clerk/clerk-react'; +import { logger } from '@clerk/shared/logger'; import { eventComponentMounted } from '@clerk/shared/telemetry'; import type { Autocomplete } from '@clerk/types'; import { composeEventHandlers } from '@radix-ui/primitive'; @@ -35,6 +36,8 @@ import { useFormStore, } from '~/internals/machines/form/form.context'; import { usePassword } from '~/react/hooks/use-password.hook'; +import { SignInRouterCtx } from '~/react/sign-in/context'; +import { useSignInPasskeyAutofill } from '~/react/sign-in/context/router.context'; import type { ErrorMessagesKey } from '~/react/utils/generate-password-error-text'; import { isReactFragment } from '~/react/utils/is-react-fragment'; @@ -179,7 +182,7 @@ const useInput = ({ onFocus: onFocusProp, ...passthroughProps }: FormInputProps) => { - // Inputs can be used outside of a wrapper if desired, so safely destructure here + // Inputs can be used outside a wrapper if desired, so safely destructure here const fieldContext = useFieldContext(); const name = inputName || fieldContext?.name; const { state: fieldState } = useFieldState({ name }); @@ -509,6 +512,7 @@ const INPUT_NAME = 'ClerkElementsInput'; type PasswordInputProps = Exclude & { validatePassword?: boolean; }; + type FormInputProps = | RadixFormControlProps | ({ type: 'otp'; render: OTPInputProps['render'] } & Omit) @@ -552,6 +556,11 @@ type FormInputProps = const Input = React.forwardRef, FormInputProps>( (props: FormInputProps, forwardedRef) => { const clerk = useClerk(); + const field = useInput(props); + + const hasPasskeyAutofillProp = Boolean(field.props.autoComplete?.includes('webauthn')); + const allowedTypeForPasskey = (['text', 'email', 'tel'] as FormInputProps['type'][]).includes(field.props.type); + const signInRouterRef = SignInRouterCtx.useActorRef(true); clerk.telemetry?.record( eventComponentMounted('Elements_Input', { @@ -565,7 +574,25 @@ const Input = React.forwardRef, FormInputP }), ); - const field = useInput(props); + if (signInRouterRef && hasPasskeyAutofillProp && allowedTypeForPasskey) { + return ( + + ); + } + + if (hasPasskeyAutofillProp && !allowedTypeForPasskey) { + logger.warnOnce( + ` can only be used with or `, + ); + } else if (hasPasskeyAutofillProp) { + logger.warnOnce( + ` can only be used inside in order to trigger a sign-in attempt, otherwise it will be ignored.`, + ); + } + return ( , FormInputP Input.displayName = INPUT_NAME; +const InputWithPasskeyAutofill = React.forwardRef, FormInputProps>( + (props: FormInputProps, forwardedRef) => { + const signInRouterRef = SignInRouterCtx.useActorRef(true); + const passkeyAutofillSupported = useSignInPasskeyAutofill(); + + React.useEffect(() => { + if (passkeyAutofillSupported) { + signInRouterRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); + } + }, [passkeyAutofillSupported, signInRouterRef]); + + const field = useInput(props); + return ( + + ); + }, +); + /* ------------------------------------------------------------------------------------------------- * Label * -----------------------------------------------------------------------------------------------*/ diff --git a/packages/elements/src/react/sign-in/context/router.context.ts b/packages/elements/src/react/sign-in/context/router.context.ts index e39108a3ab0..409c7d09c0e 100644 --- a/packages/elements/src/react/sign-in/context/router.context.ts +++ b/packages/elements/src/react/sign-in/context/router.context.ts @@ -21,3 +21,6 @@ export const useSignInStartStep = () => useSignInStep('star export const useSignInFirstFactorStep = () => useSignInStep('firstFactor'); export const useSignInSecondFactorStep = () => useSignInStep('secondFactor'); export const useSignInResetPasswordStep = () => useSignInStep('resetPassword'); + +export const useSignInPasskeyAutofill = () => + SignInRouterCtx.useSelector(state => state.context.webAuthnAutofillSupport); diff --git a/packages/elements/src/react/sign-in/index.ts b/packages/elements/src/react/sign-in/index.ts index 8dc851debc6..dfc75220c28 100644 --- a/packages/elements/src/react/sign-in/index.ts +++ b/packages/elements/src/react/sign-in/index.ts @@ -4,6 +4,7 @@ import 'client-only'; export { SignInRoot as SignIn, SignInRoot as Root } from './root'; export { SignInStep as Step } from './step'; export { SignInAction as Action } from './action'; +export { SignInPasskey as Passkey } from './passkey'; export { SignInSupportedStrategy as SupportedStrategy } from './choose-strategy'; export { diff --git a/packages/elements/src/react/sign-in/passkey.tsx b/packages/elements/src/react/sign-in/passkey.tsx new file mode 100644 index 00000000000..91fec396758 --- /dev/null +++ b/packages/elements/src/react/sign-in/passkey.tsx @@ -0,0 +1,44 @@ +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; + +import { SignInRouterCtx } from '~/react/sign-in/context'; + +export type SignInPasskeyElement = React.ElementRef<'button'>; + +export type SignInPasskeyProps = { + asChild?: boolean; + children: React.ReactNode; +} & React.DetailedHTMLProps, HTMLButtonElement>; + +const SIGN_IN_PASSKEY_NAME = 'SignInPasskey'; + +/** + * Prompt users to select a passkey from their device in order to sign in. + * This component must be used within the . + * + * @example + * Use Passkey instead + */ +export const SignInPasskey = React.forwardRef( + ({ asChild, ...rest }, forwardedRef) => { + const actorRef = SignInRouterCtx.useActorRef(true); + + const Comp = asChild ? Slot : 'button'; + const defaultProps = asChild ? {} : { type: 'button' as const }; + + const sendEvent = React.useCallback(() => { + actorRef?.send({ type: 'AUTHENTICATE.PASSKEY' }); + }, [actorRef]); + + return ( + + ); + }, +); + +SignInPasskey.displayName = SIGN_IN_PASSKEY_NAME; diff --git a/packages/shared/package.json b/packages/shared/package.json index beaac5bb337..5b3b79068ad 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -69,7 +69,8 @@ "apiUrlFromPublishableKey", "scripts", "telemetry", - "logger" + "logger", + "webauthn" ], "scripts": { "build": "tsup", diff --git a/packages/shared/src/webauthn.ts b/packages/shared/src/webauthn.ts new file mode 100644 index 00000000000..adf27b6f74b --- /dev/null +++ b/packages/shared/src/webauthn.ts @@ -0,0 +1,30 @@ +import { isValidBrowser } from './browser'; + +function isWebAuthnSupported() { + return ( + isValidBrowser() && + // Check if `PublicKeyCredential` is a constructor + typeof window.PublicKeyCredential === 'function' + ); +} + +async function isWebAuthnAutofillSupported(): Promise { + try { + return isWebAuthnSupported() && (await window.PublicKeyCredential.isConditionalMediationAvailable()); + } catch (e) { + return false; + } +} + +async function isWebAuthnPlatformAuthenticatorSupported(): Promise { + try { + return ( + typeof window !== 'undefined' && + (await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) + ); + } catch (e) { + return false; + } +} + +export { isWebAuthnPlatformAuthenticatorSupported, isWebAuthnAutofillSupported, isWebAuthnSupported }; diff --git a/packages/shared/subpaths.mjs b/packages/shared/subpaths.mjs index d6d1c476526..37f18538715 100644 --- a/packages/shared/subpaths.mjs +++ b/packages/shared/subpaths.mjs @@ -25,6 +25,7 @@ export const subpathNames = [ 'apiUrlFromPublishableKey', 'telemetry', 'logger', + 'webauthn', ]; export const subpathFoldersBarrel = ['react'];