-
Notifications
You must be signed in to change notification settings - Fork 474
fix(clerk-js): reuse pending passkey challenge across autofill calls #9714
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,5 @@ | ||
| --- | ||
| '@clerk/clerk-js': patch | ||
| --- | ||
|
|
||
| Reuse a pending passkey challenge when `authenticateWithPasskey` runs again with the `autofill` or `discoverable` flow, instead of creating a new sign-in attempt on every call. A sign-in form that mounts several times in a row no longer issues one `POST /v1/client/sign_ins` per mount. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1855,6 +1855,90 @@ describe('SignIn', () => { | |
| expect(mockWebAuthnGetCredential).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| describe('pending passkey challenge', () => { | ||
| const credential = { | ||
| id: 'credential_123', | ||
| rawId: new ArrayBuffer(32), | ||
| response: { | ||
| authenticatorData: new ArrayBuffer(37), | ||
| clientDataJSON: new ArrayBuffer(121), | ||
| signature: new ArrayBuffer(64), | ||
| userHandle: null, | ||
| }, | ||
| type: 'public-key', | ||
| }; | ||
|
|
||
| const createResponse = (expireAt: number) => ({ | ||
| client: null, | ||
| response: { | ||
| id: 'signin_123', | ||
| first_factor_verification: { | ||
| strategy: 'passkey', | ||
| status: 'unverified', | ||
|
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 Cover the The fixture always uses As per coding guidelines: “Unit tests are required for all new functionality” and “Include tests for all new features.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), | ||
| expire_at: expireAt, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const isCreate = (call: any[]) => | ||
| call[0].path === '/client/sign_ins' && !('publicKeyCredential' in call[0].body); | ||
|
|
||
| const setup = (expireAt: number) => { | ||
| const mockWebAuthnGetCredential = vi | ||
| .fn() | ||
| .mockResolvedValueOnce({ publicKeyCredential: null, error: new Error('aborted') }) | ||
| .mockResolvedValueOnce({ publicKeyCredential: credential, error: null }); | ||
|
|
||
| SignIn.clerk = { | ||
| __internal_isWebAuthnSupported: vi.fn().mockReturnValue(true), | ||
| __internal_isWebAuthnAutofillSupported: vi.fn().mockResolvedValue(true), | ||
| __internal_getPublicCredentials: mockWebAuthnGetCredential, | ||
| __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, | ||
| } as any; | ||
|
|
||
| const mockFetch = vi | ||
| .fn() | ||
| .mockResolvedValueOnce(createResponse(expireAt)) | ||
| .mockResolvedValueOnce(createResponse(expireAt)) | ||
| .mockResolvedValueOnce({ client: null, response: { id: 'signin_123', status: 'complete' } }); | ||
| BaseResource._fetch = mockFetch; | ||
| return mockFetch; | ||
| }; | ||
|
|
||
| it('reuses the challenge when autofill runs again', async () => { | ||
| const mockFetch = setup(Date.now() + 60_000); | ||
| const signIn = new SignIn(); | ||
|
|
||
| const first = await signIn.__internal_future.passkey({ flow: 'autofill' }); | ||
| expect(first.error).not.toBeNull(); | ||
| const second = await signIn.__internal_future.passkey({ flow: 'autofill' }); | ||
| expect(second.error).toBeNull(); | ||
|
|
||
| expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('creates a new sign-in when the challenge expired', async () => { | ||
| const mockFetch = setup(Date.now() - 1_000); | ||
| const signIn = new SignIn(); | ||
|
|
||
| await signIn.__internal_future.passkey({ flow: 'autofill' }); | ||
| await signIn.__internal_future.passkey({ flow: 'autofill' }); | ||
|
|
||
| expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('reuses the challenge in authenticateWithPasskey', async () => { | ||
| const mockFetch = setup(Date.now() + 60_000); | ||
| const signIn = new SignIn(); | ||
|
|
||
| await expect(signIn.authenticateWithPasskey({ flow: 'autofill' })).rejects.toThrow('aborted'); | ||
| await signIn.authenticateWithPasskey({ flow: 'autofill' }); | ||
|
|
||
| expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(1); | ||
| }); | ||
| }); | ||
|
|
||
| it('creates signIn with passkey for discoverable flow', async () => { | ||
| const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); | ||
| const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ | ||
|
|
||
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.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: clerk/javascript
Length of output: 50373
🏁 Script executed:
Repository: clerk/javascript
Length of output: 38483
🏁 Script executed:
Repository: clerk/javascript
Length of output: 12409
🏁 Script executed:
Repository: clerk/javascript
Length of output: 23864
Serialize pending passkey challenge creation.
If two calls start before the first response updates
firstFactorVerification, both calls can see no pending challenge.BaseResource._basePostdoes not coalesceSignIn.createor the future API’s_createcall because neither passescoalesce: true. This can issue duplicate sign-in creations and may trigger the sign-in creation rate limit. Store and await one shared in-flight creation promise across both entry points.📍 Affects 1 file
packages/clerk-js/src/core/resources/SignIn.ts#L592-L592(this comment)packages/clerk-js/src/core/resources/SignIn.ts#L1418-L1418🤖 Prompt for AI Agents