From e70b37d90d3823c58293e7b3b027bf0c5d7f9fe9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 30 May 2024 20:18:48 +0300 Subject: [PATCH 01/21] feat(elements): Add support for sign in with passkey --- .../app/sign-in/[[...sign-in]]/page.tsx | 22 ++++++++++++ .../machines/sign-in/router.machine.ts | 11 ++++++ .../machines/sign-in/router.types.ts | 4 ++- .../machines/sign-in/start.machine.ts | 34 ++++++++++++++++++- .../internals/machines/sign-in/start.types.ts | 3 +- .../sign-in/utils/starting-factors.ts | 26 ++++++++++++-- .../machines/sign-in/verification.machine.ts | 8 +++-- .../src/react/sign-in/action/action.tsx | 14 ++++++-- 8 files changed, 112 insertions(+), 10 deletions(-) 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..b9719c5d713 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,13 @@ 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 c2740b213e8..7f341122659 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -20,6 +20,7 @@ import type { SignInRouterContext, SignInRouterEvents, SignInRouterNextEvent, + SignInRouterPasskeyEvent, SignInRouterSchema, } from './router.types'; import { SignInStartMachine } from './start.machine'; @@ -307,6 +308,16 @@ export const SignInRouterMachine = setup({ target: 'Start', reenter: true, }, + 'AUTHENTICATE.PASSKEY': { + actions: sendTo( + 'start', + ({ event }) => + ({ + type: 'AUTHENTICATE.PASSKEY', + flow: event.flow, + } as SignInRouterPasskeyEvent), + ), + }, 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..80ca1101aae 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,7 @@ 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'; flow: 'autofill' | 'discoverable' | undefined }; export interface SignInRouterInitEvent extends BaseRouterInput { type: 'INIT'; @@ -89,7 +90,8 @@ export type SignInRouterEvents = | SignInVerificationFactorUpdateEvent | SignInRouterLoadingEvent | SignInRouterSetClerkEvent - | SignInRouterSubmitEvent; + | SignInRouterSubmitEvent + | SignInRouterPasskeyEvent; // ---------------------------------- Context ---------------------------------- // 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..466d7cb2d3b 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -7,7 +7,7 @@ import { sendToLoading } from '~/internals/machines/shared'; import { assertActorEventError } from '~/internals/machines/utils/assert'; import type { SignInRouterMachineActorRef } from './router.types'; -import type { SignInStartSchema } from './start.types'; +import type { SignInStartPasskeyEvent, SignInStartSchema } from './start.types'; export type TSignInStartMachine = typeof SignInStartMachine; @@ -15,6 +15,14 @@ export const SignInStartMachineId = 'SignInStart'; export const SignInStartMachine = setup({ actors: { + attemptPasskey: fromPromise< + SignInResource, + { parent: SignInRouterMachineActorRef; flow: '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,11 @@ export const SignInStartMachine = setup({ target: 'Attempting', reenter: true, }, + 'AUTHENTICATE.PASSKEY': { + guard: not('isExampleMode'), + target: 'AttemptingPasskey', + reenter: true, + }, }, }, Attempting: { @@ -97,5 +110,24 @@ export const SignInStartMachine = setup({ }, }, }, + AttemptingPasskey: { + tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', + invoke: { + id: 'attemptPasskey', + src: 'attemptPasskey', + input: ({ context, event }) => ({ + parent: context.parent, + flow: (event as SignInStartPasskeyEvent).flow, + }), + onDone: { + actions: ['sendToNext', 'sendToLoading'], + }, + onError: { + actions: ['setFormErrors', 'sendToLoading'], + 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..1d10cf101f3 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,9 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load // ---------------------------------- Events ---------------------------------- // export type SignInStartSubmitEvent = { type: 'SUBMIT' }; +export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY'; flow: 'discoverable' | undefined }; -export type SignInStartEvents = ErrorActorEvent | SignInStartSubmitEvent | DoneActorEvent; +export type SignInStartEvents = ErrorActorEvent | SignInStartSubmitEvent | SignInStartPasskeyEvent | 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..1c6f949d4af 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/clerk-js/src/utils/passkeys'; 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 4846322a9e1..939dceee2b0 100644 --- a/packages/elements/src/internals/machines/sign-in/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/verification.machine.ts @@ -145,7 +145,8 @@ const SignInVerificationMachine = setup({ }, guards: { isResendable: ({ context }) => context.resendable || context.resendableAfter === 0, - isNeverResendable: ({ context }) => context.currentFactor?.strategy === 'password', + isNeverResendable: ({ context }) => + context.currentFactor?.strategy === 'password' || context.currentFactor?.strategy === 'passkey', }, delays: SignInVerificationDelays, types: {} as SignInVerificationSchema, @@ -348,7 +349,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 (!params?.strategy || params.strategy === 'password' || params.strategy === 'passkey' || !needsPrepare) { return Promise.resolve(clerk.client.signIn); } @@ -368,6 +369,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); diff --git a/packages/elements/src/react/sign-in/action/action.tsx b/packages/elements/src/react/sign-in/action/action.tsx index da05b33bad8..825d597b16c 100644 --- a/packages/elements/src/react/sign-in/action/action.tsx +++ b/packages/elements/src/react/sign-in/action/action.tsx @@ -5,6 +5,7 @@ import { Submit } from '~/react/common'; import type { SignInNavigateElementKey, SignInNavigateProps } from './navigate'; import { SignInNavigate } from './navigate'; +import { SignInPasskey } from './passkey'; import type { SignInResendProps } from './resend'; import { SignInResend } from './resend'; @@ -14,9 +15,11 @@ export type SignInActionProps = { asChild?: boolean } & FormSubmitProps & navigate: SignInNavigateProps['to']; resend?: never; submit?: never; + passkey?: never; } & Omit) - | { navigate?: never; resend?: never; submit: true } - | ({ navigate?: never; resend: true; submit?: never } & SignInResendProps) + | { navigate?: never; resend?: never; submit: true; passkey?: never } + | { navigate?: never; resend?: never; submit?: never; passkey: true } + | ({ navigate?: never; resend: true; submit?: never; passkey?: never } & SignInResendProps) ); export type SignInActionCompProps = React.ForwardRefExoticComponent< @@ -43,9 +46,12 @@ const SIGN_IN_ACTION_NAME = 'SignInAction'; * * @example * Resend + + * @example + * Use Passkey instead */ export const SignInAction = React.forwardRef, SignInActionProps>((props, forwardedRef) => { - const { submit, navigate, resend, ...rest } = props; + const { submit, navigate, passkey, resend, ...rest } = props; let Comp: React.ForwardRefExoticComponent | undefined; if (submit) { @@ -54,6 +60,8 @@ export const SignInAction = React.forwardRef, SignInA Comp = SignInNavigate; } else if (resend) { Comp = SignInResend; + } else if (passkey) { + Comp = SignInPasskey; } return Comp ? ( From c2d7611886a860126b8005abf7c1af4463ae7d78 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 30 May 2024 21:36:01 +0300 Subject: [PATCH 02/21] feat(elements): Input field with passkey autofill --- .../machines/sign-in/router.machine.ts | 6 +++ .../machines/sign-in/router.types.ts | 7 +++- .../machines/sign-in/start.machine.ts | 29 ++++++++++++++ .../internals/machines/sign-in/start.types.ts | 11 +++++- .../elements/src/react/common/form/index.tsx | 39 ++++++++++++++++++- 5 files changed, 88 insertions(+), 4 deletions(-) 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 7f341122659..4f17196dcba 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -318,6 +318,12 @@ export const SignInRouterMachine = setup({ } as SignInRouterPasskeyEvent), ), }, + 'AUTHENTICATE.PASSKEY_AUTOFILL': { + actions: sendTo('start', () => ({ + type: 'AUTHENTICATE.PASSKEY_AUTOFILL', + flow: 'autofill', + })), + }, 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 80ca1101aae..a4a2e569cdd 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -64,6 +64,10 @@ export type SignInRouterLoadingEvent = BaseRouterLoadingEvent<'start' | 'verific export type SignInRouterSetClerkEvent = BaseRouterSetClerkEvent; export type SignInRouterSubmitEvent = { type: 'SUBMIT' }; export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY'; flow: 'autofill' | 'discoverable' | undefined }; +export type SignInRouterPasskeyAutofillEvent = { + type: 'AUTHENTICATE.PASSKEY_AUTOFILL'; + flow: 'autofill' | 'discoverable' | undefined; +}; export interface SignInRouterInitEvent extends BaseRouterInput { type: 'INIT'; @@ -91,7 +95,8 @@ export type SignInRouterEvents = | SignInRouterLoadingEvent | SignInRouterSetClerkEvent | SignInRouterSubmitEvent - | SignInRouterPasskeyEvent; + | SignInRouterPasskeyEvent + | SignInRouterPasskeyAutofillEvent; // ---------------------------------- Context ---------------------------------- // 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 466d7cb2d3b..e02a3728a09 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -89,6 +89,11 @@ export const SignInStartMachine = setup({ target: 'AttemptingPasskey', reenter: true, }, + 'AUTHENTICATE.PASSKEY_AUTOFILL': { + guard: not('isExampleMode'), + target: 'AttemptingPasskeyAutoFill', + reenter: false, + }, }, }, Attempting: { @@ -129,5 +134,29 @@ export const SignInStartMachine = setup({ }, }, }, + AttemptingPasskeyAutoFill: { + on: { + 'AUTHENTICATE.PASSKEY': { + guard: not('isExampleMode'), + target: 'AttemptingPasskey', + reenter: true, + }, + }, + invoke: { + id: 'attemptPasskeyAutofill', + src: 'attemptPasskey', + input: ({ context, event }) => ({ + parent: context.parent, + flow: (event as SignInStartPasskeyEvent).flow, + }), + 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 1d10cf101f3..9533a4b2ff1 100644 --- a/packages/elements/src/internals/machines/sign-in/start.types.ts +++ b/packages/elements/src/internals/machines/sign-in/start.types.ts @@ -13,8 +13,17 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load export type SignInStartSubmitEvent = { type: 'SUBMIT' }; export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY'; flow: 'discoverable' | undefined }; +export type SignInStartPasskeyAutofillEvent = { + type: 'AUTHENTICATE.PASSKEY_AUTOFILL'; + flow: 'discoverable' | undefined; +}; -export type SignInStartEvents = ErrorActorEvent | SignInStartSubmitEvent | SignInStartPasskeyEvent | DoneActorEvent; +export type SignInStartEvents = + | ErrorActorEvent + | SignInStartSubmitEvent + | SignInStartPasskeyEvent + | SignInStartPasskeyAutofillEvent + | DoneActorEvent; // ---------------------------------- Input ---------------------------------- // diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 8e748c7c5fa..b936d0e4bff 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -1,3 +1,4 @@ +import { isWebAuthnAutofillSupported } from '@clerk/clerk-js/src/utils/passkeys'; import { useClerk } from '@clerk/clerk-react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; import type { Autocomplete } from '@clerk/types'; @@ -35,6 +36,7 @@ import { useFormStore, } from '~/internals/machines/form/form.context'; import { usePassword } from '~/react/hooks/use-password.hook'; +import { SignInRouterCtx } from '~/react/sign-in/context'; import type { ErrorMessagesKey } from '~/react/utils/generate-password-error-text'; import { isReactFragment } from '~/react/utils/is-react-fragment'; @@ -172,7 +174,8 @@ const useInput = ({ onFocus: onFocusProp, ...passthroughProps }: FormInputProps) => { - // Inputs can be used outside of a wrapper if desired, so safely destructure here + const signInActorRef = SignInRouterCtx.useActorRef(true); + // 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 }); @@ -270,6 +273,29 @@ const useInput = ({ ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } }); }, [name, ref, initialValue]); + const [isSupported, setIsSupported] = React.useState(false); + React.useEffect(() => { + async function runAutofillPasskey() { + const _isSupported = await isWebAuthnAutofillSupported().catch(() => false); + setIsSupported(_isSupported); + } + + // @ts-expect-error - Depending on type the props can be different + if (passthroughProps?.passkeyAutofill) { + runAutofillPasskey(); + } + + // @ts-expect-error - Depending on type the props can be different + }, [passthroughProps?.passkeyAutofill]); + + React.useEffect(() => { + // @ts-expect-error - Depending on type the props can be different + if (passthroughProps?.passkeyAutofill) { + signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY_AUTOFILL', flow: 'autofill' }); + } + // @ts-expect-error - Depending on type the props can be different + }, [passthroughProps?.passkeyAutofill, signInActorRef]); + if (!name) { throw new Error('Clerk: must be wrapped in a component or have a name prop.'); } @@ -306,10 +332,19 @@ const useInput = ({ }; } + if (isSupported) { + props = { + autoComplete: 'webauthn', + }; + } + // Filter out invalid props that should not be passed through // @ts-expect-error - Doesn't know about type narrowing by type here const { validatePassword: _1, ...rest } = passthroughProps; + // @ts-expect-error - Depending on type the props can be different + delete rest['passkeyAutofill']; + return { Element, props: { @@ -491,7 +526,7 @@ type PasswordInputProps = Exclude & { validatePassword?: boolean; }; type FormInputProps = - | RadixFormControlProps + | (RadixFormControlProps & { passkeyAutofill?: boolean }) | ({ type: 'otp'; render: OTPInputProps['render'] } & Omit) | ({ type: 'otp'; render?: undefined } & OTPInputProps) // Usecase: Toggle the visibility of the password input, therefore 'password' and 'text' are allowed From 69e387f2bcf9c0093f03cd3d01e7f8009f787e03 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 30 May 2024 21:52:24 +0300 Subject: [PATCH 03/21] feat(elements): Handle submit attempts when attempting passkey autofill --- .../elements/src/internals/machines/sign-in/start.machine.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 e02a3728a09..6755c2f040e 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -141,6 +141,11 @@ export const SignInStartMachine = setup({ target: 'AttemptingPasskey', reenter: true, }, + SUBMIT: { + guard: not('isExampleMode'), + target: 'Attempting', + reenter: true, + }, }, invoke: { id: 'attemptPasskeyAutofill', From ac84712bcd32b318342dbef0415c2ec30e6f7721 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 31 May 2024 12:36:41 +0300 Subject: [PATCH 04/21] chore(elements): Remove redundant types --- .../internals/machines/sign-in/router.machine.ts | 13 ++++--------- .../src/internals/machines/sign-in/router.types.ts | 3 +-- .../src/internals/machines/sign-in/start.machine.ts | 12 ++++++------ .../src/internals/machines/sign-in/start.types.ts | 7 ++----- 4 files changed, 13 insertions(+), 22 deletions(-) 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 4f17196dcba..881e6133bdc 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -20,7 +20,6 @@ import type { SignInRouterContext, SignInRouterEvents, SignInRouterNextEvent, - SignInRouterPasskeyEvent, SignInRouterSchema, } from './router.types'; import { SignInStartMachine } from './start.machine'; @@ -309,14 +308,10 @@ export const SignInRouterMachine = setup({ reenter: true, }, 'AUTHENTICATE.PASSKEY': { - actions: sendTo( - 'start', - ({ event }) => - ({ - type: 'AUTHENTICATE.PASSKEY', - flow: event.flow, - } as SignInRouterPasskeyEvent), - ), + actions: sendTo('start', () => ({ + type: 'AUTHENTICATE.PASSKEY', + flow: 'discoverable', + })), }, 'AUTHENTICATE.PASSKEY_AUTOFILL': { actions: sendTo('start', () => ({ 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 a4a2e569cdd..631271580a8 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -63,10 +63,9 @@ 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'; flow: 'autofill' | 'discoverable' | undefined }; +export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; export type SignInRouterPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY_AUTOFILL'; - flow: 'autofill' | 'discoverable' | undefined; }; export interface SignInRouterInitEvent extends BaseRouterInput { 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 6755c2f040e..a0197464c5e 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -7,7 +7,7 @@ import { sendToLoading } from '~/internals/machines/shared'; import { assertActorEventError } from '~/internals/machines/utils/assert'; import type { SignInRouterMachineActorRef } from './router.types'; -import type { SignInStartPasskeyEvent, SignInStartSchema } from './start.types'; +import type { SignInStartSchema } from './start.types'; export type TSignInStartMachine = typeof SignInStartMachine; @@ -17,7 +17,7 @@ export const SignInStartMachine = setup({ actors: { attemptPasskey: fromPromise< SignInResource, - { parent: SignInRouterMachineActorRef; flow: 'discoverable' | undefined } + { parent: SignInRouterMachineActorRef; flow: 'autofill' | 'discoverable' | undefined } >(({ input: { parent, flow } }) => { return parent.getSnapshot().context.clerk.client.signIn.authenticateWithPasskey({ flow, @@ -121,9 +121,9 @@ export const SignInStartMachine = setup({ invoke: { id: 'attemptPasskey', src: 'attemptPasskey', - input: ({ context, event }) => ({ + input: ({ context }) => ({ parent: context.parent, - flow: (event as SignInStartPasskeyEvent).flow, + flow: 'discoverable', }), onDone: { actions: ['sendToNext', 'sendToLoading'], @@ -150,9 +150,9 @@ export const SignInStartMachine = setup({ invoke: { id: 'attemptPasskeyAutofill', src: 'attemptPasskey', - input: ({ context, event }) => ({ + input: ({ context }) => ({ parent: context.parent, - flow: (event as SignInStartPasskeyEvent).flow, + flow: 'autofill', }), onDone: { actions: ['sendToNext'], 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 9533a4b2ff1..a9413d47ee7 100644 --- a/packages/elements/src/internals/machines/sign-in/start.types.ts +++ b/packages/elements/src/internals/machines/sign-in/start.types.ts @@ -12,11 +12,8 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load // ---------------------------------- Events ---------------------------------- // export type SignInStartSubmitEvent = { type: 'SUBMIT' }; -export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY'; flow: 'discoverable' | undefined }; -export type SignInStartPasskeyAutofillEvent = { - type: 'AUTHENTICATE.PASSKEY_AUTOFILL'; - flow: 'discoverable' | undefined; -}; +export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; +export type SignInStartPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY_AUTOFILL' }; export type SignInStartEvents = | ErrorActorEvent From 4af33d87be47d114d8ebaebfb5c8e1667242b0f3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 31 May 2024 12:46:43 +0300 Subject: [PATCH 05/21] chore(elements): Improve code readability --- .../src/internals/machines/sign-in/verification.machine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 939dceee2b0..2a551c15290 100644 --- a/packages/elements/src/internals/machines/sign-in/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/verification.machine.ts @@ -347,9 +347,11 @@ export const SignInFirstFactorMachine = SignInVerificationMachine.provide({ // If a prepare call has already been fired recently, don't re-send const currentVerificationExpiration = clerk.client.signIn.firstFactorVerification.expireAt; + const nonPreperableStategies = ['passkey', 'password']; + const preparable = params?.strategy ? !nonPreperableStategies.includes(params.strategy) : false; const needsPrepare = resendable || !currentVerificationExpiration || currentVerificationExpiration < new Date(); - if (!params?.strategy || params.strategy === 'password' || params.strategy === 'passkey' || !needsPrepare) { + if (!preparable || !needsPrepare) { return Promise.resolve(clerk.client.signIn); } From d12ececd09c0ce986ff24357c1f9b2b7213055a6 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 31 May 2024 12:48:06 +0300 Subject: [PATCH 06/21] chore(elements): Add changeset --- .changeset/fuzzy-bees-doubt.md | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .changeset/fuzzy-bees-doubt.md diff --git a/.changeset/fuzzy-bees-doubt.md b/.changeset/fuzzy-bees-doubt.md new file mode 100644 index 00000000000..a6cb8feb34b --- /dev/null +++ b/.changeset/fuzzy-bees-doubt.md @@ -0,0 +1,51 @@ +--- +'@clerk/elements': minor +--- + +Support passkeys in SignIn flows. +APIs introduced: +- `` +- `` +- `` +- `` + +Usage Examples: +- `` +```tsx + + + +``` + +- `` +```tsx + + + +``` + +- `` +```tsx + +

+ Welcome back ! +

+ + Continue with Passkey +
+``` + +- `` +```tsx + + + Email + + + + +``` From f830e31451274910c096af132f9d918fd730675f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 31 May 2024 12:56:06 +0300 Subject: [PATCH 07/21] fix(elements): Add missing file --- .../elements/src/react/common/form/index.tsx | 2 +- .../src/react/sign-in/action/passkey.tsx | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/elements/src/react/sign-in/action/passkey.tsx diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index b936d0e4bff..90d6dc34fdc 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -291,7 +291,7 @@ const useInput = ({ React.useEffect(() => { // @ts-expect-error - Depending on type the props can be different if (passthroughProps?.passkeyAutofill) { - signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY_AUTOFILL', flow: 'autofill' }); + signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY_AUTOFILL' }); } // @ts-expect-error - Depending on type the props can be different }, [passthroughProps?.passkeyAutofill, signInActorRef]); diff --git a/packages/elements/src/react/sign-in/action/passkey.tsx b/packages/elements/src/react/sign-in/action/passkey.tsx new file mode 100644 index 00000000000..06cf1561673 --- /dev/null +++ b/packages/elements/src/react/sign-in/action/passkey.tsx @@ -0,0 +1,47 @@ +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; +}; + +const SIGN_IN_PASSKEY_NAME = 'SignInPasskey'; + +/** + * Resend verification codes during the sign-in process. + * This component must be used within the . + * + * @note This component is not intended to be used directly. Instead, use the component. + * + * @example + * import { Action } from '@clerk/elements/sign-in'; + * 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; From 51d7def8c680e4baead044784e19614c62b84de5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 3 Jun 2024 19:14:20 +0300 Subject: [PATCH 08/21] chore(elements): Use dot notation for event type --- .../src/internals/machines/sign-in/router.machine.ts | 12 +++--------- .../src/internals/machines/sign-in/router.types.ts | 2 +- .../src/internals/machines/sign-in/start.machine.ts | 2 +- .../src/internals/machines/sign-in/start.types.ts | 2 +- packages/elements/src/react/common/form/index.tsx | 2 +- 5 files changed, 7 insertions(+), 13 deletions(-) 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 881e6133bdc..2e2a38bd211 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -308,16 +308,10 @@ export const SignInRouterMachine = setup({ reenter: true, }, 'AUTHENTICATE.PASSKEY': { - actions: sendTo('start', () => ({ - type: 'AUTHENTICATE.PASSKEY', - flow: 'discoverable', - })), + actions: sendTo('start', ({ event }) => event), }, - 'AUTHENTICATE.PASSKEY_AUTOFILL': { - actions: sendTo('start', () => ({ - type: 'AUTHENTICATE.PASSKEY_AUTOFILL', - flow: 'autofill', - })), + 'AUTHENTICATE.PASSKEY.AUTOFILL': { + actions: sendTo('start', ({ event }) => event), }, NEXT: [ { 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 631271580a8..c555448b83b 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -65,7 +65,7 @@ export type SignInRouterSetClerkEvent = BaseRouterSetClerkEvent; export type SignInRouterSubmitEvent = { type: 'SUBMIT' }; export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; export type SignInRouterPasskeyAutofillEvent = { - type: 'AUTHENTICATE.PASSKEY_AUTOFILL'; + type: 'AUTHENTICATE.PASSKEY.AUTOFILL'; }; export interface SignInRouterInitEvent extends BaseRouterInput { 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 a0197464c5e..fac82a42ec7 100644 --- a/packages/elements/src/internals/machines/sign-in/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/start.machine.ts @@ -89,7 +89,7 @@ export const SignInStartMachine = setup({ target: 'AttemptingPasskey', reenter: true, }, - 'AUTHENTICATE.PASSKEY_AUTOFILL': { + 'AUTHENTICATE.PASSKEY.AUTOFILL': { guard: not('isExampleMode'), target: 'AttemptingPasskeyAutoFill', reenter: false, 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 a9413d47ee7..7f935eb84b9 100644 --- a/packages/elements/src/internals/machines/sign-in/start.types.ts +++ b/packages/elements/src/internals/machines/sign-in/start.types.ts @@ -13,7 +13,7 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load export type SignInStartSubmitEvent = { type: 'SUBMIT' }; export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' }; -export type SignInStartPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY_AUTOFILL' }; +export type SignInStartPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }; export type SignInStartEvents = | ErrorActorEvent diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 90d6dc34fdc..84f6a7dbc3a 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -291,7 +291,7 @@ const useInput = ({ React.useEffect(() => { // @ts-expect-error - Depending on type the props can be different if (passthroughProps?.passkeyAutofill) { - signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY_AUTOFILL' }); + signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); } // @ts-expect-error - Depending on type the props can be different }, [passthroughProps?.passkeyAutofill, signInActorRef]); From 01c42bf857ae71141614d8c06695991e65cffafc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 3 Jun 2024 19:27:06 +0300 Subject: [PATCH 09/21] chore(elements): Introduce `isNonPreperableStrategy` --- .../machines/sign-in/verification.machine.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 2a551c15290..d04e497c1a3 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({ @@ -145,8 +153,7 @@ const SignInVerificationMachine = setup({ }, guards: { isResendable: ({ context }) => context.resendable || context.resendableAfter === 0, - isNeverResendable: ({ context }) => - context.currentFactor?.strategy === 'password' || context.currentFactor?.strategy === 'passkey', + isNeverResendable: ({ context }) => isNonPreperableStrategy(context.currentFactor?.strategy), }, delays: SignInVerificationDelays, types: {} as SignInVerificationSchema, @@ -347,11 +354,9 @@ export const SignInFirstFactorMachine = SignInVerificationMachine.provide({ // If a prepare call has already been fired recently, don't re-send const currentVerificationExpiration = clerk.client.signIn.firstFactorVerification.expireAt; - const nonPreperableStategies = ['passkey', 'password']; - const preparable = params?.strategy ? !nonPreperableStategies.includes(params.strategy) : false; const needsPrepare = resendable || !currentVerificationExpiration || currentVerificationExpiration < new Date(); - if (!preparable || !needsPrepare) { + if (isNonPreperableStrategy(params?.strategy) || !needsPrepare) { return Promise.resolve(clerk.client.signIn); } From 4737f40628a5b1003a5331a0bc27bf7aeca1de2b Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 3 Jun 2024 19:42:10 +0300 Subject: [PATCH 10/21] chore(shared,clerk-js,elements): Move webauthn utilities to shared --- .changeset/plenty-eels-pay.md | 6 +++ .../clerk-js/src/core/resources/Passkey.ts | 9 +--- .../clerk-js/src/core/resources/SignIn.ts | 3 +- .../src/ui/components/SignIn/SignInStart.tsx | 2 +- .../src/ui/components/SignIn/utils.ts | 2 +- .../src/ui/hooks/useAlternativeStrategies.ts | 2 +- packages/clerk-js/src/utils/passkeys.ts | 31 -------------- .../sign-in/utils/starting-factors.ts | 2 +- .../elements/src/react/common/form/index.tsx | 41 ++++++++++++++----- packages/shared/package.json | 3 +- packages/shared/src/webauthn.ts | 30 ++++++++++++++ packages/shared/subpaths.mjs | 1 + 12 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 .changeset/plenty-eels-pay.md create mode 100644 packages/shared/src/webauthn.ts 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/src/internals/machines/sign-in/utils/starting-factors.ts b/packages/elements/src/internals/machines/sign-in/utils/starting-factors.ts index 1c6f949d4af..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,6 +1,6 @@ // These utilities are ported from: packages/clerk-js/src/ui/components/SignIn/utils.ts // They should be functionally identical. -import { isWebAuthnSupported } from '@clerk/clerk-js/src/utils/passkeys'; +import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { PreferredSignInStrategy, SignInFactor } from '@clerk/types'; const ORDER_WHEN_PASSWORD_PREFERRED = ['passkey', 'password', 'email_link', 'email_code', 'phone_code'] as const; diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 84f6a7dbc3a..b6fe534dd52 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -1,6 +1,6 @@ -import { isWebAuthnAutofillSupported } from '@clerk/clerk-js/src/utils/passkeys'; import { useClerk } from '@clerk/clerk-react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; +import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn'; import type { Autocomplete } from '@clerk/types'; import { composeEventHandlers } from '@radix-ui/primitive'; import type { @@ -72,11 +72,18 @@ const useFieldFeedback = ({ name }: Partial>) => { }; const determineInputTypeFromName = (name: FormFieldProps['name']) => { - if (name === 'password' || name === 'confirmPassword' || name === 'currentPassword' || name === 'newPassword') + if (name === 'password' || name === 'confirmPassword' || name === 'currentPassword' || name === 'newPassword') { return 'password' as const; - if (name === 'emailAddress') return 'email' as const; - if (name === 'phoneNumber') return 'tel' as const; - if (name === 'code') return 'otp' as const; + } + if (name === 'emailAddress') { + return 'email' as const; + } + if (name === 'phoneNumber') { + return 'tel' as const; + } + if (name === 'code') { + return 'otp' as const; + } return 'text' as const; }; @@ -234,7 +241,9 @@ const useInput = ({ // Register the field in the machine context React.useEffect(() => { - if (!name || ref.getSnapshot().context.fields.get(name)) return; + if (!name || ref.getSnapshot().context.fields.get(name)) { + return; + } ref.send({ type: 'FIELD.ADD', field: { name, value: initialValue } }); @@ -245,9 +254,13 @@ const useInput = ({ const onChange = React.useCallback( (event: React.ChangeEvent) => { onChangeProp?.(event); - if (!name || initialValue) return; + if (!name || initialValue) { + return; + } ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value } }); - if (shouldValidatePassword) validatePassword(event.target.value); + if (shouldValidatePassword) { + validatePassword(event.target.value); + } }, [ref, name, onChangeProp, initialValue, shouldValidatePassword, validatePassword], ); @@ -255,7 +268,9 @@ const useInput = ({ const onBlur = React.useCallback( (event: React.FocusEvent) => { onBlurProp?.(event); - if (shouldValidatePassword) validatePassword(event.target.value); + if (shouldValidatePassword) { + validatePassword(event.target.value); + } }, [onBlurProp, shouldValidatePassword, validatePassword], ); @@ -263,13 +278,17 @@ const useInput = ({ const onFocus = React.useCallback( (event: React.FocusEvent) => { onFocusProp?.(event); - if (shouldValidatePassword) validatePassword(event.target.value); + if (shouldValidatePassword) { + validatePassword(event.target.value); + } }, [onFocusProp, shouldValidatePassword, validatePassword], ); React.useEffect(() => { - if (!initialValue || !name) return; + if (!initialValue || !name) { + return; + } ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } }); }, [name, ref, initialValue]); diff --git a/packages/shared/package.json b/packages/shared/package.json index 57c3f614e90..04148e203c7 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']; From 7db4a1b10963a9c641c785e323025e32481f6f92 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 3 Jun 2024 19:47:37 +0300 Subject: [PATCH 11/21] chore(elements): Sanitize props --- packages/elements/src/react/common/form/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index b6fe534dd52..5a02fa39621 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -359,10 +359,7 @@ const useInput = ({ // Filter out invalid props that should not be passed through // @ts-expect-error - Doesn't know about type narrowing by type here - const { validatePassword: _1, ...rest } = passthroughProps; - - // @ts-expect-error - Depending on type the props can be different - delete rest['passkeyAutofill']; + const { validatePassword: _1, passkeyAutofill, ...rest } = passthroughProps; return { Element, From 0122832cf895e7ea7e44f705a24da7f6b32b1b8e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 4 Jun 2024 12:18:43 +0300 Subject: [PATCH 12/21] chore(elements): Make passkey a standalone component --- .../nextjs/app/sign-in/[[...sign-in]]/page.tsx | 7 ++----- .../elements/src/react/sign-in/action/action.tsx | 12 ++++-------- packages/elements/src/react/sign-in/index.ts | 1 + .../src/react/sign-in/{action => }/passkey.tsx | 9 ++++----- 4 files changed, 11 insertions(+), 18 deletions(-) rename packages/elements/src/react/sign-in/{action => }/passkey.tsx (79%) 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 b9719c5d713..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,12 +175,9 @@ export default function SignInPage() { Continue with Google - + {isLoading => (isLoading ? : 'Use passkey instead')} - + {continueWithEmail ? ( <> diff --git a/packages/elements/src/react/sign-in/action/action.tsx b/packages/elements/src/react/sign-in/action/action.tsx index 825d597b16c..55f3194a198 100644 --- a/packages/elements/src/react/sign-in/action/action.tsx +++ b/packages/elements/src/react/sign-in/action/action.tsx @@ -5,7 +5,6 @@ import { Submit } from '~/react/common'; import type { SignInNavigateElementKey, SignInNavigateProps } from './navigate'; import { SignInNavigate } from './navigate'; -import { SignInPasskey } from './passkey'; import type { SignInResendProps } from './resend'; import { SignInResend } from './resend'; @@ -15,11 +14,10 @@ export type SignInActionProps = { asChild?: boolean } & FormSubmitProps & navigate: SignInNavigateProps['to']; resend?: never; submit?: never; - passkey?: never; } & Omit) - | { navigate?: never; resend?: never; submit: true; passkey?: never } - | { navigate?: never; resend?: never; submit?: never; passkey: true } - | ({ navigate?: never; resend: true; submit?: never; passkey?: never } & SignInResendProps) + | { navigate?: never; resend?: never; submit: true } + | { navigate?: never; resend?: never; submit?: never } + | ({ navigate?: never; resend: true; submit?: never } & SignInResendProps) ); export type SignInActionCompProps = React.ForwardRefExoticComponent< @@ -51,7 +49,7 @@ const SIGN_IN_ACTION_NAME = 'SignInAction'; * Use Passkey instead */ export const SignInAction = React.forwardRef, SignInActionProps>((props, forwardedRef) => { - const { submit, navigate, passkey, resend, ...rest } = props; + const { submit, navigate, resend, ...rest } = props; let Comp: React.ForwardRefExoticComponent | undefined; if (submit) { @@ -60,8 +58,6 @@ export const SignInAction = React.forwardRef, SignInA Comp = SignInNavigate; } else if (resend) { Comp = SignInResend; - } else if (passkey) { - Comp = SignInPasskey; } return Comp ? ( 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/action/passkey.tsx b/packages/elements/src/react/sign-in/passkey.tsx similarity index 79% rename from packages/elements/src/react/sign-in/action/passkey.tsx rename to packages/elements/src/react/sign-in/passkey.tsx index 06cf1561673..6727bb5bb6b 100644 --- a/packages/elements/src/react/sign-in/action/passkey.tsx +++ b/packages/elements/src/react/sign-in/passkey.tsx @@ -8,19 +8,18 @@ export type SignInPasskeyElement = React.ElementRef<'button'>; export type SignInPasskeyProps = { asChild?: boolean; children: React.ReactNode; -}; +} & React.DetailedHTMLProps, HTMLButtonElement>; const SIGN_IN_PASSKEY_NAME = 'SignInPasskey'; /** - * Resend verification codes during the sign-in process. + * Prompt users to select a passkey from their device in order to sign in. * This component must be used within the . * - * @note This component is not intended to be used directly. Instead, use the component. * * @example - * import { Action } from '@clerk/elements/sign-in'; - * Use passkey instead; + * import { Passkey } from '@clerk/elements/sign-in'; + * Use passkey instead; */ export const SignInPasskey = React.forwardRef( ({ asChild, ...rest }, forwardedRef) => { From 4c9e102d223bbe9473cf36f0a598f98cab9e3698 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 4 Jun 2024 12:20:44 +0300 Subject: [PATCH 13/21] chore(elements): Update changeset --- .changeset/fuzzy-bees-doubt.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/fuzzy-bees-doubt.md b/.changeset/fuzzy-bees-doubt.md index a6cb8feb34b..29b6f454b58 100644 --- a/.changeset/fuzzy-bees-doubt.md +++ b/.changeset/fuzzy-bees-doubt.md @@ -4,7 +4,7 @@ Support passkeys in SignIn flows. APIs introduced: -- `` +- `` - `` - `` - `` @@ -12,12 +12,12 @@ APIs introduced: Usage Examples: - `` ```tsx - + {isLoading => (isLoading ? : 'Use passkey instead')}. - + ``` @@ -47,5 +47,5 @@ Usage Examples:
- + ``` From 6fadabdf001c9b53b4dbea824cb8a0965432a783 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 4 Jun 2024 13:10:44 +0300 Subject: [PATCH 14/21] chore(elements): Move autofill support detection inside an actor --- .../machines/sign-in/router.machine.ts | 11 ++++++- .../machines/sign-in/router.types.ts | 1 + .../elements/src/react/common/form/index.tsx | 33 +++++++------------ .../react/sign-in/context/router.context.ts | 2 ++ 4 files changed, 24 insertions(+), 23 deletions(-) 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 2e2a38bd211..bd215fa0bbd 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' }), @@ -208,6 +210,13 @@ export const SignInRouterMachine = setup({ }, states: { Idle: { + invoke: { + id: 'webAuthnAutofill', + src: 'webAuthnAutofillSupport', + onDone: { + actions: assign({ webAuthnAutofillSupport: ({ event }) => event.output }), + }, + }, on: { INIT: { actions: assign(({ event }) => ({ 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 c555448b83b..2f2b277a79a 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -105,6 +105,7 @@ export interface SignInRouterContext extends BaseRouterContext { formRef: ActorRefFrom; loading: SignInRouterLoadingContext; signUpPath: string; + webAuthnAutofillSupport: boolean; } // ---------------------------------- Input ---------------------------------- // diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 5a02fa39621..41db2361448 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -1,6 +1,5 @@ import { useClerk } from '@clerk/clerk-react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; -import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn'; import type { Autocomplete } from '@clerk/types'; import { composeEventHandlers } from '@radix-ui/primitive'; import type { @@ -37,6 +36,7 @@ import { } 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,6 +179,8 @@ const useInput = ({ onChange: onChangeProp, onBlur: onBlurProp, onFocus: onFocusProp, + // @ts-expect-error - Depending on type the props can be different + passkeyAutofill, ...passthroughProps }: FormInputProps) => { const signInActorRef = SignInRouterCtx.useActorRef(true); @@ -292,28 +294,15 @@ const useInput = ({ ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } }); }, [name, ref, initialValue]); - const [isSupported, setIsSupported] = React.useState(false); - React.useEffect(() => { - async function runAutofillPasskey() { - const _isSupported = await isWebAuthnAutofillSupported().catch(() => false); - setIsSupported(_isSupported); - } - - // @ts-expect-error - Depending on type the props can be different - if (passthroughProps?.passkeyAutofill) { - runAutofillPasskey(); - } - - // @ts-expect-error - Depending on type the props can be different - }, [passthroughProps?.passkeyAutofill]); + const passkeyAutofillSupported = useSignInPasskeyAutofill(); React.useEffect(() => { // @ts-expect-error - Depending on type the props can be different - if (passthroughProps?.passkeyAutofill) { + if (passthroughProps?.passkeyAutofill && passkeyAutofillSupported) { signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); } // @ts-expect-error - Depending on type the props can be different - }, [passthroughProps?.passkeyAutofill, signInActorRef]); + }, [passthroughProps?.passkeyAutofill, passkeyAutofillSupported, signInActorRef]); if (!name) { throw new Error('Clerk: must be wrapped in a component or have a name prop.'); @@ -351,16 +340,16 @@ const useInput = ({ }; } - if (isSupported) { + // Filter out invalid props that should not be passed through + // @ts-expect-error - Doesn't know about type narrowing by type here + const { validatePassword: _1, ...rest } = passthroughProps; + + if (passkeyAutofill && passkeyAutofillSupported) { props = { autoComplete: 'webauthn', }; } - // Filter out invalid props that should not be passed through - // @ts-expect-error - Doesn't know about type narrowing by type here - const { validatePassword: _1, passkeyAutofill, ...rest } = passthroughProps; - return { Element, props: { 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..609f06b974c 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,5 @@ 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.webAuthnSupport); From 376190d209c276df731ca50f9e2644fd65a5f8f8 Mon Sep 17 00:00:00 2001 From: Lennart Date: Wed, 5 Jun 2024 12:48:33 +0200 Subject: [PATCH 15/21] adjust changeset --- .changeset/fuzzy-bees-doubt.md | 69 +++++++++++++++++----------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/.changeset/fuzzy-bees-doubt.md b/.changeset/fuzzy-bees-doubt.md index 29b6f454b58..02ec5872692 100644 --- a/.changeset/fuzzy-bees-doubt.md +++ b/.changeset/fuzzy-bees-doubt.md @@ -2,50 +2,51 @@ '@clerk/elements': minor --- -Support passkeys in SignIn flows. +Support passkeys in `` flows. + APIs introduced: - `` - `` - `` - `` -Usage Examples: +Usage examples: - `` -```tsx - - - - {isLoading => (isLoading ? : 'Use passkey instead')}. - - - -``` + ```tsx + + + + {isLoading => (isLoading ? : 'Use passkey instead')}. + + + + ``` - `` -```tsx - - - -``` + ```tsx + + + + ``` - `` -```tsx - -

- Welcome back ! -

- - Continue with Passkey -
-``` + ```tsx + +

+ Welcome back ! +

+ + Continue with Passkey +
+ ``` - `` -```tsx - - - Email - - - - -``` + ```tsx + + + Email + + + + + ``` From 12e62578bf0ff2a8f3a862203ed71c5c4c19dc82 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 5 Jun 2024 14:19:36 +0300 Subject: [PATCH 16/21] Address PR comments --- .../elements/src/react/common/form/index.tsx | 27 ++++++++++--------- .../src/react/sign-in/action/action.tsx | 4 --- .../elements/src/react/sign-in/passkey.tsx | 4 +-- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 41db2361448..1b0ef4f324b 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -179,8 +179,6 @@ const useInput = ({ onChange: onChangeProp, onBlur: onBlurProp, onFocus: onFocusProp, - // @ts-expect-error - Depending on type the props can be different - passkeyAutofill, ...passthroughProps }: FormInputProps) => { const signInActorRef = SignInRouterCtx.useActorRef(true); @@ -296,13 +294,13 @@ const useInput = ({ const passkeyAutofillSupported = useSignInPasskeyAutofill(); + const passkeyAutofillProp = (passthroughProps as PasskeyInputProps).passkeyAutofill; + React.useEffect(() => { - // @ts-expect-error - Depending on type the props can be different - if (passthroughProps?.passkeyAutofill && passkeyAutofillSupported) { + if (passkeyAutofillProp && passkeyAutofillSupported) { signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); } - // @ts-expect-error - Depending on type the props can be different - }, [passthroughProps?.passkeyAutofill, passkeyAutofillSupported, signInActorRef]); + }, [passkeyAutofillProp, passkeyAutofillSupported, signInActorRef]); if (!name) { throw new Error('Clerk: must be wrapped in a component or have a name prop.'); @@ -340,16 +338,16 @@ const useInput = ({ }; } - // Filter out invalid props that should not be passed through - // @ts-expect-error - Doesn't know about type narrowing by type here - const { validatePassword: _1, ...rest } = passthroughProps; - - if (passkeyAutofill && passkeyAutofillSupported) { + if (passkeyAutofillProp && passkeyAutofillSupported) { props = { autoComplete: 'webauthn', }; } + // Filter out invalid props that should not be passed through + // @ts-expect-error - Doesn't know about type narrowing by type here + const { validatePassword: _1, passkeyAutofill, ...rest } = passthroughProps; + return { Element, props: { @@ -530,8 +528,13 @@ const INPUT_NAME = 'ClerkElementsInput'; type PasswordInputProps = Exclude & { validatePassword?: boolean; }; + +type PasskeyInputProps = FormControlProps & { + passkeyAutofill?: boolean; +}; + type FormInputProps = - | (RadixFormControlProps & { passkeyAutofill?: boolean }) + | PasskeyInputProps | ({ type: 'otp'; render: OTPInputProps['render'] } & Omit) | ({ type: 'otp'; render?: undefined } & OTPInputProps) // Usecase: Toggle the visibility of the password input, therefore 'password' and 'text' are allowed diff --git a/packages/elements/src/react/sign-in/action/action.tsx b/packages/elements/src/react/sign-in/action/action.tsx index 55f3194a198..da05b33bad8 100644 --- a/packages/elements/src/react/sign-in/action/action.tsx +++ b/packages/elements/src/react/sign-in/action/action.tsx @@ -16,7 +16,6 @@ export type SignInActionProps = { asChild?: boolean } & FormSubmitProps & submit?: never; } & Omit) | { navigate?: never; resend?: never; submit: true } - | { navigate?: never; resend?: never; submit?: never } | ({ navigate?: never; resend: true; submit?: never } & SignInResendProps) ); @@ -44,9 +43,6 @@ const SIGN_IN_ACTION_NAME = 'SignInAction'; * * @example * Resend - - * @example - * Use Passkey instead */ export const SignInAction = React.forwardRef, SignInActionProps>((props, forwardedRef) => { const { submit, navigate, resend, ...rest } = props; diff --git a/packages/elements/src/react/sign-in/passkey.tsx b/packages/elements/src/react/sign-in/passkey.tsx index 6727bb5bb6b..91fec396758 100644 --- a/packages/elements/src/react/sign-in/passkey.tsx +++ b/packages/elements/src/react/sign-in/passkey.tsx @@ -16,10 +16,8 @@ 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 - * import { Passkey } from '@clerk/elements/sign-in'; - * Use passkey instead; + * Use Passkey instead */ export const SignInPasskey = React.forwardRef( ({ asChild, ...rest }, forwardedRef) => { From f8990704b868dd56e5acd5cbe71e75af4039506e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 5 Jun 2024 16:12:28 +0300 Subject: [PATCH 17/21] Address tests failing --- .../elements/src/react/common/form/index.tsx | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 1b0ef4f324b..22d7ee36ad8 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -181,7 +181,6 @@ const useInput = ({ onFocus: onFocusProp, ...passthroughProps }: FormInputProps) => { - const signInActorRef = SignInRouterCtx.useActorRef(true); // Inputs can be used outside a wrapper if desired, so safely destructure here const fieldContext = useFieldContext(); const name = inputName || fieldContext?.name; @@ -292,16 +291,6 @@ const useInput = ({ ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } }); }, [name, ref, initialValue]); - const passkeyAutofillSupported = useSignInPasskeyAutofill(); - - const passkeyAutofillProp = (passthroughProps as PasskeyInputProps).passkeyAutofill; - - React.useEffect(() => { - if (passkeyAutofillProp && passkeyAutofillSupported) { - signInActorRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); - } - }, [passkeyAutofillProp, passkeyAutofillSupported, signInActorRef]); - if (!name) { throw new Error('Clerk: must be wrapped in a component or have a name prop.'); } @@ -338,7 +327,7 @@ const useInput = ({ }; } - if (passkeyAutofillProp && passkeyAutofillSupported) { + if ((passthroughProps as PasskeyInputProps).passkeyAutofill) { props = { autoComplete: 'webauthn', }; @@ -576,6 +565,7 @@ type FormInputProps = const Input = React.forwardRef, FormInputProps>( (props: FormInputProps, forwardedRef) => { const clerk = useClerk(); + const signInRouterRef = SignInRouterCtx.useActorRef(true); clerk.telemetry?.record( eventComponentMounted('Elements_Input', { @@ -589,7 +579,40 @@ const Input = React.forwardRef, FormInputP }), ); - const field = useInput(props); + if (signInRouterRef) { + return ( + + ); + } + + return ( + + ); + }, +); + +Input.displayName = INPUT_NAME; + +const SignInInput = React.forwardRef, FormInputProps>( + (props: FormInputProps, forwardedRef) => { + const signInRouterRef = SignInRouterCtx.useActorRef(true); + const passkeyAutofillSupported = useSignInPasskeyAutofill(); + const passkeyAutofillProp = (props as PasskeyInputProps).passkeyAutofill; + + React.useEffect(() => { + if (passkeyAutofillProp && passkeyAutofillSupported) { + signInRouterRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); + } + }, [passkeyAutofillProp, passkeyAutofillSupported, signInRouterRef]); + + // @ts-expect-error - Depending on type the props can be different + const field = useInput({ ...props, passkeyAutofill: passkeyAutofillProp && passkeyAutofillSupported }); return ( , FormInputP }, ); -Input.displayName = INPUT_NAME; +const CommonInput = React.forwardRef, FormInputProps>( + (props: FormInputProps, forwardedRef) => { + const field = useInput(props); + return ( + + ); + }, +); /* ------------------------------------------------------------------------------------------------- * Label From 0f5c3e3638cb0764d740c5c1141e699cdf91246c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 5 Jun 2024 16:25:59 +0300 Subject: [PATCH 18/21] Improve error handling --- packages/elements/src/react/common/form/index.tsx | 14 +++++++++----- .../src/react/sign-in/context/router.context.ts | 3 ++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 22d7ee36ad8..4ba81d198b8 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -565,6 +565,7 @@ type FormInputProps = const Input = React.forwardRef, FormInputProps>( (props: FormInputProps, forwardedRef) => { const clerk = useClerk(); + const passkeyAutofillProp = (props as PasskeyInputProps).passkeyAutofill; const signInRouterRef = SignInRouterCtx.useActorRef(true); clerk.telemetry?.record( @@ -579,7 +580,7 @@ const Input = React.forwardRef, FormInputP }), ); - if (signInRouterRef) { + if (signInRouterRef && passkeyAutofillProp) { return ( , FormInputP ); } + if (passkeyAutofillProp) { + throw new ClerkElementsRuntimeError(` can only be used inside .`); + } + return ( , Form (props: FormInputProps, forwardedRef) => { const signInRouterRef = SignInRouterCtx.useActorRef(true); const passkeyAutofillSupported = useSignInPasskeyAutofill(); - const passkeyAutofillProp = (props as PasskeyInputProps).passkeyAutofill; React.useEffect(() => { - if (passkeyAutofillProp && passkeyAutofillSupported) { + if (passkeyAutofillSupported) { signInRouterRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); } - }, [passkeyAutofillProp, passkeyAutofillSupported, signInRouterRef]); + }, [passkeyAutofillSupported, signInRouterRef]); // @ts-expect-error - Depending on type the props can be different - const field = useInput({ ...props, passkeyAutofill: passkeyAutofillProp && passkeyAutofillSupported }); + const field = useInput({ ...props, passkeyAutofill: passkeyAutofillSupported }); return ( useSignInStep useSignInStep('secondFactor'); export const useSignInResetPasswordStep = () => useSignInStep('resetPassword'); -export const useSignInPasskeyAutofill = () => SignInRouterCtx.useSelector(state => state.context.webAuthnSupport); +export const useSignInPasskeyAutofill = () => + SignInRouterCtx.useSelector(state => state.context.webAuthnAutofillSupport); From 1d3344eaf6f90dd845ab6c5a8d232a3528627cca Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 6 Jun 2024 20:14:56 +0300 Subject: [PATCH 19/21] chore(elements): Drop `passkeyAutofill` and depend on `webauthn` --- .../elements/src/react/common/form/index.tsx | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 4ba81d198b8..e6b489b2f9e 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'; @@ -327,15 +328,9 @@ const useInput = ({ }; } - if ((passthroughProps as PasskeyInputProps).passkeyAutofill) { - props = { - autoComplete: 'webauthn', - }; - } - // Filter out invalid props that should not be passed through // @ts-expect-error - Doesn't know about type narrowing by type here - const { validatePassword: _1, passkeyAutofill, ...rest } = passthroughProps; + const { validatePassword: _1, ...rest } = passthroughProps; return { Element, @@ -518,12 +513,8 @@ type PasswordInputProps = Exclude & { validatePassword?: boolean; }; -type PasskeyInputProps = FormControlProps & { - passkeyAutofill?: boolean; -}; - type FormInputProps = - | PasskeyInputProps + | RadixFormControlProps | ({ type: 'otp'; render: OTPInputProps['render'] } & Omit) | ({ type: 'otp'; render?: undefined } & OTPInputProps) // Usecase: Toggle the visibility of the password input, therefore 'password' and 'text' are allowed @@ -565,7 +556,10 @@ type FormInputProps = const Input = React.forwardRef, FormInputProps>( (props: FormInputProps, forwardedRef) => { const clerk = useClerk(); - const passkeyAutofillProp = (props as PasskeyInputProps).passkeyAutofill; + const field = useInput(props); + + const hasPasskeyAutofillProp = Boolean(field.props.autoComplete?.includes('webauthn')); + const allowedTypeForPasskey = (['text', 'email'] as FormInputProps['type'][]).includes(field.props.type); const signInRouterRef = SignInRouterCtx.useActorRef(true); clerk.telemetry?.record( @@ -580,23 +574,29 @@ const Input = React.forwardRef, FormInputP }), ); - if (signInRouterRef && passkeyAutofillProp) { + if (signInRouterRef && hasPasskeyAutofillProp && allowedTypeForPasskey) { return ( - ); } - if (passkeyAutofillProp) { - throw new ClerkElementsRuntimeError(` can only be used inside .`); + 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 ( - ); }, @@ -604,7 +604,7 @@ const Input = React.forwardRef, FormInputP Input.displayName = INPUT_NAME; -const SignInInput = React.forwardRef, FormInputProps>( +const InputWithPasskeyAutofill = React.forwardRef, FormInputProps>( (props: FormInputProps, forwardedRef) => { const signInRouterRef = SignInRouterCtx.useActorRef(true); const passkeyAutofillSupported = useSignInPasskeyAutofill(); @@ -615,19 +615,6 @@ const SignInInput = React.forwardRef, Form } }, [passkeyAutofillSupported, signInRouterRef]); - // @ts-expect-error - Depending on type the props can be different - const field = useInput({ ...props, passkeyAutofill: passkeyAutofillSupported }); - return ( - - ); - }, -); - -const CommonInput = React.forwardRef, FormInputProps>( - (props: FormInputProps, forwardedRef) => { const field = useInput(props); return ( Date: Mon, 10 Jun 2024 16:48:11 +0300 Subject: [PATCH 20/21] chore(*): Update changeset --- .changeset/fuzzy-bees-doubt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/fuzzy-bees-doubt.md b/.changeset/fuzzy-bees-doubt.md index 02ec5872692..6b51d405b97 100644 --- a/.changeset/fuzzy-bees-doubt.md +++ b/.changeset/fuzzy-bees-doubt.md @@ -8,7 +8,7 @@ APIs introduced: - `` - `` - `` -- `` +- Detects the usage of `webauthn` to trigger passkey autofill `` Usage examples: - `` @@ -40,12 +40,12 @@ Usage examples: ``` -- `` +- Passkey Autofill ```tsx Email - + From 88477f42d6dcde39c6dea5973b2f90c6b043bd33 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 11 Jun 2024 10:59:23 +0300 Subject: [PATCH 21/21] chore(elements): Detect webauthn in with type `tel` --- packages/elements/src/react/common/form/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index e6b489b2f9e..9267b11a4b4 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -559,7 +559,7 @@ const Input = React.forwardRef, FormInputP const field = useInput(props); const hasPasskeyAutofillProp = Boolean(field.props.autoComplete?.includes('webauthn')); - const allowedTypeForPasskey = (['text', 'email'] as FormInputProps['type'][]).includes(field.props.type); + const allowedTypeForPasskey = (['text', 'email', 'tel'] as FormInputProps['type'][]).includes(field.props.type); const signInRouterRef = SignInRouterCtx.useActorRef(true); clerk.telemetry?.record(