Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/eight-buttons-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---

@LekoArts LekoArts Mar 22, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll probably want to add a changeset

'@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') {
...
}
}
}


```
6 changes: 6 additions & 0 deletions packages/clerk-js/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
Comment on lines +118 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Maybe we should be more descriptive of what the publicKey refers to?

@LekoArts LekoArts Mar 22, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where this function is used @panteliselef wrote // This should never occur, just a fail-safe so I'm assuming this is for handling edge cases. If this would be surfaced to the user a CTA to contact us might be helpful since we ourselves don't think they'll ever get into that state

}
20 changes: 12 additions & 8 deletions packages/clerk-js/src/core/resources/Passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
});
}

Expand All @@ -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',
},
);
}
}

Expand Down
19 changes: 10 additions & 9 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClerkRuntimeError, deepSnakeToCamel, Poller } from '@clerk/shared';
import { deepSnakeToCamel, Poller } from '@clerk/shared';
import type {
__experimental_PasskeyFactor,
AttemptFirstFactorParams,
Expand Down Expand Up @@ -30,6 +30,7 @@ import type {

import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils';
import {
ClerkWebAuthnError,
convertJSONToPublicKeyRequestOptions,
isWebAuthnAutofillSupported,
isWebAuthnSupported,
Expand All @@ -41,6 +42,7 @@ import {
clerkInvalidFAPIResponse,
clerkInvalidStrategy,
clerkMissingOptionError,
clerkMissingWebAuthnPublicKeyOptions,
clerkVerifyEmailAddressCalledBeforeCreate,
clerkVerifyPasskeyCalledBeforeCreate,
clerkVerifyWeb3WalletCalledBeforeCreate,
Expand Down Expand Up @@ -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',
});
}

Expand All @@ -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;
Expand All @@ -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,
});

Expand Down
12 changes: 8 additions & 4 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise<unknown>
}
} 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;
}
}
Expand Down
41 changes: 27 additions & 14 deletions packages/clerk-js/src/utils/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be all lower case? or is it aligning with a code sent back from FAPI?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FAPI also uses a lower case / snake case format. But these have nothing to do with FAPI as they will be thrown when something goes wrong when interacting with WebAuthN which is client only.

| '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 (
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -159,33 +165,40 @@ 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
*/
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);
}

/**
* Map webauthn errors from `navigator.credentials.get()` to Clerk-js errors
* @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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down