Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e70b37d
feat(elements): Add support for sign in with passkey
panteliselef May 30, 2024
c2d7611
feat(elements): Input field with passkey autofill
panteliselef May 30, 2024
69e387f
feat(elements): Handle submit attempts when attempting passkey autofill
panteliselef May 30, 2024
ac84712
chore(elements): Remove redundant types
panteliselef May 31, 2024
4af33d8
chore(elements): Improve code readability
panteliselef May 31, 2024
d12ecec
chore(elements): Add changeset
panteliselef May 31, 2024
f830e31
fix(elements): Add missing file
panteliselef May 31, 2024
51d7def
chore(elements): Use dot notation for event type
panteliselef Jun 3, 2024
01c42bf
chore(elements): Introduce `isNonPreperableStrategy`
panteliselef Jun 3, 2024
4737f40
chore(shared,clerk-js,elements): Move webauthn utilities to shared
panteliselef Jun 3, 2024
d1ed9a9
Merge branch 'refs/heads/main' into elef/passkey-elements
panteliselef Jun 3, 2024
7db4a1b
chore(elements): Sanitize props
panteliselef Jun 3, 2024
0122832
chore(elements): Make passkey a standalone component
panteliselef Jun 4, 2024
4c9e102
chore(elements): Update changeset
panteliselef Jun 4, 2024
6fadabd
chore(elements): Move autofill support detection inside an actor
panteliselef Jun 4, 2024
376190d
adjust changeset
LekoArts Jun 5, 2024
12e6257
Address PR comments
panteliselef Jun 5, 2024
f899070
Address tests failing
panteliselef Jun 5, 2024
0f5c3e3
Improve error handling
panteliselef Jun 5, 2024
1d3344e
chore(elements): Drop `passkeyAutofill` and depend on `webauthn`
panteliselef Jun 6, 2024
32344a9
Merge branch 'main' into elef/passkey-elements
panteliselef Jun 10, 2024
3a0356d
chore(*): Update changeset
panteliselef Jun 10, 2024
88477f4
chore(elements): Detect webauthn in with type `tel`
panteliselef Jun 11, 2024
d16809f
Merge branch 'main' into elef/passkey-elements
panteliselef Jun 11, 2024
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
52 changes: 52 additions & 0 deletions .changeset/fuzzy-bees-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@clerk/elements': minor
---

Support passkeys in `<SignIn>` flows.

APIs introduced:
- `<SignIn.Passkey />`
- `<SignIn.SupportedStrategy name='passkey'>`
- `<SignIn.Strategy name='passkey'>`
- Detects the usage of `webauthn` to trigger passkey autofill `<Clerk.Input autoComplete="webauthn" />`

Usage examples:
- `<SignIn.Action passkey />`
```tsx
<SignIn.Step name='start'>
<SignIn.Passkey>
<Clerk.Loading>
{isLoading => (isLoading ? <Spinner /> : 'Use passkey instead')}.
</Clerk.Loading>
</SignIn.Passkey>
</SignIn.Step>
```

- `<SignIn.SupportedStrategy name='passkey'>`
```tsx
<SignIn.SupportedStrategy asChild name='passkey' >
<Button>use passkey</Button>
</SignIn.SupportedStrategy>
```

- `<SignIn.Strategy name='passkey'>`
```tsx
<SignIn.Strategy name='passkey'>
<p className='text-sm'>
Welcome back <SignIn.Salutation />!
</p>

<CustomSubmit>Continue with Passkey</CustomSubmit>
</SignIn.Strategy>
```

- Passkey Autofill
```tsx
<SignIn.Step name='start'>
<Clerk.Field name='identifier'>
<Clerk.Label className='sr-only'>Email</Clerk.Label>
<Clerk.Input autoComplete="webauthn" placeholder='Enter your email address' />
<Clerk.FieldError />
</Clerk.Field>
</SignIn.Step>
```
6 changes: 6 additions & 0 deletions .changeset/plenty-eels-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Move `isWebAuthnSupported`, `isWebAuthnAutofillSupported`, `isWebAuthnPlatformAuthenticatorSupported` to `@clerk/shared/webauthn`.
9 changes: 2 additions & 7 deletions packages/clerk-js/src/core/resources/Passkey.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isWebAuthnPlatformAuthenticatorSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import type {
DeletedObjectJSON,
DeletedObjectResource,
Expand All @@ -9,13 +10,7 @@ import type {
} from '@clerk/types';

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

Expand Down
3 changes: 1 addition & 2 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { deepSnakeToCamel, Poller } from '@clerk/shared';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import type {
AttemptFirstFactorParams,
AttemptSecondFactorParams,
Expand Down Expand Up @@ -34,8 +35,6 @@ import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate }
import {
ClerkWebAuthnError,
convertJSONToPublicKeyRequestOptions,
isWebAuthnAutofillSupported,
isWebAuthnSupported,
serializePublicKeyCredentialAssertion,
webAuthnGetCredential,
} from '../../utils/passkeys';
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useClerk } from '@clerk/shared/react';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import type { ClerkAPIError, SignInCreateParams } from '@clerk/types';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { ERROR_CODES } from '../../../core/constants';
import { clerkInvalidFAPIResponse } from '../../../core/errors';
import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '../../../utils/passkeys';
import type { SignInStartIdentifier } from '../../common';
import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn } from '../../common';
import { buildSSOCallbackURL } from '../../common/redirects';
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/components/SignIn/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { titleize } from '@clerk/shared';
import { isWebAuthnSupported } from '@clerk/shared/webauthn';
import type { PreferredSignInStrategy, SignInFactor, SignInResource, SignInStrategy } from '@clerk/types';

import { isWebAuthnSupported } from '../../../utils/passkeys';
import { PREFERRED_SIGN_IN_STRATEGIES } from '../../common/constants';
import { otpPrefFactorComparator, passwordPrefFactorComparator } from '../../utils/factorSorting';

Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isWebAuthnSupported } from '@clerk/shared/webauthn';
import type { SignInFactor } from '@clerk/types';

import { isWebAuthnSupported } from '../../utils/passkeys';
import { factorHasLocalStrategy, isResetPasswordStrategy } from '../components/SignIn/utils';
import { useCoreSignIn } from '../contexts';
import { allStrategiesButtonsComparator } from '../utils';
Expand Down
31 changes: 0 additions & 31 deletions packages/clerk-js/src/utils/passkeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isValidBrowser } from '@clerk/shared/browser';
import { ClerkRuntimeError } from '@clerk/shared/error';
import type {
PublicKeyCredentialCreationOptionsJSON,
Expand Down Expand Up @@ -36,33 +35,6 @@ type ClerkWebAuthnErrorCode =
| 'passkey_registration_cancelled'
| 'passkey_registration_failed';

function isWebAuthnSupported() {
return (
isValidBrowser() &&
// Check if `PublicKeyCredential` is a constructor
typeof window.PublicKeyCredential === 'function'
);
}

async function isWebAuthnAutofillSupported(): Promise<boolean> {
try {
return isWebAuthnSupported() && (await window.PublicKeyCredential.isConditionalMediationAvailable());
} catch (e) {
return false;
}
}

async function isWebAuthnPlatformAuthenticatorSupported(): Promise<boolean> {
try {
return (
typeof window !== 'undefined' &&
(await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable())
);
} catch (e) {
return false;
}
}

class Base64Converter {
static encode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
Expand Down Expand Up @@ -284,9 +256,6 @@ export class ClerkWebAuthnError extends ClerkRuntimeError {
}

export {
isWebAuthnPlatformAuthenticatorSupported,
isWebAuthnAutofillSupported,
isWebAuthnSupported,
base64UrlToBuffer,
bufferToBase64Url,
handlePublicKeyCreateError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ export default function SignInPage() {
<CustomProvider provider='google'>Continue with Google</CustomProvider>
</div>

<SignIn.Passkey className='inline-flex px-7 py-3 justify-center transition rounded-lg focus:outline-none border items-center disabled:bg-[rgb(12,12,12)] focus:text-[rgb(255,255,255)] w-full duration-300 focus:!border-[rgb(37,37,37)] text-sm space-x-1.5 text-[rgb(160,160,160)] hover:text-[rgb(243,243,243)] disabled:text-[rgb(100,100,100)] select-none bg-[rgb(22,22,22)] hover:bg-[rgb(22,22,30)] border-[rgb(37,37,37)] hover:border-[rgb(50,50,50)]'>
<Clerk.Loading>{isLoading => (isLoading ? <Spinner /> : 'Use passkey instead')}</Clerk.Loading>
</SignIn.Passkey>

{continueWithEmail ? (
<>
<Clerk.Field
Expand Down Expand Up @@ -227,6 +231,13 @@ export default function SignInPage() {
<Button>Send a code to your phone</Button>
</SignIn.SupportedStrategy>

<SignIn.SupportedStrategy
asChild
name='passkey'
>
<Button>use passkey</Button>
</SignIn.SupportedStrategy>

<SignIn.SupportedStrategy
asChild
name='email_code'
Expand Down Expand Up @@ -279,6 +290,14 @@ export default function SignInPage() {
<div className='flex gap-6 flex-col'>
<Clerk.GlobalError className='block text-red-400 font-mono' />

<SignIn.Strategy name='passkey'>
<P className='text-sm'>
Welcome back <SignIn.Salutation />!
</P>

<CustomSubmit>Continue with Passkey</CustomSubmit>
</SignIn.Strategy>

<SignIn.Strategy name='password'>
<P className='text-sm'>
Welcome back <SignIn.Salutation />!
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { joinURL } from '@clerk/shared/url';
import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn';
import type { SignInStatus } from '@clerk/types';
import type { NonReducibleUnknown } from 'xstate';
import { and, assign, enqueueActions, not, or, raise, sendTo, setup } from 'xstate';
import { and, assign, enqueueActions, fromPromise, not, or, raise, sendTo, setup } from 'xstate';

import {
ERROR_CODES,
Expand Down Expand Up @@ -48,6 +49,7 @@ export const SignInRouterMachine = setup({
startMachine: SignInStartMachine,
secondFactorMachine: SignInSecondFactorMachine,
thirdPartyMachine: ThirdPartyMachine,
webAuthnAutofillSupport: fromPromise(() => isWebAuthnAutofillSupported()),
},
actions: {
clearFormErrors: sendTo(({ context }) => context.formRef, { type: 'ERRORS.CLEAR' }),
Expand Down Expand Up @@ -220,6 +222,13 @@ export const SignInRouterMachine = setup({
},
states: {
Idle: {
invoke: {
id: 'webAuthnAutofill',
src: 'webAuthnAutofillSupport',
onDone: {
actions: assign({ webAuthnAutofillSupport: ({ event }) => event.output }),
},
},
on: {
INIT: {
actions: assign(({ event }) => ({
Expand Down Expand Up @@ -319,6 +328,12 @@ export const SignInRouterMachine = setup({
target: 'Start',
reenter: true,
},
'AUTHENTICATE.PASSKEY': {
actions: sendTo('start', ({ event }) => event),
},
'AUTHENTICATE.PASSKEY.AUTOFILL': {
actions: sendTo('start', ({ event }) => event),
},
NEXT: [
{
guard: 'isComplete',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export type SignInRouterResetStepEvent = BaseRouterResetStepEvent;
export type SignInRouterLoadingEvent = BaseRouterLoadingEvent<'start' | 'verifications' | 'reset-password'>;
export type SignInRouterSetClerkEvent = BaseRouterSetClerkEvent;
export type SignInRouterSubmitEvent = { type: 'SUBMIT' };
export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' };
export type SignInRouterPasskeyAutofillEvent = {
type: 'AUTHENTICATE.PASSKEY.AUTOFILL';
};

export interface SignInRouterInitEvent extends BaseRouterInput {
type: 'INIT';
Expand All @@ -89,7 +93,9 @@ export type SignInRouterEvents =
| SignInVerificationFactorUpdateEvent
| SignInRouterLoadingEvent
| SignInRouterSetClerkEvent
| SignInRouterSubmitEvent;
| SignInRouterSubmitEvent
| SignInRouterPasskeyEvent
| SignInRouterPasskeyAutofillEvent;

// ---------------------------------- Context ---------------------------------- //

Expand All @@ -99,6 +105,7 @@ export interface SignInRouterContext extends BaseRouterContext {
formRef: ActorRefFrom<TFormMachine>;
loading: SignInRouterLoadingContext;
signUpPath: string;
webAuthnAutofillSupport: boolean;
}

// ---------------------------------- Input ---------------------------------- //
Expand Down
66 changes: 66 additions & 0 deletions packages/elements/src/internals/machines/sign-in/start.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const SignInStartMachineId = 'SignInStart';

export const SignInStartMachine = setup({
actors: {
attemptPasskey: fromPromise<
SignInResource,
{ parent: SignInRouterMachineActorRef; flow: 'autofill' | 'discoverable' | undefined }
>(({ input: { parent, flow } }) => {
return parent.getSnapshot().context.clerk.client.signIn.authenticateWithPasskey({
flow,
});
}),
attempt: fromPromise<SignInResource, { parent: SignInRouterMachineActorRef; fields: FormFields }>(
({ input: { fields, parent } }) => {
const clerk = parent.getSnapshot().context.clerk;
Expand Down Expand Up @@ -76,6 +84,16 @@ export const SignInStartMachine = setup({
target: 'Attempting',
reenter: true,
},
'AUTHENTICATE.PASSKEY': {
guard: not('isExampleMode'),
target: 'AttemptingPasskey',
reenter: true,
},
'AUTHENTICATE.PASSKEY.AUTOFILL': {
guard: not('isExampleMode'),
target: 'AttemptingPasskeyAutoFill',
reenter: false,
},
},
},
Attempting: {
Expand All @@ -97,5 +115,53 @@ export const SignInStartMachine = setup({
},
},
},
AttemptingPasskey: {
tags: ['state:attempting', 'state:loading'],
entry: 'sendToLoading',
invoke: {
id: 'attemptPasskey',
src: 'attemptPasskey',
input: ({ context }) => ({
parent: context.parent,
flow: 'discoverable',
}),
onDone: {
actions: ['sendToNext', 'sendToLoading'],
},
onError: {
actions: ['setFormErrors', 'sendToLoading'],
target: 'Pending',
},
},
},
AttemptingPasskeyAutoFill: {
on: {
'AUTHENTICATE.PASSKEY': {
guard: not('isExampleMode'),
target: 'AttemptingPasskey',
reenter: true,
},
SUBMIT: {
guard: not('isExampleMode'),
target: 'Attempting',
reenter: true,
},
},
Comment on lines +138 to +149
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.

Enabling "autofill" is kind of fire and forget action, so we should be able to recover from this state and not get stuck. Promise will await until user interact with the field and select a passkey and that may never happen.

invoke: {
id: 'attemptPasskeyAutofill',
src: 'attemptPasskey',
input: ({ context }) => ({
parent: context.parent,
flow: 'autofill',
}),
onDone: {
actions: ['sendToNext'],
},
onError: {
actions: ['setFormErrors'],
target: 'Pending',
},
},
},
},
});
11 changes: 9 additions & 2 deletions packages/elements/src/internals/machines/sign-in/start.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ export type SignInStartTags = 'state:pending' | 'state:attempting' | 'state:load
// ---------------------------------- Events ---------------------------------- //

export type SignInStartSubmitEvent = { type: 'SUBMIT' };

export type SignInStartEvents = ErrorActorEvent | SignInStartSubmitEvent | DoneActorEvent;
export type SignInStartPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' };
export type SignInStartPasskeyAutofillEvent = { type: 'AUTHENTICATE.PASSKEY.AUTOFILL' };

export type SignInStartEvents =
| ErrorActorEvent
| SignInStartSubmitEvent
| SignInStartPasskeyEvent
| SignInStartPasskeyAutofillEvent
| DoneActorEvent;

// ---------------------------------- Input ---------------------------------- //

Expand Down
Loading