Skip to content

Commit 0db0654

Browse files
jeremy-clerkwobsoriano
authored andcommitted
feat(electron): add passkey support
1 parent 786c3f8 commit 0db0654

20 files changed

Lines changed: 315 additions & 39 deletions

File tree

‎.github/workflows/electron-passkeys.yml.template‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757

5858
- name: Smoke test (host-native targets only)
5959
if: matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc'
60-
run: node -e "const m = require('./index.js'); console.log('isAvailable:', m.isAvailable(), 'capabilities:', JSON.stringify(m.capabilities()))"
60+
run: node -e "const m = require('./index.js'); console.log('isAvailable:', m.isAvailable(), 'capabilities:', JSON.stringify(m.capabilities())); if (!m.isAvailable()) throw new Error('the loader did not find the freshly built native binary');"
6161

6262
- uses: actions/upload-artifact@v4
6363
with:

‎packages/electron-passkeys/Cargo.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ objc2-authentication-services = { version = "0.3", default-features = false, fea
3737
"std",
3838
"ASFoundation",
3939
"ASAuthorization",
40+
"ASAuthorizationError",
4041
"ASAuthorizationController",
4142
"ASAuthorizationCredential",
4243
"ASAuthorizationRequest",
@@ -55,6 +56,12 @@ objc2-authentication-services = { version = "0.3", default-features = false, fea
5556
"ASAuthorizationPublicKeyCredentialParameters",
5657
] }
5758

59+
# NSDictionary is only needed to build NSError fixtures in unit tests.
60+
[target.'cfg(target_os = "macos")'.dev-dependencies]
61+
objc2-foundation = { version = "0.3", default-features = false, features = [
62+
"NSDictionary",
63+
] }
64+
5865
[target.'cfg(target_os = "windows")'.dependencies]
5966
windows = { version = "0.61", features = [
6067
"Win32_Foundation",

‎packages/electron-passkeys/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Native passkey (WebAuthn) support for [`@clerk/electron`](https://github.com/cle
55
> [!WARNING]
66
> This package is under active development and is not yet ready for production use.
77
8-
This package is a napi-rs native module loaded in the Electron **main** process by `setupPasskeysMain()` from `@clerk/electron`. You should not need to call it directly.
8+
This package is a napi-rs native module loaded in the Electron **main** process by `createClerkBridge({ passkeys: true })` from `@clerk/electron`. You should not need to call it directly.
99

1010
| Platform | Backend | Authenticators |
1111
| ---------------- | ---------------------------------------------------- | ---------------------------------------- |

‎packages/electron-passkeys/index.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ const PLATFORM_PACKAGES = {
1111
function loadNative() {
1212
const key = `${process.platform}-${process.arch}`;
1313

14-
// Local napi builds land next to this file.
15-
const localBinary = join(__dirname, `electron-passkeys.${key}.node`);
14+
// Local napi builds land next to this file; napi appends the ABI to the
15+
// filename on Windows (e.g. electron-passkeys.win32-x64-msvc.node).
16+
const localKey = process.platform === 'win32' ? `${key}-msvc` : key;
17+
const localBinary = join(__dirname, `electron-passkeys.${localKey}.node`);
1618
if (existsSync(localBinary)) {
1719
return require(localBinary);
1820
}

‎packages/electron-passkeys/src/macos.rs‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use objc2::{
1515
use objc2_app_kit::{NSView, NSWindow};
1616
use objc2_authentication_services::{
1717
ASAuthorization, ASAuthorizationController, ASAuthorizationControllerDelegate,
18-
ASAuthorizationControllerPresentationContextProviding,
18+
ASAuthorizationControllerPresentationContextProviding, ASAuthorizationError, ASAuthorizationErrorDomain,
1919
ASAuthorizationPlatformPublicKeyCredentialDescriptor, ASAuthorizationPlatformPublicKeyCredentialProvider,
2020
ASAuthorizationPublicKeyCredentialParameters, ASAuthorizationRequest,
2121
ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor,
@@ -170,20 +170,21 @@ impl CeremonyDelegate {
170170
}
171171

172172
fn map_nserror(error: &NSError) -> (String, String) {
173-
let domain = error.domain().to_string();
174-
let code = error.code();
173+
let domain = error.domain();
174+
let code = ASAuthorizationError(error.code());
175175
let message = error.localizedDescription().to_string();
176176
let lowered = message.to_lowercase();
177177

178178
let mapped = if lowered.contains("timed out") || lowered.contains("timeout") {
179179
"timeout"
180-
} else if domain == "ASAuthorizationError" {
180+
} else if &*domain == unsafe { ASAuthorizationErrorDomain } {
181181
match code {
182-
// ASAuthorizationError.canceled
183-
1001 => "cancelled",
182+
ASAuthorizationError::Canceled => "cancelled",
184183
// ASAuthorizationError.failed also covers RP ID / associated-domain
185184
// mismatches, so use the localized description for classification.
186-
1004 if lowered.contains("not associated") || lowered.contains("associated domain") => {
185+
ASAuthorizationError::Failed
186+
if lowered.contains("not associated") || lowered.contains("associated domain") =>
187+
{
187188
"invalid_rp"
188189
}
189190
_ => "unknown",
@@ -613,3 +614,52 @@ pub(crate) async fn create_credential(handle: usize, options: CreationOptions) -
613614
pub(crate) async fn get_credential(handle: usize, options: RequestOptions) -> String {
614615
run_ceremony(handle, move || build_get_requests(&options)).await
615616
}
617+
618+
#[cfg(test)]
619+
mod tests {
620+
use objc2::rc::Retained;
621+
use objc2::runtime::AnyObject;
622+
use objc2_authentication_services::{ASAuthorizationError, ASAuthorizationErrorDomain};
623+
use objc2_foundation::{NSDictionary, NSError, NSLocalizedDescriptionKey, NSString};
624+
625+
use super::map_nserror;
626+
627+
fn authorization_error(code: ASAuthorizationError, description: Option<&str>) -> Retained<NSError> {
628+
let user_info = description.map(|text| {
629+
let value = NSString::from_str(text);
630+
let object: &AnyObject = &value;
631+
NSDictionary::from_slices(&[unsafe { NSLocalizedDescriptionKey }], &[object])
632+
});
633+
unsafe {
634+
NSError::errorWithDomain_code_userInfo(ASAuthorizationErrorDomain, code.0, user_info.as_deref())
635+
}
636+
}
637+
638+
#[test]
639+
fn maps_user_cancellation_to_cancelled() {
640+
let error = authorization_error(ASAuthorizationError::Canceled, None);
641+
assert_eq!(map_nserror(&error).0, "cancelled");
642+
}
643+
644+
#[test]
645+
fn maps_domain_association_failures_to_invalid_rp() {
646+
let error = authorization_error(
647+
ASAuthorizationError::Failed,
648+
Some("Application with identifier ABC.com.example is not associated with domain example.com"),
649+
);
650+
assert_eq!(map_nserror(&error).0, "invalid_rp");
651+
}
652+
653+
#[test]
654+
fn maps_other_authorization_failures_to_unknown() {
655+
let error = authorization_error(ASAuthorizationError::Unknown, None);
656+
assert_eq!(map_nserror(&error).0, "unknown");
657+
}
658+
659+
#[test]
660+
fn maps_foreign_domains_to_unknown() {
661+
let domain = NSString::from_str("com.example.SomeOtherDomain");
662+
let error = unsafe { NSError::errorWithDomain_code_userInfo(&domain, 1001, None) };
663+
assert_eq!(map_nserror(&error).0, "unknown");
664+
}
665+
}

‎packages/electron/README.md‎

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ This package exposes entrypoints for Electron's distinct runtime contexts:
4242
```ts
4343
// main.ts
4444
import { app, BrowserWindow, net, protocol } from 'electron';
45-
import { createClerkBridge, setupPasskeysMain } from '@clerk/electron';
45+
import { createClerkBridge } from '@clerk/electron';
4646
import { storage } from '@clerk/electron/storage';
4747
import { join } from 'node:path';
4848
import { pathToFileURL } from 'node:url';
@@ -53,10 +53,9 @@ createClerkBridge({
5353
scheme: 'my-app',
5454
host: 'renderer',
5555
},
56+
passkeys: true,
5657
});
5758

58-
setupPasskeysMain();
59-
6059
app.whenReady().then(() => {
6160
protocol.handle('my-app', request => {
6261
const url = new URL(request.url);
@@ -79,17 +78,22 @@ In `my-app://renderer/sign-in`, `my-app` is the scheme, `renderer` is the host,
7978

8079
```ts
8180
// preload.ts
82-
import { exposeClerkBridge, setupPasskeysPreload } from '@clerk/electron/preload';
81+
import { exposeClerkBridge } from '@clerk/electron/preload';
8382

84-
exposeClerkBridge();
85-
setupPasskeysPreload();
83+
exposeClerkBridge({ passkeys: true });
8684
```
8785

8886
```tsx
8987
// renderer.tsx
9088
import { ClerkProvider } from '@clerk/electron/react';
91-
92-
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>{/* ... */}</ClerkProvider>;
89+
import { passkeys } from '@clerk/electron/passkeys';
90+
91+
<ClerkProvider
92+
publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}
93+
passkeys={passkeys}
94+
>
95+
{/* ... */}
96+
</ClerkProvider>;
9397
```
9498

9599
## Passkeys
@@ -109,28 +113,41 @@ pnpm add @clerk/electron-passkeys
109113

110114
```ts
111115
// main process
112-
import { createClerkBridge, setupPasskeysMain } from '@clerk/electron';
116+
import { createClerkBridge } from '@clerk/electron';
113117
import { storage } from '@clerk/electron/storage';
114118

115-
createClerkBridge({ storage: storage() });
116-
setupPasskeysMain();
119+
createClerkBridge({ storage: storage(), passkeys: true });
117120
```
118121

119122
```ts
120123
// preload script
121-
import { exposeClerkBridge, setupPasskeysPreload } from '@clerk/electron/preload';
124+
import { exposeClerkBridge } from '@clerk/electron/preload';
122125

123-
exposeClerkBridge();
124-
setupPasskeysPreload();
126+
exposeClerkBridge({ passkeys: true });
125127
```
126128

129+
```tsx
130+
// renderer process (React)
131+
import { ClerkProvider } from '@clerk/electron/react';
132+
import { passkeys } from '@clerk/electron/passkeys';
133+
134+
<ClerkProvider
135+
publishableKey={publishableKey}
136+
passkeys={passkeys}
137+
>
138+
{/* ... */}
139+
</ClerkProvider>;
140+
```
141+
142+
Passkey code is only bundled and initialized when you pass the `passkeys` prop. If you manage the Clerk instance yourself instead of using `ClerkProvider`, wire it up before `clerk.load()`:
143+
127144
```ts
128-
// renderer process
145+
// renderer process (vanilla)
129146
import { Clerk } from '@clerk/clerk-js';
130147
import { createPasskeyProvider } from '@clerk/electron/passkeys';
131148

132149
const clerk = new Clerk(publishableKey);
133-
createPasskeyProvider(clerk); // optionally { mode: 'auto' | 'renderer' | 'native' }
150+
createPasskeyProvider(clerk);
134151
await clerk.load();
135152
```
136153

‎packages/electron/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export { createClerkBridge } from './main/create-clerk-bridge';
22
export { setupPasskeysMain } from './main/passkey-handlers';
3-
export type { ClerkBridge, CreateClerkBridgeOptions, TokenStorage } from './shared/types';
3+
export type { ClerkBridge, CreateClerkBridgeOptions, ExposeClerkBridgeOptions, TokenStorage } from './shared/types';

‎packages/electron/src/main/__tests__/create-clerk-bridge.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { app, ipcMain, protocol, shell } from 'electron';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33

4-
import { OAUTH_TRANSPORT_CHANNELS } from '../../shared/ipc';
4+
import { OAUTH_TRANSPORT_CHANNELS, PASSKEY_CHANNELS } from '../../shared/ipc';
55
import type { TokenStorage } from '../../shared/types';
66
import { createClerkBridge } from '../create-clerk-bridge';
77

@@ -23,6 +23,15 @@ vi.mock('electron', () => ({
2323
},
2424
}));
2525

26+
vi.mock('@clerk/electron-passkeys', () => ({
27+
default: {
28+
isAvailable: () => true,
29+
capabilities: () => ({ platformAuthenticator: true, securityKeys: true }),
30+
createCredential: vi.fn(),
31+
getCredential: vi.fn(),
32+
},
33+
}));
34+
2635
describe('createClerkBridge', () => {
2736
const missingStorage = {} as Parameters<typeof createClerkBridge>[0];
2837
const storage: TokenStorage = {
@@ -100,6 +109,32 @@ describe('createClerkBridge', () => {
100109
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3);
101110
});
102111

112+
it('does not register passkey IPC handlers by default', () => {
113+
createClerkBridge({ storage });
114+
115+
const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel);
116+
expect(channels).not.toContain(PASSKEY_CHANNELS.create);
117+
expect(channels).not.toContain(PASSKEY_CHANNELS.get);
118+
expect(channels).not.toContain(PASSKEY_CHANNELS.capabilities);
119+
});
120+
121+
it('registers passkey IPC handlers when passkeys are enabled', () => {
122+
createClerkBridge({ storage, passkeys: true });
123+
124+
const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel);
125+
expect(channels).toContain(PASSKEY_CHANNELS.create);
126+
expect(channels).toContain(PASSKEY_CHANNELS.get);
127+
expect(channels).toContain(PASSKEY_CHANNELS.capabilities);
128+
});
129+
130+
it('cleans up passkey handlers together with the token handlers', () => {
131+
const clerk = createClerkBridge({ storage, passkeys: true });
132+
133+
clerk.cleanup();
134+
135+
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(6);
136+
});
137+
103138
it('cleans up OAuth transport handlers when renderer origin is configured', () => {
104139
const clerk = createClerkBridge({
105140
storage,

‎packages/electron/src/main/__tests__/passkey-handlers.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ const getHandler = (channel: string): Handler => {
3535
};
3636

3737
const windowHandle = Buffer.from([1, 2, 3, 4]);
38-
const event = { sender: {} } as IpcMainInvokeEvent;
38+
const mainFrame = {};
39+
const event = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as IpcMainInvokeEvent;
3940

4041
const creationOptions = { challenge: 'abc', rp: { id: 'example.com', name: 'Example' } };
4142
const registrationJSON = { id: 'cred', rawId: 'cred', type: 'public-key', response: {} };
@@ -94,6 +95,16 @@ describe('setupPasskeysMain', () => {
9495
expect(native.createCredential).not.toHaveBeenCalled();
9596
});
9697

98+
it('rejects requests that do not originate from the main frame', async () => {
99+
setupPasskeysMain();
100+
101+
const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as IpcMainInvokeEvent;
102+
const result = await getHandler(PASSKEY_CHANNELS.create)(subframeEvent, creationOptions);
103+
104+
expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } });
105+
expect(native.createCredential).not.toHaveBeenCalled();
106+
});
107+
97108
it('returns an error when the request does not originate from a window', async () => {
98109
vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(null);
99110
setupPasskeysMain();

‎packages/electron/src/main/create-clerk-bridge.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { protocol } from 'electron';
33
import type { ClerkBridge, CreateClerkBridgeOptions } from '../shared/types';
44
import { setupTokenCacheIpcHandlers } from './ipc-handlers';
55
import { setupOAuthTransportIpcHandlers } from './oauth-transport';
6+
import { setupPasskeysMain } from './passkey-handlers';
67

78
function assertValidRendererOriginConfig(renderer: NonNullable<CreateClerkBridgeOptions['renderer']>): void {
89
if (renderer.scheme.includes(':') || renderer.scheme.includes('/')) {
@@ -34,6 +35,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg
3435

3536
const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage);
3637
let cleanupOAuthTransport: (() => void) | undefined;
38+
const passkeys = options.passkeys ? setupPasskeysMain() : null;
3739

3840
if (options.renderer) {
3941
assertValidRendererOriginConfig(options.renderer);
@@ -60,6 +62,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg
6062
cleanup() {
6163
cleanupTokenPersistence();
6264
cleanupOAuthTransport?.();
65+
passkeys?.cleanup();
6366
},
6467
};
6568
}

0 commit comments

Comments
 (0)