Skip to content

Commit 70a6432

Browse files
committed
fix(electron,ui): Don't run passkey autofill as a modal prompt
The Electron passkey provider reported autofill as supported but serviced the request as a modal ceremony, so an OS passkey sheet opened as soon as the sign-in form mounted, before any user intent. - `get()` now honours `conditionalUI` instead of hardcoding it to `false`, so the renderer path performs real conditional mediation. When a conditional request would resolve to the native or unsupported path it is quietly aborted rather than prompting, and it no longer falls back to the native path on error. - `isSupported()` and `isAutoFillSupported()` now answer through the same routing rules as `decidePath()`, so they stop advertising capabilities the resolved path cannot deliver. - `SignInStart` resolves both WebAuthn predicates against the Clerk instance before falling back to the shared helpers, so a host-provided passkey provider can influence whether the autofill flow starts at all. Closes #9496
1 parent 72ffc81 commit 70a6432

8 files changed

Lines changed: 262 additions & 14 deletions

File tree

‎.changeset/rude-pianos-smoke.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/electron': patch
3+
'@clerk/ui': patch
4+
---
5+
6+
Stop passkey autofill from opening a passkey prompt as soon as the sign-in form renders. Autofill now runs as a real background request when the window can service one (an `https` origin matching your RP ID), and is not attempted at all when it would route to the OS passkey dialog. Signing in with the explicit "Use passkey" action is unchanged.

‎packages/electron/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ Passkey support works in two modes, selected automatically per request:
212212
- **Renderer mode**: when your window loads content over `https://` from an origin that matches your passkey RP (Relying Party) ID, the renderer's built-in Chromium WebAuthn is used. Credentials are synced by the OS/browser ecosystem (Windows Hello works out of the box; Touch ID on macOS requires Electron ≥ 42 and [`app.configureWebAuthn`](https://www.electronjs.org/docs/latest/api/app#appconfigurewebauthnoptions-macos)).
213213
- **Native mode**: when your window loads a local bundle (e.g. `scheme://host`), WebAuthn's origin checks reject the request, so the ceremony is routed over IPC to the main process and serviced by the OS WebAuthn APIs (AuthenticationServices on macOS, `webauthn.dll` on Windows) via the optional [`@clerk/electron-passkeys`](https://github.com/clerk/javascript/tree/main/packages/electron-passkeys) native module.
214214

215+
Passkey autofill relies on WebAuthn conditional mediation, which only the renderer can provide. In native mode passkeys are offered through the explicit "Use passkey" action instead; the sign-in form never opens a passkey prompt on its own.
216+
215217
### Setup
216218

217219
Native mode requires the optional native module:

‎packages/electron/src/passkeys/__tests__/index.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,60 @@ describe('createPasskeys', () => {
294294
expect(bridge.get).toHaveBeenCalled();
295295
expect(result.error).toBeNull();
296296
});
297+
298+
it('forwards conditional UI to the renderer path', async () => {
299+
stubEnvironment({ bridge: makeBridge() });
300+
const rendererResult = { publicKeyCredential: {} as never, error: null };
301+
vi.mocked(webAuthnGetCredential).mockResolvedValue(rendererResult);
302+
303+
const result = await createPasskeys().get({ publicKeyOptions: requestOptions(), conditionalUI: true });
304+
305+
expect(webAuthnGetCredential).toHaveBeenCalledWith({
306+
publicKeyOptions: expect.anything(),
307+
conditionalUI: true,
308+
});
309+
expect(result).toBe(rendererResult);
310+
});
311+
312+
it('aborts a conditional request instead of prompting through the native path', async () => {
313+
const bridge = makeBridge();
314+
stubEnvironment({ protocol: 'clerk:', hostname: 'app', bridge });
315+
316+
const result = await createPasskeys().get({ publicKeyOptions: requestOptions(), conditionalUI: true });
317+
318+
expect(bridge.get).not.toHaveBeenCalled();
319+
expect(webAuthnGetCredential).not.toHaveBeenCalled();
320+
expect(result.publicKeyCredential).toBeNull();
321+
expect(result.error).toMatchObject({ code: 'passkey_operation_aborted' });
322+
});
323+
324+
it('aborts a conditional request rather than reporting it as unsupported', async () => {
325+
stubEnvironment({ protocol: 'clerk:', hostname: 'app' });
326+
327+
const result = await createPasskeys().get({ publicKeyOptions: requestOptions(), conditionalUI: true });
328+
329+
expect(result.error).toMatchObject({ code: 'passkey_operation_aborted' });
330+
});
331+
332+
it('does not retry natively when a conditional renderer request fails', async () => {
333+
const bridge = makeBridge();
334+
stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge });
335+
const rendererResult = {
336+
publicKeyCredential: null,
337+
error: Object.assign(new Error('The user agent does not support public key credentials.'), {
338+
name: 'NotSupportedError',
339+
}),
340+
};
341+
vi.mocked(webAuthnGetCredential).mockResolvedValue(rendererResult as never);
342+
343+
const result = await createPasskeys().get({
344+
publicKeyOptions: requestOptionsForRpId('localhost'),
345+
conditionalUI: true,
346+
});
347+
348+
expect(bridge.get).not.toHaveBeenCalled();
349+
expect(result).toBe(rendererResult);
350+
});
297351
});
298352

299353
describe('capability checks', () => {
@@ -308,6 +362,14 @@ describe('createPasskeys', () => {
308362
expect(createPasskeys().isSupported()).toBe(true);
309363
});
310364

365+
it('isSupported is false when every request would resolve to unsupported', () => {
366+
stubEnvironment({ protocol: 'clerk:', hostname: 'app' });
367+
expect(createPasskeys().isSupported()).toBe(false);
368+
369+
stubEnvironment({ protocol: 'clerk:', hostname: 'app', bridge: makeBridge() });
370+
expect(createPasskeys().isSupported()).toBe(true);
371+
});
372+
311373
it('isAutoFillSupported is false in native mode', async () => {
312374
stubEnvironment({ bridge: makeBridge() });
313375

@@ -316,6 +378,16 @@ describe('createPasskeys', () => {
316378
expect(isWebAuthnAutofillSupported).toHaveBeenCalledTimes(1);
317379
});
318380

381+
it('isAutoFillSupported is false when requests cannot take the renderer path', async () => {
382+
stubEnvironment({ protocol: 'clerk:', hostname: 'app', bridge: makeBridge() });
383+
await expect(createPasskeys().isAutoFillSupported()).resolves.toBe(false);
384+
385+
stubEnvironment({ bridge: makeBridge({ electronMajor: 39 }) });
386+
await expect(createPasskeys().isAutoFillSupported()).resolves.toBe(false);
387+
388+
expect(isWebAuthnAutofillSupported).not.toHaveBeenCalled();
389+
});
390+
319391
it('isPlatformAuthenticatorSupported prefers native capabilities when available', async () => {
320392
const bridge = makeBridge();
321393
stubEnvironment({ bridge });

‎packages/electron/src/passkeys/__tests__/strategy.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

33
import type { StrategyEnv } from '../renderer/strategy';
4-
import { decidePath, originSatisfiesRpId } from '../renderer/strategy';
4+
import { canUseRendererPath, decidePath, originSatisfiesRpId } from '../renderer/strategy';
55

66
const RP_ID = 'example.com';
77

@@ -117,3 +117,45 @@ describe('decidePath', () => {
117117
});
118118
});
119119
});
120+
121+
describe('canUseRendererPath', () => {
122+
it('is false without renderer WebAuthn', () => {
123+
expect(canUseRendererPath('auto', env({ hasWebAuthn: false }))).toBe(false);
124+
expect(canUseRendererPath('renderer', env({ hasWebAuthn: false }))).toBe(false);
125+
});
126+
127+
it('is false in native mode', () => {
128+
expect(canUseRendererPath('native', env())).toBe(false);
129+
});
130+
131+
it('is true in renderer mode regardless of origin', () => {
132+
expect(canUseRendererPath('renderer', env({ protocol: 'app:', hostname: 'bundle' }))).toBe(true);
133+
});
134+
135+
describe('auto mode', () => {
136+
it.each([
137+
['https:', 'example.com', true],
138+
['http:', 'localhost', true],
139+
['http:', '127.0.0.1', true],
140+
['http:', '[::1]', true],
141+
['http:', 'example.com', false],
142+
['file:', '', false],
143+
['app:', 'bundle', false],
144+
['clerk:', 'app', false],
145+
])('%s//%s -> %s', (protocol, hostname, expected) => {
146+
expect(canUseRendererPath('auto', env({ protocol, hostname }))).toBe(expected);
147+
});
148+
149+
it('is true for an https origin that does not match any particular RP ID', () => {
150+
expect(canUseRendererPath('auto', env({ hostname: 'other.com' }))).toBe(true);
151+
});
152+
153+
it('is false on macOS before Electron 42, where the request routes native', () => {
154+
expect(canUseRendererPath('auto', env({ electronMajor: 39 }))).toBe(false);
155+
});
156+
157+
it('is true on macOS before Electron 42 when native is unavailable', () => {
158+
expect(canUseRendererPath('auto', env({ electronMajor: 39, nativeAvailable: false }))).toBe(true);
159+
});
160+
});
161+
});

‎packages/electron/src/passkeys/index.ts‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { isWebAuthnAutofillSupported, isWebAuthnPlatformAuthenticatorSupported }
1111

1212
import { getPasskeyBridge, nativeCreateCredential, nativeGetCredential } from './renderer/native-bridge';
1313
import type { PasskeyMode, StrategyEnv } from './renderer/strategy';
14-
import { decidePath } from './renderer/strategy';
14+
import { canUseRendererPath, decidePath } from './renderer/strategy';
1515

1616
export type { PasskeyMode, PasskeyPath, StrategyEnv } from './renderer/strategy';
1717

@@ -29,6 +29,7 @@ export type PasskeySupport = {
2929
) => Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAttestationResponse>>;
3030
get: (args: {
3131
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
32+
conditionalUI?: boolean;
3233
}) => Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>>;
3334
isSupported: () => boolean;
3435
isAutoFillSupported: () => Promise<boolean>;
@@ -67,6 +68,14 @@ const unsupportedReturn = <T>(): CredentialReturn<T> =>
6768
),
6869
}) as CredentialReturn<T>;
6970

71+
const abortedReturn = <T>(): CredentialReturn<T> =>
72+
({
73+
publicKeyCredential: null,
74+
error: new ClerkWebAuthnError('Clerk: Conditional passkey requests require the renderer WebAuthn path.', {
75+
code: 'passkey_operation_aborted',
76+
}),
77+
}) as CredentialReturn<T>;
78+
7079
const shouldRetryNativeAfterRendererError = (error: unknown): boolean => {
7180
if (!error || typeof error !== 'object') {
7281
return false;
@@ -102,19 +111,30 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport
102111
return result;
103112
};
104113

105-
const get: PasskeySupport['get'] = async ({ publicKeyOptions }) => {
114+
const get: PasskeySupport['get'] = async ({ publicKeyOptions, conditionalUI = false }) => {
106115
const env = getEnv();
107116
const path = decidePath(publicKeyOptions.rpId ?? '', mode, env);
108117

118+
// Conditional runs without user intent, don't fall through to opening a prompt.
119+
if (conditionalUI && path !== 'renderer') {
120+
return abortedReturn();
121+
}
122+
109123
if (path === 'unsupported') {
110124
return unsupportedReturn();
111125
}
112126
if (path === 'native') {
113127
return nativeGetCredential(publicKeyOptions);
114128
}
115129

116-
const result = await webAuthnGetCredential({ publicKeyOptions, conditionalUI: false });
117-
if (result.error && shouldRetryNativeAfterRendererError(result.error) && mode === 'auto' && env.nativeAvailable) {
130+
const result = await webAuthnGetCredential({ publicKeyOptions, conditionalUI });
131+
if (
132+
!conditionalUI &&
133+
result.error &&
134+
shouldRetryNativeAfterRendererError(result.error) &&
135+
mode === 'auto' &&
136+
env.nativeAvailable
137+
) {
118138
return nativeGetCredential(publicKeyOptions);
119139
}
120140
return result;
@@ -128,11 +148,11 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport
128148
if (mode === 'native') {
129149
return env.nativeAvailable;
130150
}
131-
return env.hasWebAuthn || env.nativeAvailable;
151+
return canUseRendererPath(mode, env) || env.nativeAvailable;
132152
};
133153

134-
const isAutoFillSupported: PasskeySupport['isAutoFillSupported'] = () => {
135-
return mode === 'native' ? Promise.resolve(false) : isWebAuthnAutofillSupported();
154+
const isAutoFillSupported: PasskeySupport['isAutoFillSupported'] = async () => {
155+
return canUseRendererPath(mode, getEnv()) && isWebAuthnAutofillSupported();
136156
};
137157

138158
const isPlatformAuthenticatorSupported: PasskeySupport['isPlatformAuthenticatorSupported'] = async () => {

‎packages/electron/src/passkeys/renderer/strategy.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export function originSatisfiesRpId(env: Pick<StrategyEnv, 'protocol' | 'hostnam
3131
return env.hostname === rpId || env.hostname.endsWith(`.${rpId}`);
3232
}
3333

34+
/** Whether the origin could satisfy some RP ID, without knowing which one is requested. */
35+
function originCanSatisfyRpId(env: Pick<StrategyEnv, 'protocol' | 'hostname'>): boolean {
36+
return env.protocol === 'https:' || (env.protocol === 'http:' && isLoopbackHostname(env.hostname));
37+
}
38+
39+
function prefersNativeOnLegacyDarwin(env: StrategyEnv): boolean {
40+
return env.platform === 'darwin' && env.electronMajor > 0 && env.electronMajor < 42 && env.nativeAvailable;
41+
}
42+
3443
/**
3544
* Prefer Chromium WebAuthn when the page origin can satisfy the RP ID.
3645
* Local bundles and older macOS Electron builds use the native bridge when available.
@@ -44,11 +53,23 @@ export function decidePath(rpId: string, mode: PasskeyMode, env: StrategyEnv): P
4453
}
4554

4655
if (env.hasWebAuthn && originSatisfiesRpId(env, rpId)) {
47-
if (env.platform === 'darwin' && env.electronMajor > 0 && env.electronMajor < 42 && env.nativeAvailable) {
48-
return 'native';
49-
}
50-
return 'renderer';
56+
return prefersNativeOnLegacyDarwin(env) ? 'native' : 'renderer';
5157
}
5258

5359
return env.nativeAvailable ? 'native' : 'unsupported';
5460
}
61+
62+
/**
63+
* Whether a request can take the renderer path, evaluated without RP ID.
64+
* More loose than originSatisfiesRpId as we don't have an RP ID yet.
65+
* This informs if Chromium *can* service a request rather than *will service*
66+
*/
67+
export function canUseRendererPath(mode: PasskeyMode, env: StrategyEnv): boolean {
68+
if (!env.hasWebAuthn || mode === 'native') {
69+
return false;
70+
}
71+
if (mode === 'renderer') {
72+
return true;
73+
}
74+
return originCanSatisfyRpId(env) && !prefersNativeOnLegacyDarwin(env);
75+
}

‎packages/ui/src/components/SignIn/SignInStart.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ const useAutoFillPasskey = () => {
6060
const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check');
6161
const { userSettings } = useEnvironment();
6262
const { passkeySettings, attributes } = userSettings;
63+
// @ts-expect-error - This is not a public API
64+
const { __internal_isWebAuthnAutofillSupported } = useClerk();
6365

6466
useEffect(() => {
6567
async function runAutofillPasskey() {
66-
const _isSupported = await isWebAuthnAutofillSupported();
68+
const _isSupported = await (__internal_isWebAuthnAutofillSupported ?? isWebAuthnAutofillSupported)();
6769
setIsSupported(_isSupported);
6870
if (!_isSupported) {
6971
return;
@@ -105,7 +107,9 @@ function SignInStartInternal(): JSX.Element {
105107
const { isWebAuthnAutofillSupported } = useAutoFillPasskey();
106108
const onSecondFactor = () => navigate('factor-two');
107109
const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check');
108-
const isWebSupported = isWebAuthnSupported();
110+
// @ts-expect-error - This is not a public API
111+
const { __internal_isWebAuthnSupported } = clerk;
112+
const isWebSupported = (__internal_isWebAuthnSupported ?? isWebAuthnSupported)();
109113

110114
const onlyPhoneNumberInitialValueExists =
111115
!!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username);

0 commit comments

Comments
 (0)