-
Notifications
You must be signed in to change notification settings - Fork 458
feat(clerk-js,ui,shared,localizations): Support passkey as a second factor #9127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@clerk/shared': minor | ||
| '@clerk/clerk-js': minor | ||
| '@clerk/ui': minor | ||
| '@clerk/localizations': minor | ||
| --- | ||
|
|
||
| Support passkeys as a second factor during sign-in and session reverification. When the instance allows passkeys to satisfy the second factor and the user has a registered passkey, FAPI advertises a `passkey` entry in `supported_second_factors`; `signIn.authenticateWithPasskey()` now completes the second factor of an in-progress sign-in, `session.verifyWithPasskey({ level: 'second_factor' })` completes a multi-factor reverification, and the prebuilt `<SignIn/>` and `<UserVerification/>` flows offer the passkey alongside the enrolled second factors. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,7 @@ import { | |
| clerkInvalidFAPIResponse, | ||
| clerkInvalidStrategy, | ||
| clerkMissingOptionError, | ||
| clerkMissingPasskeySecondFactor, | ||
| clerkMissingWebAuthnPublicKeyOptions, | ||
| clerkVerifyEmailAddressCalledBeforeCreate, | ||
| clerkVerifyPasskeyCalledBeforeCreate, | ||
|
|
@@ -360,8 +361,19 @@ export class SignIn extends BaseResource implements SignInResource { | |
|
|
||
| attemptSecondFactor = (params: AttemptSecondFactorParams): Promise<SignInResource> => { | ||
| debugLogger.debug('SignIn.attemptSecondFactor', { id: this.id, strategy: params.strategy }); | ||
| let config; | ||
| switch (params.strategy) { | ||
| case 'passkey': | ||
| config = { | ||
| publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(params.publicKeyCredential)), | ||
| }; | ||
| break; | ||
| default: | ||
| config = { ...params }; | ||
| } | ||
|
|
||
| return this._basePost({ | ||
| body: params, | ||
| body: { ...config, strategy: params.strategy }, | ||
| action: 'attempt_second_factor', | ||
| }); | ||
| }; | ||
|
|
@@ -560,6 +572,42 @@ export class SignIn extends BaseResource implements SignInResource { | |
| }); | ||
| } | ||
|
|
||
| // When the sign-in already awaits its second factor, the passkey acts as | ||
| // the second factor: run the discrete prepare/attempt second-factor flow | ||
| // against the in-progress sign-in. `flow` is ignored here — autofill and | ||
| // discoverable are identifier-first concepts, and their `create()` call | ||
| // would discard the in-progress sign-in. | ||
| const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust'; | ||
| if (isSecondFactor) { | ||
| const passkeySecondFactor = (this.supportedSecondFactors || []).find(f => f.strategy === 'passkey'); | ||
| if (!passkeySecondFactor) { | ||
| clerkMissingPasskeySecondFactor(); | ||
| } | ||
|
|
||
| await this.prepareSecondFactor({ strategy: 'passkey' }); | ||
|
|
||
| const { nonce: secondFactorNonce } = this.secondFactorVerification; | ||
| const secondFactorPublicKeyOptions = secondFactorNonce | ||
| ? convertJSONToPublicKeyRequestOptions(JSON.parse(secondFactorNonce)) | ||
| : null; | ||
| if (!secondFactorPublicKeyOptions) { | ||
| clerkMissingWebAuthnPublicKeyOptions('get'); | ||
| } | ||
|
|
||
| const { publicKeyCredential, error } = await webAuthnGetCredential({ | ||
| publicKeyOptions: secondFactorPublicKeyOptions, | ||
| conditionalUI: false, | ||
| }); | ||
| if (!publicKeyCredential) { | ||
| throw error; | ||
| } | ||
|
|
||
| return this.attemptSecondFactor({ | ||
| publicKeyCredential, | ||
| strategy: 'passkey', | ||
| }); | ||
| } | ||
|
|
||
|
Comment on lines
+575
to
+610
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Document the new second-factor behavior of The public method now reuses in-progress second-factor/client-trust sign-ins and ignores As per coding guidelines, “All public APIs must be documented with JSDoc” and “Update documentation for API changes.” As per path instructions, changed public API JSDoc may affect generated Clerk Docs. 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
| if (flow === 'autofill' || flow === 'discoverable') { | ||
| // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level | ||
| await this.create({ strategy: 'passkey' }); | ||
|
|
@@ -777,6 +825,7 @@ class SignInFuture implements SignInFutureResource { | |
| verifyEmailCode: this.verifyMFAEmailCode.bind(this), | ||
| verifyTOTP: this.verifyTOTP.bind(this), | ||
| verifyBackupCode: this.verifyBackupCode.bind(this), | ||
| passkey: this.verifyMFAPasskey.bind(this), | ||
| }; | ||
|
|
||
| #canBeDiscarded = false; | ||
|
|
@@ -1501,6 +1550,55 @@ class SignInFuture implements SignInFutureResource { | |
| }); | ||
| } | ||
|
|
||
| async verifyMFAPasskey(): Promise<{ error: ClerkError | null }> { | ||
| /** | ||
| * 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; | ||
|
|
||
| if (!isWebAuthnSupported()) { | ||
| throw new ClerkWebAuthnError('Passkeys are not supported', { | ||
| code: 'passkey_not_supported', | ||
| }); | ||
| } | ||
|
|
||
| return runAsyncResourceTask(this.#resource, async () => { | ||
| const passkeyFactor = this.#resource.supportedSecondFactors?.find(f => f.strategy === 'passkey'); | ||
| if (!passkeyFactor) { | ||
| throw new ClerkRuntimeError('Passkey factor not found', { code: 'factor_not_found' }); | ||
| } | ||
|
|
||
| await this.#resource.__internal_basePost({ | ||
| body: { strategy: 'passkey' }, | ||
| action: 'prepare_second_factor', | ||
| }); | ||
|
|
||
| const { nonce } = this.#resource.secondFactorVerification; | ||
| const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; | ||
| if (!publicKeyOptions) { | ||
| throw new ClerkRuntimeError('Missing public key options', { code: 'missing_public_key_options' }); | ||
| } | ||
|
|
||
| const { publicKeyCredential, error } = await webAuthnGetCredential({ | ||
| publicKeyOptions, | ||
| conditionalUI: false, | ||
| }); | ||
| if (!publicKeyCredential) { | ||
| throw new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_failed' }); | ||
| } | ||
|
|
||
| await this.#resource.__internal_basePost({ | ||
| body: { | ||
| publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)), | ||
| strategy: 'passkey', | ||
| }, | ||
| action: 'attempt_second_factor', | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async ticket(params?: SignInFutureTicketParams): Promise<{ error: ClerkError | null }> { | ||
| const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket'); | ||
| return this.create({ strategy: 'ticket', ticket: ticket ?? undefined }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Reject invalid verification levels instead of silently downgrading.
Line 321 uses
||, while Lines 324-329 treat every value except'second_factor'as first factor. Invalid JavaScript/config input can therefore request step-up verification but receive lower-assurance first-factor verification. Use??for the default and explicitly reject values outside the two supported levels.As per coding guidelines, “Validate all inputs” and “Follow OWASP security best practices.”
🤖 Prompt for AI Agents
Source: Coding guidelines
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document the new public verification-level contract.
Add JSDoc covering
params.level, its default, return value, and error behavior. This API change may also require Docs-team review.As per coding guidelines, “All public APIs must be documented with JSDoc” and “Update documentation for API changes.” As per path instructions, public API JSDoc is customer-facing generated documentation.
🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions