Skip to content

Commit a52d486

Browse files
authored
fix(electron,ui): Don't run passkey autofill as a modal prompt (#9500)
1 parent 72ffc81 commit a52d486

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)