Skip to content

Commit

Permalink
feat(clerk-expo): Support expo passkeys (#4352)
Browse files Browse the repository at this point in the history
Co-authored-by: Stefanos Anagnostou <anagstef@users.noreply.github.com>
Co-authored-by: Stefanos Anagnostou <stefanos@clerk.dev>
  • Loading branch information
3 people authored Nov 6, 2024
1 parent b064f52 commit e199037
Show file tree
Hide file tree
Showing 19 changed files with 1,667 additions and 5,843 deletions.
50 changes: 50 additions & 0 deletions .changeset/late-camels-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@clerk/clerk-js": minor
"@clerk/shared": minor
"@clerk/types": minor
"@clerk/clerk-expo": minor
"@clerk/expo-passkeys": patch
---

Introduce experimental support for passkeys in Expo (iOS, Android, and Web).

To use passkeys in Expo projects, pass the `__experimental_passkeys` object, which can be imported from `@clerk/clerk-expo/passkeys`, to the `ClerkProvider` component:

```tsx

import { ClerkProvider } from '@clerk/clerk-expo';
import { passkeys } from '@clerk/clerk-expo/passkeys';

<ClerkProvider __experimental_passkeys={passkeys}>
{/* Your app here */}
</ClerkProvider>
```

The API for using passkeys in Expo projects is the same as the one used in web apps:

```tsx
// passkey creation
const { user } = useUser();

const handleCreatePasskey = async () => {
if (!user) return;
try {
return await user.createPasskey();
} catch (e: any) {
// handle error
}
};


// passkey authentication
const { signIn, setActive } = useSignIn();

const handlePasskeySignIn = async () => {
try {
const signInResponse = await signIn.authenticateWithPasskey();
await setActive({ session: signInResponse.createdSessionId });
} catch (err: any) {
//handle error
}
};
```
46 changes: 3 additions & 43 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ClientResource,
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentResource,
Expand All @@ -36,6 +37,10 @@ import type {
OrganizationProfileProps,
OrganizationResource,
OrganizationSwitcherProps,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
SDKMetadata,
Expand Down Expand Up @@ -185,6 +190,24 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;

public __internal_createPublicCredentials:
| ((
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions,
) => Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAttestationResponse>>)
| undefined;

public __internal_getPublicCredentials:
| (({
publicKeyOptions,
}: {
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
}) => Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>>)
| undefined;

public __internal_isWebAuthnSupported: (() => boolean) | undefined;
public __internal_isWebAuthnAutofillSupported: (() => Promise<boolean>) | undefined;
public __internal_isWebAuthnPlatformAuthenticatorSupported: (() => Promise<boolean>) | undefined;

get publishableKey(): string {
return this.#publishableKey;
}
Expand Down
19 changes: 16 additions & 3 deletions packages/clerk-js/src/core/resources/Passkey.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { isWebAuthnPlatformAuthenticatorSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import { ClerkWebAuthnError } from '@clerk/shared/error';
import {
isWebAuthnPlatformAuthenticatorSupported as isWebAuthnPlatformAuthenticatorSupportedOnWindow,
isWebAuthnSupported as isWebAuthnSupportedOnWindow,
} from '@clerk/shared/webauthn';
import type {
DeletedObjectJSON,
DeletedObjectResource,
Expand All @@ -10,7 +14,10 @@ import type {
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { ClerkWebAuthnError, serializePublicKeyCredential, webAuthnCreateCredential } from '../../utils/passkeys';
import {
serializePublicKeyCredential,
webAuthnCreateCredential as webAuthnCreateCredentialOnWindow,
} from '../../utils/passkeys';
import { clerkMissingWebAuthnPublicKeyOptions } from '../errors';
import { BaseResource, DeletedObject, PasskeyVerification } from './internal';

Expand Down Expand Up @@ -55,6 +62,13 @@ export class Passkey extends BaseResource implements PasskeyResource {
* 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 = Passkey.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow;
const webAuthnCreateCredential =
Passkey.clerk.__internal_createPublicCredentials || webAuthnCreateCredentialOnWindow;
const isWebAuthnPlatformAuthenticatorSupported =
Passkey.clerk.__internal_isWebAuthnPlatformAuthenticatorSupported ||
isWebAuthnPlatformAuthenticatorSupportedOnWindow;

if (!isWebAuthnSupported()) {
throw new ClerkWebAuthnError('Passkeys are not supported on this device.', {
code: 'passkey_not_supported',
Expand Down Expand Up @@ -89,7 +103,6 @@ export class Passkey extends BaseResource implements PasskeyResource {
if (!publicKeyCredential) {
throw error;
}

return this.attemptVerification(passkey.id, publicKeyCredential);
}

Expand Down
15 changes: 12 additions & 3 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ClerkWebAuthnError } from '@clerk/shared/error';
import { Poller } from '@clerk/shared/poller';
import { deepSnakeToCamel } from '@clerk/shared/underscore';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import {
isWebAuthnAutofillSupported as isWebAuthnAutofillSupportedOnWindow,
isWebAuthnSupported as isWebAuthnSupportedOnWindow,
} from '@clerk/shared/webauthn';
import type {
AttemptFirstFactorParams,
AttemptSecondFactorParams,
Expand Down Expand Up @@ -41,10 +45,9 @@ import {
windowNavigate,
} from '../../utils';
import {
ClerkWebAuthnError,
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredentialAssertion,
webAuthnGetCredential,
webAuthnGetCredential as webAuthnGetCredentialOnWindow,
} from '../../utils/passkeys';
import { createValidatePassword } from '../../utils/passwords/password';
import {
Expand Down Expand Up @@ -304,6 +307,12 @@ export class SignIn extends BaseResource implements 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.
*/

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',
Expand Down
40 changes: 3 additions & 37 deletions packages/clerk-js/src/utils/passkeys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ClerkRuntimeError } from '@clerk/shared/error';
import type { ClerkRuntimeError } from '@clerk/shared/error';
import { ClerkWebAuthnError } from '@clerk/shared/error';
import type {
CredentialReturn,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsJSON,
Expand All @@ -8,33 +10,9 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
} from '@clerk/types';

type CredentialReturn<T> =
| {
publicKeyCredential: T;
error: null;
}
| {
publicKeyCredential: null;
error: ClerkWebAuthnError | Error;
};

type WebAuthnCreateCredentialReturn = CredentialReturn<PublicKeyCredentialWithAuthenticatorAttestationResponse>;
type WebAuthnGetCredentialReturn = CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>;

type ClerkWebAuthnErrorCode =
// Generic
| 'passkey_not_supported'
| 'passkey_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_registration_failed';

class Base64Converter {
static encode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
Expand Down Expand Up @@ -243,18 +221,6 @@ function serializePublicKeyCredentialAssertion(pkc: PublicKeyCredentialWithAuthe
const bufferToBase64Url = Base64Converter.encode.bind(Base64Converter);
const base64UrlToBuffer = Base64Converter.decode.bind(Base64Converter);

export class ClerkWebAuthnError extends ClerkRuntimeError {
/**
* A unique code identifying the error, can be used for localization.
*/
code: ClerkWebAuthnErrorCode;

constructor(message: string, { code }: { code: ClerkWebAuthnErrorCode }) {
super(message, { code });
this.code = code;
}
}

export {
base64UrlToBuffer,
bufferToBase64Url,
Expand Down
2 changes: 1 addition & 1 deletion packages/expo-passkeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
import { ClerkProvider } from '@clerk/clerk-expo';
import { passkeys } from '@clerk/clerk-expo/passkeys';

<ClerkProvider passkeys={passkeys}>{/* Your app here */}</ClerkProvider>;
<ClerkProvider __experimental_passkeys={passkeys}>{/* Your app here */}</ClerkProvider>;
```

### 🔑 Creating a Passkey
Expand Down
5 changes: 2 additions & 3 deletions packages/expo-passkeys/example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { ClerkProvider, SignedIn, SignedOut, useAuth, useSignIn, useUser } from '@clerk/clerk-expo';
import { passkeys } from '@clerk/clerk-expo/passkeys';
import * as SecureStore from 'expo-secure-store';
import React from 'react';
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';

import { passkeys } from '../src';

const tokenCache = {
async getToken(key: string) {
try {
Expand Down Expand Up @@ -143,7 +142,7 @@ export default function App() {
<ClerkProvider
publishableKey={publishableKey}
tokenCache={tokenCache}
passkeys={passkeys}
__experimental_passkeys={passkeys}
>
<View style={styles.container}>
<SignedIn>
Expand Down
Loading

0 comments on commit e199037

Please sign in to comment.