diff --git a/.changeset/eight-buttons-wink.md b/.changeset/eight-buttons-wink.md new file mode 100644 index 00000000000..aca497a7a1e --- /dev/null +++ b/.changeset/eight-buttons-wink.md @@ -0,0 +1,33 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Improved error handling for registration and retrieval of passkeys. +ClerkRuntimeError codes introduced: +- `passkey_not_supported` +- `passkeys_pa_not_supported` +- `passkey_invalid_rpID_or_domain` +- `passkey_already_exists` +- `passkey_operation_aborted` +- `passkey_retrieval_cancelled` +- `passkey_retrieval_failed` +- `passkey_registration_cancelled` +- `passkey_registration_failed` + +Example usage: + +```ts +try { + await __experimental_authenticateWithPasskey(...args); +}catch (e) { + if (isClerkRuntimeError(e)) { + if (err.code === 'passkey_operation_aborted') { + ... + } + } +} + + +``` diff --git a/packages/clerk-js/src/core/errors.ts b/packages/clerk-js/src/core/errors.ts index cd350ce7a6b..b917db134c4 100644 --- a/packages/clerk-js/src/core/errors.ts +++ b/packages/clerk-js/src/core/errors.ts @@ -113,3 +113,9 @@ export function clerkInvalidRoutingStrategy(strategy?: string): never { export function clerkUnsupportedReloadMethod(className: string): never { throw new Error(`${errorPrefix} Calling ${className}.reload is not currently supported. Please contact support.`); } + +export function clerkMissingWebAuthnPublicKeyOptions(name: 'create' | 'get'): never { + throw new Error( + `${errorPrefix} Missing publicKey. When calling 'navigator.credentials.${name}()' it is required to pass a publicKey object.`, + ); +} diff --git a/packages/clerk-js/src/core/resources/Passkey.ts b/packages/clerk-js/src/core/resources/Passkey.ts index d29dd5ab687..d3cec54e120 100644 --- a/packages/clerk-js/src/core/resources/Passkey.ts +++ b/packages/clerk-js/src/core/resources/Passkey.ts @@ -10,12 +10,14 @@ import type { import { unixEpochToDate } from '../../utils/date'; import { + ClerkWebAuthnError, isWebAuthnPlatformAuthenticatorSupported, isWebAuthnSupported, serializePublicKeyCredential, webAuthnCreateCredential, } from '../../utils/passkeys'; -import { BaseResource, ClerkRuntimeError, DeletedObject, PasskeyVerification } from './internal'; +import { clerkMissingWebAuthnPublicKeyOptions } from '../errors'; +import { BaseResource, DeletedObject, PasskeyVerification } from './internal'; export class Passkey extends BaseResource implements PasskeyResource { id!: string; @@ -60,8 +62,8 @@ export class Passkey extends BaseResource implements PasskeyResource { * As a precaution we need to check if WebAuthn is supported. */ if (!isWebAuthnSupported()) { - throw new ClerkRuntimeError('Passkeys are not supported', { - code: 'passkeys_unsupported', + throw new ClerkWebAuthnError('Passkeys are not supported on this device.', { + code: 'passkey_not_supported', }); } @@ -73,15 +75,17 @@ export class Passkey extends BaseResource implements PasskeyResource { // This should never occur, just a fail-safe if (!publicKey) { - // TODO-PASSKEYS: Implement this later - throw 'Missing key'; + clerkMissingWebAuthnPublicKeyOptions('create'); } if (publicKey.authenticatorSelection?.authenticatorAttachment === 'platform') { if (!(await isWebAuthnPlatformAuthenticatorSupported())) { - throw new ClerkRuntimeError('Platform authenticator is not supported', { - code: 'passkeys_unsupported_platform_authenticator', - }); + throw new ClerkWebAuthnError( + 'Registration requires a platform authenticator but the device does not support it.', + { + code: 'passkeys_pa_not_supported', + }, + ); } } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 9672d93afa9..742c8afbdd9 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1,4 +1,4 @@ -import { ClerkRuntimeError, deepSnakeToCamel, Poller } from '@clerk/shared'; +import { deepSnakeToCamel, Poller } from '@clerk/shared'; import type { __experimental_PasskeyFactor, AttemptFirstFactorParams, @@ -30,6 +30,7 @@ import type { import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils'; import { + ClerkWebAuthnError, convertJSONToPublicKeyRequestOptions, isWebAuthnAutofillSupported, isWebAuthnSupported, @@ -41,6 +42,7 @@ import { clerkInvalidFAPIResponse, clerkInvalidStrategy, clerkMissingOptionError, + clerkMissingWebAuthnPublicKeyOptions, clerkVerifyEmailAddressCalledBeforeCreate, clerkVerifyPasskeyCalledBeforeCreate, clerkVerifyWeb3WalletCalledBeforeCreate, @@ -271,8 +273,8 @@ export class SignIn extends BaseResource implements SignInResource { * As a precaution we need to check if WebAuthn is supported. */ if (!isWebAuthnSupported()) { - throw new ClerkRuntimeError('Passkeys are not supported', { - code: 'passkeys_unsupported', + throw new ClerkWebAuthnError('Passkeys are not supported', { + code: 'passkey_not_supported', }); } @@ -294,11 +296,10 @@ export class SignIn extends BaseResource implements SignInResource { } const { nonce } = this.firstFactorVerification; - const publicKey = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; + const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; - if (!publicKey) { - // TODO-PASSKEYS: Implement this later - throw 'Missing key'; + if (!publicKeyOptions) { + clerkMissingWebAuthnPublicKeyOptions('get'); } let canUseConditionalUI = false; @@ -311,9 +312,9 @@ export class SignIn extends BaseResource implements SignInResource { canUseConditionalUI = await isWebAuthnAutofillSupported(); } - // Invoke the WebAuthn get() method. + // Invoke the navigator.create.get() method. const { publicKeyCredential, error } = await webAuthnGetCredential({ - publicKeyOptions: publicKey, + publicKeyOptions, conditionalUI: canUseConditionalUI, }); diff --git a/packages/clerk-js/src/ui/components/SignIn/shared.ts b/packages/clerk-js/src/ui/components/SignIn/shared.ts index 46fed464326..a7ad44f8a5b 100644 --- a/packages/clerk-js/src/ui/components/SignIn/shared.ts +++ b/packages/clerk-js/src/ui/components/SignIn/shared.ts @@ -35,10 +35,14 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise } } catch (err) { const { flow } = args[0] || {}; - // In case of autofill, if retrieval of credentials is aborted just return to avoid updating state of unmounted components. - if (flow === 'autofill' && isClerkRuntimeError(err)) { - const skipActionCodes = ['passkey_retrieval_aborted', 'passkey_retrieval_cancelled']; - if (skipActionCodes.includes(err.code)) { + + if (isClerkRuntimeError(err)) { + // In any case if the call gets aborted we should skip showing an error. This prevents updating the state of unmounted components. + if (err.code === 'passkey_operation_aborted') { + return; + } + // In case of autofill, if retrieval of credentials is cancelled by the user avoid showing errors as it results to pour UX. + if (flow === 'autofill' && err.code === 'passkey_retrieval_cancelled') { return; } } diff --git a/packages/clerk-js/src/utils/passkeys.ts b/packages/clerk-js/src/utils/passkeys.ts index fdca7ae6f6f..932b250e64e 100644 --- a/packages/clerk-js/src/utils/passkeys.ts +++ b/packages/clerk-js/src/utils/passkeys.ts @@ -25,12 +25,18 @@ type WebAuthnGetCredentialReturn = CredentialReturn<__experimental_PublicKeyCredentialWithAuthenticatorAssertionResponse>; type ClerkWebAuthnErrorCode = - | 'passkey_exists' - | 'passkey_retrieval_aborted' + // Generic + | 'passkey_not_supported' + | 'passkeys_pa_not_supported' + | 'passkey_invalid_rpID_or_domain' + | 'passkey_already_exists' + | 'passkey_operation_aborted' + // Retrieval | 'passkey_retrieval_cancelled' + | 'passkey_retrieval_failed' + // Registration | 'passkey_registration_cancelled' - | 'passkey_credential_create_failed' - | 'passkey_credential_get_failed'; + | 'passkey_registration_failed'; function isWebAuthnSupported() { return ( @@ -92,7 +98,7 @@ async function webAuthnCreateCredential( if (!credential) { return { error: new ClerkWebAuthnError('Browser failed to create credential', { - code: 'passkey_credential_create_failed', + code: 'passkey_registration_failed', }), publicKeyCredential: null, }; @@ -148,7 +154,7 @@ async function webAuthnGetCredential({ if (!credential) { return { - error: new ClerkWebAuthnError('Browser failed to get credential', { code: 'passkey_credential_get_failed' }), + error: new ClerkWebAuthnError('Browser failed to get credential', { code: 'passkey_retrieval_failed' }), publicKeyCredential: null, }; } @@ -159,6 +165,16 @@ async function webAuthnGetCredential({ } } +function handlePublicKeyError(error: Error): ClerkWebAuthnError | ClerkRuntimeError | Error { + if (error.name === 'AbortError') { + return new ClerkWebAuthnError(error.message, { code: 'passkey_operation_aborted' }); + } + if (error.name === 'SecurityError') { + return new ClerkWebAuthnError(error.message, { code: 'passkey_invalid_rpID_or_domain' }); + } + return error; +} + /** * Map webauthn errors from `navigator.credentials.create()` to Clerk-js errors * @param error @@ -166,11 +182,12 @@ async function webAuthnGetCredential({ function handlePublicKeyCreateError(error: Error): ClerkWebAuthnError | ClerkRuntimeError | Error { if (error.name === 'InvalidStateError') { // Note: Firefox will throw 'NotAllowedError' when passkeys exists - return new ClerkWebAuthnError(error.message, { code: 'passkey_exists' }); - } else if (error.name === 'NotAllowedError') { + return new ClerkWebAuthnError(error.message, { code: 'passkey_already_exists' }); + } + if (error.name === 'NotAllowedError') { return new ClerkWebAuthnError(error.message, { code: 'passkey_registration_cancelled' }); } - return error; + return handlePublicKeyError(error); } /** @@ -178,14 +195,10 @@ function handlePublicKeyCreateError(error: Error): ClerkWebAuthnError | ClerkRun * @param error */ function handlePublicKeyGetError(error: Error): ClerkWebAuthnError | ClerkRuntimeError | Error { - if (error.name === 'AbortError') { - return new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_aborted' }); - } - if (error.name === 'NotAllowedError') { return new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_cancelled' }); } - return error; + return handlePublicKeyError(error); } function convertJSONToPublicKeyCreateOptions(jsonPublicKey: PublicKeyCredentialCreationOptionsJSON) { diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 61faa01a540..33228752448 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -442,6 +442,11 @@ export const enUS: LocalizationResource = { 'Sign up unsuccessful due to failed security validations. Please refresh the page to try again or reach out to support for more assistance.', captcha_unavailable: 'Sign up unsuccessful due to failed bot validation. Please refresh the page to try again or reach out to support for more assistance.', + passkey_not_supported: 'Passkeys are not supported on this device.', + passkeys_pa_not_supported: 'Registration requires a platform authenticator but the device does not support it.', + passkey_retrieval_cancelled: 'Passkey verification was cancelled or timed out.', + passkey_registration_cancelled: 'Passkey registration was cancelled or timed out.', + passkey_already_exists: 'A passkey is already registered with this device.', form_code_incorrect: '', form_identifier_exists: '', form_identifier_not_found: '', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 8f2b234e256..b3903efa0de 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -723,6 +723,11 @@ type UnstableErrors = WithParamName<{ form_identifier_not_found: LocalizationValue; captcha_unavailable: LocalizationValue; captcha_invalid: LocalizationValue; + passkey_not_supported: LocalizationValue; + passkeys_pa_not_supported: LocalizationValue; + passkey_retrieval_cancelled: LocalizationValue; + passkey_registration_cancelled: LocalizationValue; + passkey_already_exists: LocalizationValue; form_password_pwned: LocalizationValue; form_username_invalid_length: LocalizationValue; form_username_invalid_character: LocalizationValue;