Skip to content

Commit

Permalink
feat(clerk-js): Experimental method for authenticating with a passkey (
Browse files Browse the repository at this point in the history
…#2970)

* feat(clerk-js): Experimental method for authenticating with a passkey

* fix(clerk-js): Pass correct object to `attemptFirstFactor`

* chore(clerk-js): Export types as experimental

* chore(clerk-js): Export types as experimental

* chore(clerk-js): Add changeset

* fix(clerk-js): Do not spread object with buffers

* test(clerk-js): Tests for new utilities

* chore(clerk-js): Update error code
  • Loading branch information
panteliselef committed Mar 14, 2024
1 parent c8c333c commit 1f650f3
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 41 deletions.
7 changes: 7 additions & 0 deletions .changeset/light-ligers-beam.md
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Experimental support for authenticating with a passkey.
Example usage: `await signIn.authenticateWithPasskey()`.
6 changes: 6 additions & 0 deletions packages/clerk-js/src/core/errors.ts
Expand Up @@ -58,6 +58,12 @@ export function clerkVerifyWeb3WalletCalledBeforeCreate(type: 'SignIn' | 'SignUp
);
}

export function clerkVerifyPasskeyCalledBeforeCreate(): never {
throw new Error(
`${errorPrefix} You need to start a SignIn flow by calling SignIn.create({ strategy: 'passkey' }) first`,
);
}

export function clerkMissingOptionError(name = ''): never {
throw new Error(`${errorPrefix} Missing '${name}' option`);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Passkey.ts
@@ -1,4 +1,5 @@
import type {
__experimental_PublicKeyCredentialWithAuthenticatorAttestationResponse,
DeletedObjectJSON,
DeletedObjectResource,
PasskeyJSON,
Expand All @@ -8,7 +9,6 @@ import type {
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import type { PublicKeyCredentialWithAuthenticatorAttestationResponse } from '../../utils/passkeys';
import {
isWebAuthnPlatformAuthenticatorSupported,
isWebAuthnSupported,
Expand Down Expand Up @@ -41,7 +41,7 @@ export class Passkey extends BaseResource implements PasskeyResource {

private static async attemptVerification(
passkeyId: string,
credential: PublicKeyCredentialWithAuthenticatorAttestationResponse,
credential: __experimental_PublicKeyCredentialWithAuthenticatorAttestationResponse,
) {
const jsonPublicKeyCredential = serializePublicKeyCredential(credential);
return BaseResource._fetch({
Expand Down
87 changes: 84 additions & 3 deletions packages/clerk-js/src/core/resources/SignIn.ts
@@ -1,5 +1,6 @@
import { deepSnakeToCamel, Poller } from '@clerk/shared';
import { ClerkRuntimeError, deepSnakeToCamel, Poller } from '@clerk/shared';
import type {
__experimental_PasskeyFactor,
AttemptFirstFactorParams,
AttemptSecondFactorParams,
AuthenticateWithRedirectParams,
Expand Down Expand Up @@ -28,12 +29,20 @@ import type {
} from '@clerk/types';

import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils';
import {
convertJSONToPublicKeyRequestOptions,
isWebAuthnAutofillSupported,
isWebAuthnSupported,
serializePublicKeyCredentialAssertion,
webAuthnGetCredential,
} from '../../utils/passkeys';
import { createValidatePassword } from '../../utils/passwords/password';
import {
clerkInvalidFAPIResponse,
clerkInvalidStrategy,
clerkMissingOptionError,
clerkVerifyEmailAddressCalledBeforeCreate,
clerkVerifyPasskeyCalledBeforeCreate,
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { BaseResource, UserData, Verification } from './internal';
Expand Down Expand Up @@ -74,6 +83,11 @@ export class SignIn extends BaseResource implements SignInResource {
prepareFirstFactor = (factor: PrepareFirstFactorParams): Promise<SignInResource> => {
let config;
switch (factor.strategy) {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
case 'passkey':
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
config = {} as PassKeyConfig;
break;
case 'email_link':
config = {
emailAddressId: factor.emailAddressId,
Expand Down Expand Up @@ -113,9 +127,22 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

attemptFirstFactor = (params: AttemptFirstFactorParams): Promise<SignInResource> => {
attemptFirstFactor = (attemptFactor: AttemptFirstFactorParams): Promise<SignInResource> => {
let config;
switch (attemptFactor.strategy) {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
case 'passkey':
config = {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)),
};
break;
default:
config = { ...attemptFactor };
}

return this._basePost({
body: params,
body: { ...config, strategy: attemptFactor.strategy },
action: 'attempt_first_factor',
});
};
Expand Down Expand Up @@ -234,6 +261,60 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

public __experimental_authenticateWithPasskey = async (): Promise<SignInResource> => {
/**
* 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.
*/
if (!isWebAuthnSupported()) {
throw new ClerkRuntimeError('Passkeys are not supported', {
code: 'passkeys_unsupported',
});
}

if (!this.firstFactorVerification.nonce) {
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.create({ strategy: 'passkey' });
}

// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
const passKeyFactor = this.supportedFirstFactors.find(
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
f => f.strategy === 'passkey',
) as __experimental_PasskeyFactor;

if (!passKeyFactor) {
clerkVerifyPasskeyCalledBeforeCreate();
}

// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
await this.prepareFirstFactor(passKeyFactor);

const { nonce } = this.firstFactorVerification;
const publicKey = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null;

if (!publicKey) {
// TODO-PASSKEYS: Implement this later
throw 'Missing key';
}

// Invoke the WebAuthn get() method.
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions: publicKey,
conditionalUI: await isWebAuthnAutofillSupported(),
});

if (!publicKeyCredential) {
throw error;
}

return this.attemptFirstFactor({
publicKeyCredential,
// @ts-ignore As this is experimental we want to support it at runtime, but not at the type level
strategy: 'passkey',
});
};

validatePassword: ReturnType<typeof createValidatePassword> = (password, cb) => {
if (SignIn.clerk.__unstable__environment?.userSettings.passwordSettings) {
return createValidatePassword({
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Verification.ts
@@ -1,9 +1,9 @@
import { parseError } from '@clerk/shared/error';
import type {
__experimental_PublicKeyCredentialCreationOptionsWithoutExtensions,
ClerkAPIError,
PasskeyVerificationResource,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsWithoutExtensions,
SignUpVerificationJSON,
SignUpVerificationResource,
SignUpVerificationsJSON,
Expand Down Expand Up @@ -58,7 +58,7 @@ export class Verification extends BaseResource implements VerificationResource {
}

export class PasskeyVerification extends Verification implements PasskeyVerificationResource {
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions | null = null;
publicKey: __experimental_PublicKeyCredentialCreationOptionsWithoutExtensions | null = null;

constructor(data: VerificationJSON | null) {
super(data);
Expand Down
64 changes: 60 additions & 4 deletions packages/clerk-js/src/utils/__tests__/passkeys.test.ts
@@ -1,7 +1,17 @@
import type { PublicKeyCredentialCreationOptionsJSON } from '@clerk/types';
import type {
type __experimental_PublicKeyCredentialWithAuthenticatorAssertionResponse,
type __experimental_PublicKeyCredentialWithAuthenticatorAttestationResponse,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@clerk/types';

import type { PublicKeyCredentialWithAuthenticatorAttestationResponse } from '../passkeys';
import { bufferToBase64Url, convertJSONToPublicKeyCreateOptions, serializePublicKeyCredential } from '../passkeys';
import {
bufferToBase64Url,
convertJSONToPublicKeyCreateOptions,
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredential,
serializePublicKeyCredentialAssertion,
} from '../passkeys';

describe('Passkey utils', () => {
describe('serialization', () => {
Expand Down Expand Up @@ -57,8 +67,29 @@ describe('Passkey utils', () => {
expect(bufferToBase64Url(result.excludeCredentials[0].id)).toEqual(pkCreateOptions.excludeCredentials[0].id);
});

it('convertJSONToPublicKeyCreateOptions()', () => {
const pkCreateOptions: PublicKeyCredentialRequestOptionsJSON = {
rpId: 'clerk.com',
allowCredentials: [
{
type: 'public-key',
id: 'cmFuZG9tX2lk',
},
],
userVerification: 'required',
timeout: 10000,
challenge: 'Y2hhbGxlbmdlXzEyMw', // challenge_123 encoded as base64url
};

const result = convertJSONToPublicKeyRequestOptions(pkCreateOptions);

expect(result.rpId).toEqual('clerk.com');
expect(result.userVerification).toEqual('required');
expect(bufferToBase64Url(result.allowCredentials[0].id)).toEqual(pkCreateOptions.allowCredentials[0].id);
});

it('serializePublicKeyCredential()', () => {
const publicKeyCredential: PublicKeyCredentialWithAuthenticatorAttestationResponse = {
const publicKeyCredential: __experimental_PublicKeyCredentialWithAuthenticatorAttestationResponse = {
type: 'public-key',
id: 'credentialId_123',
rawId: new Uint8Array([99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 73, 100, 95, 49, 50, 51]),
Expand All @@ -80,5 +111,30 @@ describe('Passkey utils', () => {
expect(result.response.attestationObject).toEqual('bElkXzE');
expect(result.response.transports).toEqual(['usb']);
});

it('serializePublicKeyCredentialAssertion()', () => {
const publicKeyCredential: __experimental_PublicKeyCredentialWithAuthenticatorAssertionResponse = {
type: 'public-key',
id: 'credentialId_123',
rawId: new Uint8Array([99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 73, 100, 95, 49, 50, 51]),
authenticatorAttachment: 'cross-platform',
response: {
clientDataJSON: new Uint8Array([110, 116, 105, 97]),
signature: new Uint8Array([108, 73, 100, 95, 49]),
authenticatorData: new Uint8Array([108, 73, 100, 95, 49]),
userHandle: null,
},
};

const result = serializePublicKeyCredentialAssertion(publicKeyCredential);

expect(result.type).toEqual('public-key');
expect(result.id).toEqual('credentialId_123');
expect(result.rawId).toEqual('Y3JlZGVudGlhbElkXzEyMw');

expect(result.response.clientDataJSON).toEqual('bnRpYQ');
expect(result.response.signature).toEqual('bElkXzE');
expect(result.response.userHandle).toEqual(null);
});
});
});

0 comments on commit 1f650f3

Please sign in to comment.