diff --git a/.changeset/honest-insects-deny.md b/.changeset/honest-insects-deny.md new file mode 100644 index 00000000000..4cee0feba39 --- /dev/null +++ b/.changeset/honest-insects-deny.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +[Experimental] Add support for sign-in with passkey to new APIs diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index cb7832dbb90..e98538e1ba5 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -38,6 +38,7 @@ import type { SignInFutureEmailLinkSendParams, SignInFutureFinalizeParams, SignInFutureMFAPhoneCodeVerifyParams, + SignInFuturePasskeyParams, SignInFuturePasswordParams, SignInFuturePhoneCodeSendParams, SignInFuturePhoneCodeVerifyParams, @@ -979,6 +980,77 @@ class SignInFuture implements SignInFutureResource { }); } + async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: unknown }> { + const { flow } = params || {}; + + /** + * The UI should always prevent from this method being called if WebAuthn is not supported. + * As a precaution we need to check if WebAuthn is supported. + */ + + const isWebAuthnSupported = SignIn.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow; + const webAuthnGetCredential = SignIn.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow; + const isWebAuthnAutofillSupported = + SignIn.clerk.__internal_isWebAuthnAutofillSupported || isWebAuthnAutofillSupportedOnWindow; + + if (!isWebAuthnSupported()) { + throw new ClerkWebAuthnError('Passkeys are not supported', { + code: 'passkey_not_supported', + }); + } + + return runAsyncResourceTask(this.resource, async () => { + if (flow === 'autofill' || flow === 'discoverable') { + await this._create({ strategy: 'passkey' }); + } else { + const passKeyFactor = this.supportedFirstFactors.find(f => f.strategy === 'passkey') as PasskeyFactor; + + if (!passKeyFactor) { + clerkVerifyPasskeyCalledBeforeCreate(); + } + await this.resource.__internal_basePost({ + body: { strategy: 'passkey' }, + action: 'prepare_first_factor', + }); + } + + const { nonce } = this.firstFactorVerification; + const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; + + if (!publicKeyOptions) { + clerkMissingWebAuthnPublicKeyOptions('get'); + } + + let canUseConditionalUI = false; + + if (flow === 'autofill') { + /** + * If autofill is not supported gracefully handle the result, we don't need to throw. + * The caller should always check this before calling this method. + */ + canUseConditionalUI = await isWebAuthnAutofillSupported(); + } + + // Invoke the navigator.create.get() method. + const { publicKeyCredential, error } = await webAuthnGetCredential({ + publicKeyOptions, + conditionalUI: canUseConditionalUI, + }); + + if (!publicKeyCredential) { + throw error; + } + + await this.resource.__internal_basePost({ + body: { + publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)), + strategy: 'passkey', + }, + action: 'attempt_first_factor', + }); + }); + } + async sendMFAPhoneCode(): Promise<{ error: unknown }> { return runAsyncResourceTask(this.resource, async () => { const phoneCodeFactor = this.resource.supportedSecondFactors?.find(f => f.strategy === 'phone_code'); diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index ab65bcf8912..bb196b6f229 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -131,6 +131,7 @@ export class StateProxy implements State { 'verifyBackupCode', ] as const), ticket: this.gateMethod(target, 'ticket'), + passkey: this.gateMethod(target, 'passkey'), web3: this.gateMethod(target, 'web3'), }, }; diff --git a/packages/types/src/signInFuture.ts b/packages/types/src/signInFuture.ts index 0a99ec6173e..8a11ea7d7d7 100644 --- a/packages/types/src/signInFuture.ts +++ b/packages/types/src/signInFuture.ts @@ -1,7 +1,7 @@ import type { SetActiveNavigate } from './clerk'; import type { PhoneCodeChannel } from './phoneCodeChannel'; import type { SignInFirstFactor, SignInSecondFactor, SignInStatus, UserData } from './signInCommon'; -import type { OAuthStrategy, Web3Strategy } from './strategies'; +import type { OAuthStrategy, PasskeyStrategy, Web3Strategy } from './strategies'; import type { VerificationResource } from './verification'; export interface SignInFutureCreateParams { @@ -14,7 +14,7 @@ export interface SignInFutureCreateParams { * The first factor verification strategy to use in the sign-in flow. Depends on the `identifier` value. Each * authentication identifier supports different verification strategies. */ - strategy?: OAuthStrategy | 'saml' | 'enterprise_sso'; + strategy?: OAuthStrategy | 'saml' | 'enterprise_sso' | PasskeyStrategy; /** * The full URL or path that the OAuth provider should redirect to after successful authorization on their part. */ @@ -215,6 +215,16 @@ export interface SignInFutureWeb3Params { strategy: Web3Strategy; } +export interface SignInFuturePasskeyParams { + /** + * The flow to use for the passkey sign-in. + * + * - `'autofill'`: The client prompts your users to select a passkey before they interact with your app. + * - `'discoverable'`: The client requires the user to interact with the client. + */ + flow?: 'autofill' | 'discoverable'; +} + export interface SignInFutureFinalizeParams { navigate?: SetActiveNavigate; } @@ -432,6 +442,14 @@ export interface SignInFutureResource { */ web3: (params: SignInFutureWeb3Params) => Promise<{ error: unknown }>; + /** + * Initiates a passkey-based authentication flow, enabling users to authenticate using a previously + * registered passkey. When called without parameters, this method requires a prior call to + * `SignIn.create({ strategy: 'passkey' })` to initialize the sign-in context. This pattern is particularly useful in + * scenarios where the authentication strategy needs to be determined dynamically at runtime. + */ + passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: unknown }>; + /** * Used to convert a sign-in with `status === 'complete'` into an active session. Will cause anything observing the * session state (such as the `useUser()` hook) to update automatically.