From 32551d2c8199f9f6216e2b5f92e36fba3326b508 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 9 Jun 2026 01:14:03 +0400 Subject: [PATCH 01/19] feat(electron): scaffold initial @clerk/electron package Add the initial packages/electron package with metadata, dual ESM+CJS tsup build, exports, and a README placeholder. Exposes the intended entrypoints with stubbed bodies: - @clerk/electron (Electron main process) - @clerk/electron/preload (Electron preload scripts) --- .changeset/electron-package-scaffold.md | 2 + packages/electron/LICENSE | 21 +++++ packages/electron/README.md | 59 ++++++++++++++ packages/electron/package.json | 82 ++++++++++++++++++++ packages/electron/src/global.d.ts | 3 + packages/electron/src/index.ts | 2 + packages/electron/src/preload/index.ts | 2 + packages/electron/tsconfig.declarations.json | 12 +++ packages/electron/tsconfig.json | 23 ++++++ packages/electron/tsup.config.ts | 38 +++++++++ pnpm-lock.yaml | 12 ++- 11 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .changeset/electron-package-scaffold.md create mode 100644 packages/electron/LICENSE create mode 100644 packages/electron/README.md create mode 100644 packages/electron/package.json create mode 100644 packages/electron/src/global.d.ts create mode 100644 packages/electron/src/index.ts create mode 100644 packages/electron/src/preload/index.ts create mode 100644 packages/electron/tsconfig.declarations.json create mode 100644 packages/electron/tsconfig.json create mode 100644 packages/electron/tsup.config.ts diff --git a/.changeset/electron-package-scaffold.md b/.changeset/electron-package-scaffold.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/electron-package-scaffold.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/electron/LICENSE b/packages/electron/LICENSE new file mode 100644 index 00000000000..daceccfbc84 --- /dev/null +++ b/packages/electron/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/electron/README.md b/packages/electron/README.md new file mode 100644 index 00000000000..65101286b3e --- /dev/null +++ b/packages/electron/README.md @@ -0,0 +1,59 @@ +

+ + + + + + +
+

@clerk/electron

+

+ +
+ +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_electron) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/electron/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron) + +
+ +## Getting Started + +[Clerk](https://clerk.com/?utm_source=github&utm_medium=clerk_electron) is the easiest way to add authentication and user management to your Electron application. + +> [!WARNING] +> `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change. + +This package exposes two entrypoints, targeting Electron's distinct runtime contexts: + +- `@clerk/electron` — for use in the Electron **main** process. +- `@clerk/electron/preload` — for use in Electron **preload** scripts. + +## Support + +For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron). + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/electron` follows good practices of security, but 100% security cannot be assured. + +`@clerk/electron` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/electron/LICENSE) for more information. diff --git a/packages/electron/package.json b/packages/electron/package.json new file mode 100644 index 00000000000..dfb89cee8b6 --- /dev/null +++ b/packages/electron/package.json @@ -0,0 +1,82 @@ +{ + "name": "@clerk/electron", + "version": "0.0.0", + "description": "Clerk SDK for Electron", + "keywords": [ + "clerk", + "electron", + "auth", + "authentication", + "session", + "jwt", + "desktop" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron" + }, + "license": "MIT", + "author": "Clerk", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/types/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/types/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./preload": { + "import": { + "types": "./dist/types/preload/index.d.ts", + "default": "./dist/esm/preload/index.js" + }, + "require": { + "types": "./dist/types/preload/index.d.ts", + "default": "./dist/cjs/preload/index.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "preload" + ], + "scripts": { + "build": "tsup", + "build:declarations": "tsc -p tsconfig.declarations.json", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax", + "lint:publint": "publint", + "test": "vitest run", + "test:ci": "vitest run --maxWorkers=70%", + "test:watch": "vitest" + }, + "dependencies": { + "tslib": "catalog:repo" + }, + "devDependencies": { + "@types/node": "^22.19.17" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts new file mode 100644 index 00000000000..09ed4b48d45 --- /dev/null +++ b/packages/electron/src/global.d.ts @@ -0,0 +1,3 @@ +declare const PACKAGE_NAME: string; +declare const PACKAGE_VERSION: string; +declare const __DEV__: boolean; diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts new file mode 100644 index 00000000000..4266b361b4a --- /dev/null +++ b/packages/electron/src/index.ts @@ -0,0 +1,2 @@ +// TODO: Implement the Clerk SDK for the Electron main process. +export const SDK_NAME = PACKAGE_NAME; diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts new file mode 100644 index 00000000000..81876ea7a2a --- /dev/null +++ b/packages/electron/src/preload/index.ts @@ -0,0 +1,2 @@ +// TODO: Implement the Clerk preload bridge for the Electron preload context. +export const SDK_NAME = PACKAGE_NAME; diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json new file mode 100644 index 00000000000..c42a5efd18e --- /dev/null +++ b/packages/electron/tsconfig.declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "sourceMap": false, + "declarationDir": "./dist/types" + } +} diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json new file mode 100644 index 00000000000..274fd384521 --- /dev/null +++ b/packages/electron/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "declaration": true, + "declarationDir": "dist/types", + "declarationMap": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts new file mode 100644 index 00000000000..ff369e0c90d --- /dev/null +++ b/packages/electron/tsup.config.ts @@ -0,0 +1,38 @@ +import type { Options } from 'tsup'; +import { defineConfig } from 'tsup'; + +import { runAfterLast } from '../../scripts/utils'; +import { name, version } from './package.json'; + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + const shouldPublish = !!overrideOptions.env?.publish; + + const common: Options = { + entry: ['./src/index.ts', './src/preload/index.ts'], + bundle: true, + clean: true, + minify: false, + sourcemap: true, + legacyOutput: true, + treeshake: true, + define: { + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isWatch}`, + }, + }; + + const esm: Options = { + ...common, + format: 'esm', + }; + + const cjs: Options = { + ...common, + format: 'cjs', + outDir: './dist/cjs', + }; + + return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(esm, cjs); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17edd6444d5..884d76dbd33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,16 @@ importers: specifier: ^5.10.0 version: 5.10.0 + packages/electron: + dependencies: + tslib: + specifier: catalog:repo + version: 2.8.1 + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.19.17 + packages/expo: dependencies: '@clerk/clerk-js': @@ -2701,7 +2711,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} From 61ce4b09c425dfcc2f54f82b91e7ae8fb55ab0be Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 9 Jun 2026 09:36:55 -0700 Subject: [PATCH 02/19] chore(electron): Add initial main and preload scripts (#8785) --- packages/electron/package.json | 23 +- packages/electron/src/global.d.ts | 10 + packages/electron/src/index.ts | 4 +- .../src/main/__tests__/ipc-handlers.test.ts | 63 ++++ .../src/main/__tests__/setup-main.test.ts | 43 +++ packages/electron/src/main/ipc-handlers.ts | 24 ++ packages/electron/src/main/setup-main.ts | 18 + .../src/preload/__tests__/index.test.ts | 71 ++++ packages/electron/src/preload/index.ts | 20 +- packages/electron/src/shared/ipc.ts | 5 + packages/electron/src/shared/types.ts | 21 ++ .../src/storage/__tests__/index.test.ts | 80 ++++ packages/electron/src/storage/index.ts | 35 ++ packages/electron/tsup.config.ts | 2 +- pnpm-lock.yaml | 346 +++++++++++++++++- 15 files changed, 758 insertions(+), 7 deletions(-) create mode 100644 packages/electron/src/main/__tests__/ipc-handlers.test.ts create mode 100644 packages/electron/src/main/__tests__/setup-main.test.ts create mode 100644 packages/electron/src/main/ipc-handlers.ts create mode 100644 packages/electron/src/main/setup-main.ts create mode 100644 packages/electron/src/preload/__tests__/index.test.ts create mode 100644 packages/electron/src/shared/ipc.ts create mode 100644 packages/electron/src/shared/types.ts create mode 100644 packages/electron/src/storage/__tests__/index.test.ts create mode 100644 packages/electron/src/storage/index.ts diff --git a/packages/electron/package.json b/packages/electron/package.json index dfb89cee8b6..30c26ce3f0a 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -44,6 +44,16 @@ "default": "./dist/cjs/preload/index.js" } }, + "./storage": { + "import": { + "types": "./dist/types/storage/index.d.ts", + "default": "./dist/esm/storage/index.js" + }, + "require": { + "types": "./dist/types/storage/index.d.ts", + "default": "./dist/cjs/storage/index.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/cjs/index.js", @@ -71,7 +81,18 @@ "tslib": "catalog:repo" }, "devDependencies": { - "@types/node": "^22.19.17" + "@types/node": "^22.19.17", + "electron": "^39.2.6", + "electron-store": "^8.2.0" + }, + "peerDependencies": { + "electron": ">=28", + "electron-store": "^8.2.0" + }, + "peerDependenciesMeta": { + "electron-store": { + "optional": true + } }, "engines": { "node": ">=20.9.0" diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts index 09ed4b48d45..6c762af29a5 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,3 +1,13 @@ +import type { TokenCache } from './shared/types'; + declare const PACKAGE_NAME: string; declare const PACKAGE_VERSION: string; declare const __DEV__: boolean; + +declare global { + interface Window { + __clerk_internal_electron?: { + tokenCache: TokenCache; + }; + } +} diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 4266b361b4a..80aa04dd58e 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,2 +1,2 @@ -// TODO: Implement the Clerk SDK for the Electron main process. -export const SDK_NAME = PACKAGE_NAME; +export { setupMain } from './main/setup-main'; +export type { TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/ipc-handlers.test.ts b/packages/electron/src/main/__tests__/ipc-handlers.test.ts new file mode 100644 index 00000000000..c57c98220f4 --- /dev/null +++ b/packages/electron/src/main/__tests__/ipc-handlers.test.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc'; +import type { TokenStorage } from '../../shared/types'; +import { setupTokenCacheIpcHandlers } from '../ipc-handlers'; + +const ipcEvent = {} as Electron.IpcMainInvokeEvent; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +describe('setupTokenCacheIpcHandlers', () => { + const storage: TokenStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers token cache IPC handlers', () => { + setupTokenCacheIpcHandlers(storage); + + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, expect.any(Function)); + }); + + it('delegates token operations to the storage adapter', async () => { + vi.mocked(storage.getItem).mockResolvedValue('jwt'); + + setupTokenCacheIpcHandlers(storage); + + const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1]; + const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1]; + const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1]; + + await expect(getTokenHandler(ipcEvent, 'token-key')).resolves.toBe('jwt'); + await saveTokenHandler(ipcEvent, 'token-key', 'jwt'); + await clearTokenHandler(ipcEvent, 'token-key'); + + expect(storage.getItem).toHaveBeenCalledWith('token-key'); + expect(storage.setItem).toHaveBeenCalledWith('token-key', 'jwt'); + expect(storage.removeItem).toHaveBeenCalledWith('token-key'); + }); + + it('removes registered handlers on cleanup', () => { + const cleanup = setupTokenCacheIpcHandlers(storage); + + cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken); + }); +}); diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts new file mode 100644 index 00000000000..c1f90c90677 --- /dev/null +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TokenStorage } from '../../shared/types'; +import { setupMain } from '../setup-main'; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +describe('setupMain', () => { + const missingStorage = {} as Parameters[0]; + const storage: TokenStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requires a storage adapter', () => { + expect(() => setupMain(missingStorage)).toThrow('setupMain requires a storage adapter'); + }); + + it('sets up token persistence IPC handlers with the provided storage', () => { + setupMain({ storage }); + + expect(ipcMain.handle).toHaveBeenCalledTimes(3); + }); + + it('returns a cleanup function for registered handlers', () => { + const clerk = setupMain({ storage }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/electron/src/main/ipc-handlers.ts b/packages/electron/src/main/ipc-handlers.ts new file mode 100644 index 00000000000..5114729404b --- /dev/null +++ b/packages/electron/src/main/ipc-handlers.ts @@ -0,0 +1,24 @@ +import { ipcMain } from 'electron'; + +import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; +import type { TokenStorage } from '../shared/types'; + +export function setupTokenCacheIpcHandlers(storage: TokenStorage): () => void { + ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (_event, key: string) => { + return storage.getItem(key); + }); + + ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (_event, key: string, value: string) => { + return storage.setItem(key, value); + }); + + ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (_event, key: string) => { + return storage.removeItem(key); + }); + + return () => { + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.getToken); + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.saveToken); + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.clearToken); + }; +} diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts new file mode 100644 index 00000000000..64c1853f2d8 --- /dev/null +++ b/packages/electron/src/main/setup-main.ts @@ -0,0 +1,18 @@ +import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; +import { setupTokenCacheIpcHandlers } from './ipc-handlers'; + +export function setupMain(options: SetupMainOptions): SetupMainReturn { + if (!options.storage) { + throw new Error( + 'Clerk: setupMain requires a storage adapter. Pass setupMain({ storage: storage() }) from @clerk/electron/storage, or provide a custom storage adapter.', + ); + } + + const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + + return { + cleanup() { + cleanupTokenPersistence(); + }, + }; +} diff --git a/packages/electron/src/preload/__tests__/index.test.ts b/packages/electron/src/preload/__tests__/index.test.ts new file mode 100644 index 00000000000..28c329ed7b4 --- /dev/null +++ b/packages/electron/src/preload/__tests__/index.test.ts @@ -0,0 +1,71 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc'; +import { setupPreload } from '../index'; + +vi.mock('electron', () => ({ + contextBridge: { + exposeInMainWorld: vi.fn(), + }, + ipcRenderer: { + invoke: vi.fn(), + }, +})); + +describe('setupPreload', () => { + const originalContextIsolated = process.contextIsolated; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }); + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(() => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: originalContextIsolated }); + }); + + it('exposes the Clerk Electron bridge through contextBridge when context isolation is enabled', () => { + setupPreload(); + + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith('__clerk_internal_electron', { + tokenCache: { + getToken: expect.any(Function), + saveToken: expect.any(Function), + clearToken: expect.any(Function), + }, + }); + }); + + it('exposes the Clerk Electron bridge on window when context isolation is disabled', () => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: false }); + + setupPreload(); + + expect(window.__clerk_internal_electron?.tokenCache).toEqual({ + getToken: expect.any(Function), + saveToken: expect.any(Function), + clearToken: expect.any(Function), + }); + }); + + it('forwards token cache calls over IPC', async () => { + setupPreload(); + + const bridge = vi.mocked(contextBridge.exposeInMainWorld).mock + .calls[0][1] as typeof window.__clerk_internal_electron; + + await bridge?.tokenCache.getToken('token-key'); + await bridge?.tokenCache.saveToken('token-key', 'jwt'); + await bridge?.tokenCache.clearToken('token-key'); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, 'token-key'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, 'token-key', 'jwt'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, 'token-key'); + }); +}); diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index 81876ea7a2a..8b9903b1c4d 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -1,2 +1,18 @@ -// TODO: Implement the Clerk preload bridge for the Electron preload context. -export const SDK_NAME = PACKAGE_NAME; +import { contextBridge, ipcRenderer } from 'electron'; + +import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; +import type { TokenCache } from '../shared/types'; + +export function setupPreload(): void { + const tokenCache: TokenCache = { + getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), + saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value), + clearToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.clearToken, key), + }; + + if (process.contextIsolated) { + contextBridge.exposeInMainWorld('__clerk_internal_electron', { tokenCache }); + } else { + window.__clerk_internal_electron = { tokenCache }; + } +} diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts new file mode 100644 index 00000000000..e50db4bb9c3 --- /dev/null +++ b/packages/electron/src/shared/ipc.ts @@ -0,0 +1,5 @@ +export const TOKEN_CACHE_CHANNELS = { + getToken: 'clerk:token-cache:get', + saveToken: 'clerk:token-cache:save', + clearToken: 'clerk:token-cache:clear', +} as const; diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts new file mode 100644 index 00000000000..9c51778cc28 --- /dev/null +++ b/packages/electron/src/shared/types.ts @@ -0,0 +1,21 @@ +type Awaitable = T | Promise; + +export type TokenStorage = { + getItem: (key: string) => Awaitable; + setItem: (key: string, value: string) => Awaitable; + removeItem: (key: string) => Awaitable; +}; + +export type SetupMainOptions = { + storage: TokenStorage; +}; + +export type SetupMainReturn = { + cleanup: () => void; +}; + +export type TokenCache = { + getToken: (key: string) => Promise; + saveToken: (key: string, value: string) => Promise; + clearToken: (key: string) => Promise; +}; diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts new file mode 100644 index 00000000000..406d98651b3 --- /dev/null +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -0,0 +1,80 @@ +import { safeStorage } from 'electron'; +import Store from 'electron-store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { storage } from '../index'; + +const storeGet = vi.fn(); +const storeSet = vi.fn(); +const storeDelete = vi.fn(); + +vi.mock('electron', () => ({ + safeStorage: { + decryptString: vi.fn(), + encryptString: vi.fn(), + }, +})); + +vi.mock('electron-store', () => ({ + default: vi.fn(() => ({ + get: storeGet, + set: storeSet, + delete: storeDelete, + })), +})); + +describe('storage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates an electron-store instance with the default store name', () => { + storage(); + + expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens' }); + }); + + it('supports a custom store name', () => { + storage({ name: 'custom-clerk-tokens' }); + + expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' }); + }); + + it('returns null when a token is missing', () => { + storeGet.mockReturnValue(undefined); + + expect(storage().getItem('token-key')).toBeNull(); + }); + + it('decrypts stored tokens', () => { + storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + expect(storage().getItem('token-key')).toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token')); + }); + + it('returns null when token decryption fails', () => { + storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + expect(storage().getItem('token-key')).toBeNull(); + }); + + it('encrypts tokens before storing them', async () => { + vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token')); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64')); + }); + + it('removes stored tokens', async () => { + await storage().removeItem('token-key'); + + expect(storeDelete).toHaveBeenCalledWith('token-key'); + }); +}); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts new file mode 100644 index 00000000000..ce130078db0 --- /dev/null +++ b/packages/electron/src/storage/index.ts @@ -0,0 +1,35 @@ +import { safeStorage } from 'electron'; +import Store from 'electron-store'; + +import type { TokenStorage } from '../shared/types'; + +type StorageOptions = { + name?: string; +}; + +export function storage(options: StorageOptions = {}): TokenStorage { + const store = new Store>({ name: options.name ?? 'clerk-tokens' }); + + return { + getItem(key) { + const encrypted = store.get(key); + + if (!encrypted) { + return null; + } + + try { + return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); + } catch { + return null; + } + }, + setItem(key, value) { + const encrypted = safeStorage.encryptString(value); + store.set(key, encrypted.toString('base64')); + }, + removeItem(key) { + store.delete(key); + }, + }; +} diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts index ff369e0c90d..5a598814cb3 100644 --- a/packages/electron/tsup.config.ts +++ b/packages/electron/tsup.config.ts @@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/index.ts', './src/preload/index.ts'], + entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'], bundle: true, clean: true, minify: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 884d76dbd33..ccafc316a40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,12 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + electron: + specifier: ^39.2.6 + version: 39.8.10 + electron-store: + specifier: ^8.2.0 + version: 8.2.0 packages/expo: dependencies: @@ -2131,6 +2137,10 @@ packages: resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==} engines: {node: '>=18'} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2711,7 +2721,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -5167,6 +5177,10 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tanstack/history@1.154.14': resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} engines: {node: '>=12'} @@ -5378,6 +5392,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -5447,6 +5464,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -5477,6 +5497,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5533,6 +5556,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -6433,6 +6459,10 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -6651,6 +6681,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} @@ -6776,6 +6810,14 @@ packages: resolution: {integrity: sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==} engines: {node: ^16.14.0 || >=18.0.0} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + cachedir@2.4.0: resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} engines: {node: '>=6'} @@ -7009,6 +7051,9 @@ packages: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -7158,6 +7203,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -7509,6 +7558,10 @@ packages: de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -7577,6 +7630,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@4.1.4: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} @@ -7615,6 +7672,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -7767,6 +7828,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -7837,9 +7902,17 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + electron-store@8.2.0: + resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} + electron-to-chromium@1.5.331: resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + electron@39.8.10: + resolution: {integrity: sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7987,6 +8060,9 @@ packages: es-toolkit@1.46.1: resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -8943,6 +9019,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -8991,6 +9071,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -9252,6 +9336,10 @@ packages: resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} engines: {node: '>=0.10'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -9899,6 +9987,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -10315,6 +10406,10 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -10405,6 +10500,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -10770,10 +10869,22 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -11068,6 +11179,10 @@ packages: resolution: {integrity: sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==} engines: {node: '>=4'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-package-arg@11.0.3: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -11305,6 +11420,10 @@ packages: peerDependencies: oxc-parser: '>=0.98.0' + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-event@5.0.1: resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -11598,6 +11717,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + pkglab-darwin-arm64@0.17.1: resolution: {integrity: sha512-7cy7ck7iQW6xYDBkV3NHW1wz4bub9sikFkrMj11AT/1PXVYFDhI7SaaZ6jxQgL3Mcvj34rQlUXKyVWrAzhrUDw==} engines: {node: '>=18'} @@ -12106,6 +12229,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + quick-lru@6.1.2: resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} engines: {node: '>=12'} @@ -12365,6 +12492,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -12403,6 +12533,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -12464,6 +12597,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rolldown-plugin-dts@0.16.12: resolution: {integrity: sha512-9dGjm5oqtKcbZNhpzyBgb8KrYiU616A7IqcFWG7Msp1RKAXQ/hapjivRg+g5IYWSiFhnk3OKYV5T4Ft1t8Cczg==} engines: {node: '>=20.18.0'} @@ -12611,6 +12748,9 @@ packages: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver-regex@4.0.5: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} @@ -12636,6 +12776,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@7.0.5: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} @@ -12914,6 +13058,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + srvx@0.10.1: resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} @@ -13158,6 +13305,10 @@ packages: suf-log@2.5.3: resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} @@ -13538,6 +13689,10 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -16100,6 +16255,20 @@ snapshots: dependencies: '@edge-runtime/primitives': 6.0.0 + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 7.7.4 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -19816,6 +19985,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tanstack/history@1.154.14': {} '@tanstack/query-core@5.100.6': {} @@ -20117,6 +20290,13 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.17 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -20206,6 +20386,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -20237,6 +20419,10 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.17 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -20289,6 +20475,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.17 + '@types/retry@0.12.2': {} '@types/semver@7.7.1': {} @@ -21436,6 +21626,8 @@ snapshots: atomic-sleep@1.0.0: {} + atomically@1.7.0: {} + autoprefixer@10.5.0(postcss@8.5.13): dependencies: browserslist: 4.28.2 @@ -21708,6 +21900,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + borsh@0.7.0: dependencies: bn.js: 5.2.3 @@ -21875,6 +22070,18 @@ snapshots: tar: 7.5.11 unique-filename: 3.0.0 + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + cachedir@2.4.0: {} call-bind-apply-helpers@1.0.2: @@ -22138,6 +22345,10 @@ snapshots: kind-of: 6.0.3 shallow-clone: 3.0.1 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@1.2.1: {} @@ -22253,6 +22464,19 @@ snapshots: concat-map@0.0.1: {} + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.7.4 + confbox@0.1.8: {} confbox@0.2.4: {} @@ -22669,6 +22893,10 @@ snapshots: de-indent@1.0.2: {} + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + debounce@1.2.1: {} debug@2.6.9: @@ -22712,6 +22940,10 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@4.1.4: dependencies: type-detect: 4.1.0 @@ -22761,6 +22993,8 @@ snapshots: dependencies: clone: 1.0.4 + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -22900,6 +23134,10 @@ snapshots: dependencies: is-obj: 2.0.0 + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -22952,8 +23190,21 @@ snapshots: dependencies: jake: 10.9.4 + electron-store@8.2.0: + dependencies: + conf: 10.2.0 + type-fest: 2.19.0 + electron-to-chromium@1.5.331: {} + electron@39.8.10: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.17 + extract-zip: 2.0.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -23166,6 +23417,9 @@ snapshots: es-toolkit@1.46.1: {} + es6-error@4.1.1: + optional: true + es6-promise@4.2.8: {} es6-promisify@5.0.0: @@ -24467,6 +24721,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -24522,6 +24786,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} graphql@16.13.2: {} @@ -24867,6 +25145,11 @@ snapshots: jsprim: 2.0.2 sshpk: 1.18.0 + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -25529,6 +25812,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: optional: true @@ -25929,6 +26214,8 @@ snapshots: dependencies: tslib: 2.8.1 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.3.5: {} @@ -26024,6 +26311,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} md5-file@3.2.3: @@ -26791,8 +27083,14 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-fn@4.0.0: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimalistic-assert@1.0.1: {} @@ -27207,6 +27505,8 @@ snapshots: query-string: 5.1.1 sort-keys: 2.0.0 + normalize-url@6.1.0: {} + npm-package-arg@11.0.3: dependencies: hosted-git-info: 7.0.2 @@ -27681,6 +27981,8 @@ snapshots: magic-regexp: 0.10.0 oxc-parser: 0.128.0 + p-cancelable@2.1.1: {} + p-event@5.0.1: dependencies: p-timeout: 5.1.0 @@ -27944,6 +28246,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + pkglab-darwin-arm64@0.17.1: optional: true @@ -28346,6 +28652,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + quick-lru@6.1.2: {} radix3@1.1.2: {} @@ -28728,6 +29036,8 @@ snapshots: requires-port@1.0.0: {} + resolve-alpn@1.2.1: {} + resolve-from@3.0.0: optional: true @@ -28761,6 +29071,10 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -28826,6 +29140,16 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.47(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.8.3)(vue-tsc@3.1.4(typescript@5.8.3)): dependencies: '@babel/generator': 7.29.1 @@ -29029,6 +29353,9 @@ snapshots: '@types/node-forge': 1.3.14 node-forge: 1.4.0 + semver-compare@1.0.0: + optional: true + semver-regex@4.0.5: {} semver@7.7.4: {} @@ -29087,6 +29414,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serialize-javascript@7.0.5: {} seroval-plugins@1.5.0(seroval@1.5.2): @@ -29472,6 +29804,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + srvx@0.10.1: {} srvx@0.11.15: {} @@ -29734,6 +30069,12 @@ snapshots: dependencies: s.color: 0.0.15 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + superagent@8.1.2: dependencies: component-emitter: 1.3.1 @@ -30113,6 +30454,9 @@ snapshots: type-detect@4.1.0: {} + type-fest@0.13.1: + optional: true + type-fest@0.16.0: {} type-fest@0.21.3: {} From 83a46168ee639f3f5728ef98151b5c9333daffb0 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 04:39:39 +0400 Subject: [PATCH 03/19] feat(electron): harden token storage encryption (#8791) --- .../src/storage/__tests__/index.test.ts | 269 ++++++++++++++++-- packages/electron/src/storage/index.ts | 214 +++++++++++++- 2 files changed, 450 insertions(+), 33 deletions(-) diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 406d98651b3..0afedbd498f 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -8,11 +8,11 @@ const storeGet = vi.fn(); const storeSet = vi.fn(); const storeDelete = vi.fn(); +// `safeStorage` starts empty; each test installs only the methods for the backend it exercises. +// This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the +// async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it. vi.mock('electron', () => ({ - safeStorage: { - decryptString: vi.fn(), - encryptString: vi.fn(), - }, + safeStorage: {}, })); vi.mock('electron-store', () => ({ @@ -23,10 +23,32 @@ vi.mock('electron-store', () => ({ })), })); -describe('storage', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +const ss = safeStorage as unknown as Record; + +/** Installs the synchronous `safeStorage` API. */ +function installSync({ available = true }: { available?: boolean } = {}) { + ss.isEncryptionAvailable = vi.fn(() => available); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); +} + +/** Adds the Electron 42+ asynchronous `safeStorage` API. */ +function installAsync({ available = true }: { available?: boolean } = {}) { + ss.isAsyncEncryptionAvailable = vi.fn(() => Promise.resolve(available)); + ss.encryptStringAsync = vi.fn((value: string) => Promise.resolve(Buffer.from(`enc(${value})`))); + ss.decryptStringAsync = vi.fn(); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset the mocked `safeStorage` shape so backend detection starts from a clean slate. + for (const key of Object.keys(ss)) { + delete ss[key]; + } +}); + +describe('storage options', () => { + beforeEach(() => installSync()); it('creates an electron-store instance with the default store name', () => { storage(); @@ -40,38 +62,239 @@ describe('storage', () => { expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' }); }); - it('returns null when a token is missing', () => { + it('forwards a custom path as electron-store `cwd`', () => { + storage({ path: '/tmp/clerk' }); + + expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens', cwd: '/tmp/clerk' }); + }); + + it('omits `cwd` when no path is provided', () => { + storage(); + + expect(Store).toHaveBeenCalledWith(expect.not.objectContaining({ cwd: expect.anything() })); + }); +}); + +describe('getItem', () => { + it('returns null when a token is missing', async () => { + installSync(); storeGet.mockReturnValue(undefined); - expect(storage().getItem('token-key')).toBeNull(); + await expect(storage().getItem('token-key')).resolves.toBeNull(); + }); + + it('returns an unencrypted (raw:) value as-is without decrypting', async () => { + installSync(); + storeGet.mockReturnValue('raw:jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).not.toHaveBeenCalled(); + }); + + it('deletes and returns null for an unrecognized value format', async () => { + installSync(); + storeGet.mockReturnValue('garbage-without-prefix'); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).toHaveBeenCalledWith('token-key'); + }); + + it('preserves the entry and returns null when no OS encryption is available', async () => { + installSync({ available: false }); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + + describe('sync backend', () => { + it('decrypts stored tokens', async () => { + installSync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); + }); + + it('returns null without deleting the entry when decryption fails', async () => { + installSync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + }); + + describe('async backend', () => { + it('decrypts stored tokens', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); + }); + + it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('returns null without deleting the entry when decryption rejects', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + + it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => { + installSync(); + installAsync({ available: false }); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); + // Critical: calling the async crypto while unavailable crashes the process on Electron 42.x. + expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled(); + }); + }); +}); + +describe('setItem', () => { + it('encrypts tokens before storing them (sync backend)', async () => { + installSync(); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('encrypts tokens before storing them (async backend)', async () => { + installSync(); + installAsync(); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => { + installSync(); + installAsync({ available: false }); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('does not persist when no encryption is available and no fallback is configured', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await storage().setItem('token-key', 'jwt'); + + expect(storeSet).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); }); - it('decrypts stored tokens', () => { - storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); - vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + it('persists unencrypted when no encryption is available and unencryptedFallback is enabled', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt'); - expect(storage().getItem('token-key')).toBe('jwt'); - expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token')); + expect(storeSet).toHaveBeenCalledWith('token-key', 'raw:jwt'); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); }); - it('returns null when token decryption fails', () => { - storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); - vi.mocked(safeStorage.decryptString).mockImplementation(() => { - throw new Error('decrypt failed'); + it('only warns once across repeated saves', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const adapter = storage(); + await adapter.setItem('a', '1'); + await adapter.setItem('b', '2'); + + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); + + it('does not downgrade to plaintext when encryption is available but encrypt() fails', async () => { + ss.isEncryptionAvailable = vi.fn(() => true); + ss.encryptString = vi.fn(() => { + throw new Error('encrypt failed'); }); + ss.decryptString = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Even with the fallback enabled, a *failed encrypt* (vs. unavailable encryption) must not be + // persisted in the clear. + await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt'); + + expect(storeSet).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); - expect(storage().getItem('token-key')).toBeNull(); + it('resolves the cipher lazily and retries when it was initially unavailable (e.g. before app ready)', async () => { + // Unavailable on the first probe (pre-`ready`), available afterwards. + ss.isEncryptionAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const adapter = storage(); + + await adapter.setItem('token-key', 'jwt'); + expect(storeSet).not.toHaveBeenCalled(); // not persisted while unavailable + + await adapter.setItem('token-key', 'jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + + warn.mockRestore(); }); - it('encrypts tokens before storing them', async () => { - vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token')); + it('ignores a partial async surface and uses the sync API', async () => { + // `encryptStringAsync` exists but the rest of the async trio does not — must not take the async + // path (and must not call the async crypto). + ss.encryptStringAsync = vi.fn(); + ss.isEncryptionAvailable = vi.fn(() => true); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64')); + expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); }); +}); +describe('removeItem', () => { it('removes stored tokens', async () => { await storage().removeItem('token-key'); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts index ce130078db0..8dd10f5f95b 100644 --- a/packages/electron/src/storage/index.ts +++ b/packages/electron/src/storage/index.ts @@ -4,30 +4,224 @@ import Store from 'electron-store'; import type { TokenStorage } from '../shared/types'; type StorageOptions = { + /** + * The name of the file (without extension) used to persist tokens. + * + * @default 'clerk-tokens' + */ name?: string; + /** + * The directory in which the token file is stored. Maps to `electron-store`'s `cwd` option. + * When omitted, the OS default user config directory is used. + */ + path?: string; + /** + * When OS-level encryption is unavailable (e.g. a Linux machine without a keyring), tokens are + * not persisted by default, which signs the user out on the next app launch. Set this to `true` + * to instead persist tokens **unencrypted** in that scenario, keeping the user signed in across + * restarts at the cost of storing tokens in plaintext on disk. + * + * @default false + */ + unencryptedFallback?: boolean; }; +/** + * Marks a value that was encrypted via {@link safeStorage}; the remainder is base64 ciphertext. + * values will be stored as: `enc:` + */ +const ENCRYPTED_PREFIX = 'enc:'; +/** + * Marks a value persisted unencrypted via the `unencryptedFallback` option. + * values will be stored as: `raw:` + */ +const RAW_PREFIX = 'raw:'; + +type Cipher = { + encrypt: (value: string) => Promise; + /** + * Decrypts a base64 payload. `shouldReEncrypt` is `true` (async backend only) when the OS key + * has rotated and the payload should be re-encrypted with the new key. + */ + decrypt: (payload: string) => Promise<{ value: string; shouldReEncrypt: boolean }>; +}; + +type AsyncSafeStorage = { + encryptStringAsync: (plainText: string) => Promise; + decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>; + isAsyncEncryptionAvailable: () => Promise; +}; + +const syncCipher: Cipher = { + encrypt: value => Promise.resolve(safeStorage.encryptString(value).toString('base64')), + decrypt: payload => + Promise.resolve({ value: safeStorage.decryptString(Buffer.from(payload, 'base64')), shouldReEncrypt: false }), +}; + +function hasAsyncSafeStorage(storage: typeof safeStorage): storage is typeof safeStorage & AsyncSafeStorage { + const candidate = storage as Partial; + + return ( + typeof candidate.encryptStringAsync === 'function' && + typeof candidate.decryptStringAsync === 'function' && + typeof candidate.isAsyncEncryptionAvailable === 'function' + ); +} + +function createAsyncCipher(storage: AsyncSafeStorage): Cipher { + return { + encrypt: async value => (await storage.encryptStringAsync(value)).toString('base64'), + decrypt: async payload => { + const { result, shouldReEncrypt } = await storage.decryptStringAsync(Buffer.from(payload, 'base64')); + return { value: result, shouldReEncrypt }; + }, + }; +} + +/** + * Resolves the crypto backend to use, or `null` when no OS encryption is currently available. + */ +async function resolveCipher(): Promise { + // Prefer the async API, but only when the full optional function surface exists. + if (hasAsyncSafeStorage(safeStorage)) { + try { + if (await safeStorage.isAsyncEncryptionAvailable()) { + return createAsyncCipher(safeStorage); + } + } catch { + /* fall through to the synchronous API */ + } + } + + // The synchronous API blocks the calling thread on the OS prompt during the first encrypt/decrypt, + if (typeof safeStorage.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()) { + return syncCipher; + } + + return null; +} + +/** + * Creates a secure {@link TokenStorage} adapter for the Electron main process. + * + * Tokens are persisted with `electron-store` and encrypted at rest using Electron's + * {@link safeStorage} API, which is backed by the OS keystore (Keychain on macOS, DPAPI on + * Windows, libsecret/kwallet on Linux). It uses Electron 42's async `safeStorage` API only when it + * reports itself available (which generally requires a code-signed app) and otherwise falls back to + * the synchronous API. Pass the result to `setupMain({ storage: storage() })`. + * + * Behavior is secure by default: when OS encryption is unavailable the adapter does not persist + * tokens (the user will be signed out on restart) unless {@link StorageOptions.unencryptedFallback} + * is enabled. Undecryptable entries return `null`. + * + * @example + * ```ts + * import { setupMain } from '@clerk/electron'; + * import { storage } from '@clerk/electron/storage'; + * + * setupMain({ storage: storage({ name: 'my-app-tokens' }) }); + * ``` + */ export function storage(options: StorageOptions = {}): TokenStorage { - const store = new Store>({ name: options.name ?? 'clerk-tokens' }); + const store = new Store>({ + name: options.name ?? 'clerk-tokens', + ...(options.path ? { cwd: options.path } : {}), + }); + + let cachedCipher: Cipher | null = null; + let resolving: Promise | null = null; + const getCipher = (): Promise => { + if (cachedCipher) { + return Promise.resolve(cachedCipher); + } + resolving ??= resolveCipher().then(resolved => { + resolving = null; + if (resolved) { + cachedCipher = resolved; + } + return resolved; + }); + return resolving; + }; + + let warned = false; + const warnOnce = (message: string) => { + if (warned) { + return; + } + warned = true; + console.warn(message); + }; return { - getItem(key) { - const encrypted = store.get(key); + async getItem(key) { + const stored = store.get(key); - if (!encrypted) { + if (!stored) { return null; } + if (stored.startsWith(RAW_PREFIX)) { + return stored.slice(RAW_PREFIX.length); + } + + if (stored.startsWith(ENCRYPTED_PREFIX)) { + const cipher = await getCipher(); + + // No usable OS encryption, preserve entry. + if (!cipher) { + return null; + } + + const payload = stored.slice(ENCRYPTED_PREFIX.length); + + try { + const { value, shouldReEncrypt } = await cipher.decrypt(payload); + + if (shouldReEncrypt) { + // OS key has rotated, persist with new value + try { + store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); + } catch { + // keep the existing payload; it still decrypts for now + } + } + + return value; + } catch { + // Decryption failed, preserve the entry, new write on next sign-in. + return null; + } + } + + // Unknown or unrecognized format, drop the entry so we don't repeatedly fail on it. + store.delete(key); + return null; + }, + async setItem(key, value) { + const cipher = await getCipher(); + + if (!cipher) { + if (options.unencryptedFallback) { + warnOnce( + 'Clerk: OS encryption is unavailable; falling back to unencrypted storage. Session tokens are being stored unencrypted on local disk.', + ); + store.set(key, RAW_PREFIX + value); + } else { + warnOnce( + 'Clerk: OS encryption is unavailable and unencryptedFallback is not enabled, so tokens are not being persisted. The user will be signed out on the next launch. Pass `storage({ unencryptedFallback: true })` to persist unencrypted (less secure).', + ); + } + return; + } + try { - return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); + store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); } catch { - return null; + // Encryption is available but encryption failed + warnOnce('Clerk: failed to encrypt the session token; it was not persisted.'); } }, - setItem(key, value) { - const encrypted = safeStorage.encryptString(value); - store.set(key, encrypted.toString('base64')); - }, removeItem(key) { store.delete(key); }, From 73f716c1ee15efde3cf4db27d227347bc9f709f9 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 06:25:29 +0400 Subject: [PATCH 04/19] fix(electron): resolve storage type errors (#8792) --- .../src/storage/__tests__/index.test.ts | 25 ++++++++++++------- packages/electron/tsconfig.declarations.json | 3 ++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 0afedbd498f..aa930e632b3 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -8,6 +8,12 @@ const storeGet = vi.fn(); const storeSet = vi.fn(); const storeDelete = vi.fn(); +type AsyncSafeStorage = { + encryptStringAsync: (value: string) => Promise; + decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>; + isAsyncEncryptionAvailable: () => Promise; +}; + // `safeStorage` starts empty; each test installs only the methods for the backend it exercises. // This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the // async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it. @@ -24,6 +30,7 @@ vi.mock('electron-store', () => ({ })); const ss = safeStorage as unknown as Record; +const asyncSafeStorage = safeStorage as typeof safeStorage & AsyncSafeStorage; /** Installs the synchronous `safeStorage` API. */ function installSync({ available = true }: { available?: boolean } = {}) { @@ -134,20 +141,20 @@ describe('getItem', () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); await expect(storage().getItem('token-key')).resolves.toBe('jwt'); - expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); + expect(asyncSafeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); }); it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); await expect(storage().getItem('token-key')).resolves.toBe('jwt'); - expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -155,7 +162,7 @@ describe('getItem', () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); await expect(storage().getItem('token-key')).resolves.toBeNull(); expect(storeDelete).not.toHaveBeenCalled(); @@ -170,7 +177,7 @@ describe('getItem', () => { await expect(storage().getItem('token-key')).resolves.toBe('jwt'); expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); // Critical: calling the async crypto while unavailable crashes the process on Electron 42.x. - expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.decryptStringAsync).not.toHaveBeenCalled(); }); }); }); @@ -191,7 +198,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); - expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -202,7 +209,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled(); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -290,7 +297,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled(); }); }); diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json index c42a5efd18e..860b72f67d1 100644 --- a/packages/electron/tsconfig.declarations.json +++ b/packages/electron/tsconfig.declarations.json @@ -8,5 +8,6 @@ "declarationMap": true, "sourceMap": false, "declarationDir": "./dist/types" - } + }, + "exclude": ["**/__tests__/**/*"] } From 48a548499ca73b607c9007593a5124d41610e755 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 9 Jun 2026 01:14:03 +0400 Subject: [PATCH 05/19] feat(electron): scaffold initial @clerk/electron package Add the initial packages/electron package with metadata, dual ESM+CJS tsup build, exports, and a README placeholder. Exposes the intended entrypoints with stubbed bodies: - @clerk/electron (Electron main process) - @clerk/electron/preload (Electron preload scripts) --- .changeset/electron-package-scaffold.md | 2 + packages/electron/LICENSE | 21 +++++ packages/electron/README.md | 59 ++++++++++++++ packages/electron/package.json | 82 ++++++++++++++++++++ packages/electron/src/global.d.ts | 3 + packages/electron/src/index.ts | 2 + packages/electron/src/preload/index.ts | 2 + packages/electron/tsconfig.declarations.json | 12 +++ packages/electron/tsconfig.json | 23 ++++++ packages/electron/tsup.config.ts | 38 +++++++++ pnpm-lock.yaml | 12 ++- 11 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .changeset/electron-package-scaffold.md create mode 100644 packages/electron/LICENSE create mode 100644 packages/electron/README.md create mode 100644 packages/electron/package.json create mode 100644 packages/electron/src/global.d.ts create mode 100644 packages/electron/src/index.ts create mode 100644 packages/electron/src/preload/index.ts create mode 100644 packages/electron/tsconfig.declarations.json create mode 100644 packages/electron/tsconfig.json create mode 100644 packages/electron/tsup.config.ts diff --git a/.changeset/electron-package-scaffold.md b/.changeset/electron-package-scaffold.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/electron-package-scaffold.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/electron/LICENSE b/packages/electron/LICENSE new file mode 100644 index 00000000000..daceccfbc84 --- /dev/null +++ b/packages/electron/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/electron/README.md b/packages/electron/README.md new file mode 100644 index 00000000000..65101286b3e --- /dev/null +++ b/packages/electron/README.md @@ -0,0 +1,59 @@ +

+ + + + + + +
+

@clerk/electron

+

+ +
+ +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_electron) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/electron/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron) + +
+ +## Getting Started + +[Clerk](https://clerk.com/?utm_source=github&utm_medium=clerk_electron) is the easiest way to add authentication and user management to your Electron application. + +> [!WARNING] +> `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change. + +This package exposes two entrypoints, targeting Electron's distinct runtime contexts: + +- `@clerk/electron` — for use in the Electron **main** process. +- `@clerk/electron/preload` — for use in Electron **preload** scripts. + +## Support + +For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron). + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/electron` follows good practices of security, but 100% security cannot be assured. + +`@clerk/electron` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/electron/LICENSE) for more information. diff --git a/packages/electron/package.json b/packages/electron/package.json new file mode 100644 index 00000000000..dfb89cee8b6 --- /dev/null +++ b/packages/electron/package.json @@ -0,0 +1,82 @@ +{ + "name": "@clerk/electron", + "version": "0.0.0", + "description": "Clerk SDK for Electron", + "keywords": [ + "clerk", + "electron", + "auth", + "authentication", + "session", + "jwt", + "desktop" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron" + }, + "license": "MIT", + "author": "Clerk", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/types/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/types/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./preload": { + "import": { + "types": "./dist/types/preload/index.d.ts", + "default": "./dist/esm/preload/index.js" + }, + "require": { + "types": "./dist/types/preload/index.d.ts", + "default": "./dist/cjs/preload/index.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "preload" + ], + "scripts": { + "build": "tsup", + "build:declarations": "tsc -p tsconfig.declarations.json", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax", + "lint:publint": "publint", + "test": "vitest run", + "test:ci": "vitest run --maxWorkers=70%", + "test:watch": "vitest" + }, + "dependencies": { + "tslib": "catalog:repo" + }, + "devDependencies": { + "@types/node": "^22.19.17" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts new file mode 100644 index 00000000000..09ed4b48d45 --- /dev/null +++ b/packages/electron/src/global.d.ts @@ -0,0 +1,3 @@ +declare const PACKAGE_NAME: string; +declare const PACKAGE_VERSION: string; +declare const __DEV__: boolean; diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts new file mode 100644 index 00000000000..4266b361b4a --- /dev/null +++ b/packages/electron/src/index.ts @@ -0,0 +1,2 @@ +// TODO: Implement the Clerk SDK for the Electron main process. +export const SDK_NAME = PACKAGE_NAME; diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts new file mode 100644 index 00000000000..81876ea7a2a --- /dev/null +++ b/packages/electron/src/preload/index.ts @@ -0,0 +1,2 @@ +// TODO: Implement the Clerk preload bridge for the Electron preload context. +export const SDK_NAME = PACKAGE_NAME; diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json new file mode 100644 index 00000000000..c42a5efd18e --- /dev/null +++ b/packages/electron/tsconfig.declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "sourceMap": false, + "declarationDir": "./dist/types" + } +} diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json new file mode 100644 index 00000000000..274fd384521 --- /dev/null +++ b/packages/electron/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "declaration": true, + "declarationDir": "dist/types", + "declarationMap": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts new file mode 100644 index 00000000000..ff369e0c90d --- /dev/null +++ b/packages/electron/tsup.config.ts @@ -0,0 +1,38 @@ +import type { Options } from 'tsup'; +import { defineConfig } from 'tsup'; + +import { runAfterLast } from '../../scripts/utils'; +import { name, version } from './package.json'; + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + const shouldPublish = !!overrideOptions.env?.publish; + + const common: Options = { + entry: ['./src/index.ts', './src/preload/index.ts'], + bundle: true, + clean: true, + minify: false, + sourcemap: true, + legacyOutput: true, + treeshake: true, + define: { + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isWatch}`, + }, + }; + + const esm: Options = { + ...common, + format: 'esm', + }; + + const cjs: Options = { + ...common, + format: 'cjs', + outDir: './dist/cjs', + }; + + return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(esm, cjs); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b02d0e16fe2..21c4b1dd91a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,16 @@ importers: specifier: ^5.10.0 version: 5.10.0 + packages/electron: + dependencies: + tslib: + specifier: catalog:repo + version: 2.8.1 + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.19.17 + packages/expo: dependencies: '@clerk/clerk-js': @@ -2883,7 +2893,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} From 61ccd5d59fd02fec0aa4cd53a7b2c1d689311f32 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 9 Jun 2026 09:36:55 -0700 Subject: [PATCH 06/19] chore(electron): Add initial main and preload scripts (#8785) --- packages/electron/package.json | 23 +- packages/electron/src/global.d.ts | 10 + packages/electron/src/index.ts | 4 +- .../src/main/__tests__/ipc-handlers.test.ts | 63 ++++ .../src/main/__tests__/setup-main.test.ts | 43 +++ packages/electron/src/main/ipc-handlers.ts | 24 ++ packages/electron/src/main/setup-main.ts | 18 + .../src/preload/__tests__/index.test.ts | 71 ++++ packages/electron/src/preload/index.ts | 20 +- packages/electron/src/shared/ipc.ts | 5 + packages/electron/src/shared/types.ts | 21 ++ .../src/storage/__tests__/index.test.ts | 80 ++++ packages/electron/src/storage/index.ts | 35 ++ packages/electron/tsup.config.ts | 2 +- pnpm-lock.yaml | 346 +++++++++++++++++- 15 files changed, 758 insertions(+), 7 deletions(-) create mode 100644 packages/electron/src/main/__tests__/ipc-handlers.test.ts create mode 100644 packages/electron/src/main/__tests__/setup-main.test.ts create mode 100644 packages/electron/src/main/ipc-handlers.ts create mode 100644 packages/electron/src/main/setup-main.ts create mode 100644 packages/electron/src/preload/__tests__/index.test.ts create mode 100644 packages/electron/src/shared/ipc.ts create mode 100644 packages/electron/src/shared/types.ts create mode 100644 packages/electron/src/storage/__tests__/index.test.ts create mode 100644 packages/electron/src/storage/index.ts diff --git a/packages/electron/package.json b/packages/electron/package.json index dfb89cee8b6..30c26ce3f0a 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -44,6 +44,16 @@ "default": "./dist/cjs/preload/index.js" } }, + "./storage": { + "import": { + "types": "./dist/types/storage/index.d.ts", + "default": "./dist/esm/storage/index.js" + }, + "require": { + "types": "./dist/types/storage/index.d.ts", + "default": "./dist/cjs/storage/index.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/cjs/index.js", @@ -71,7 +81,18 @@ "tslib": "catalog:repo" }, "devDependencies": { - "@types/node": "^22.19.17" + "@types/node": "^22.19.17", + "electron": "^39.2.6", + "electron-store": "^8.2.0" + }, + "peerDependencies": { + "electron": ">=28", + "electron-store": "^8.2.0" + }, + "peerDependenciesMeta": { + "electron-store": { + "optional": true + } }, "engines": { "node": ">=20.9.0" diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts index 09ed4b48d45..6c762af29a5 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,3 +1,13 @@ +import type { TokenCache } from './shared/types'; + declare const PACKAGE_NAME: string; declare const PACKAGE_VERSION: string; declare const __DEV__: boolean; + +declare global { + interface Window { + __clerk_internal_electron?: { + tokenCache: TokenCache; + }; + } +} diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 4266b361b4a..80aa04dd58e 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,2 +1,2 @@ -// TODO: Implement the Clerk SDK for the Electron main process. -export const SDK_NAME = PACKAGE_NAME; +export { setupMain } from './main/setup-main'; +export type { TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/ipc-handlers.test.ts b/packages/electron/src/main/__tests__/ipc-handlers.test.ts new file mode 100644 index 00000000000..c57c98220f4 --- /dev/null +++ b/packages/electron/src/main/__tests__/ipc-handlers.test.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc'; +import type { TokenStorage } from '../../shared/types'; +import { setupTokenCacheIpcHandlers } from '../ipc-handlers'; + +const ipcEvent = {} as Electron.IpcMainInvokeEvent; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +describe('setupTokenCacheIpcHandlers', () => { + const storage: TokenStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers token cache IPC handlers', () => { + setupTokenCacheIpcHandlers(storage); + + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, expect.any(Function)); + }); + + it('delegates token operations to the storage adapter', async () => { + vi.mocked(storage.getItem).mockResolvedValue('jwt'); + + setupTokenCacheIpcHandlers(storage); + + const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1]; + const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1]; + const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1]; + + await expect(getTokenHandler(ipcEvent, 'token-key')).resolves.toBe('jwt'); + await saveTokenHandler(ipcEvent, 'token-key', 'jwt'); + await clearTokenHandler(ipcEvent, 'token-key'); + + expect(storage.getItem).toHaveBeenCalledWith('token-key'); + expect(storage.setItem).toHaveBeenCalledWith('token-key', 'jwt'); + expect(storage.removeItem).toHaveBeenCalledWith('token-key'); + }); + + it('removes registered handlers on cleanup', () => { + const cleanup = setupTokenCacheIpcHandlers(storage); + + cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken); + }); +}); diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts new file mode 100644 index 00000000000..c1f90c90677 --- /dev/null +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TokenStorage } from '../../shared/types'; +import { setupMain } from '../setup-main'; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +describe('setupMain', () => { + const missingStorage = {} as Parameters[0]; + const storage: TokenStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requires a storage adapter', () => { + expect(() => setupMain(missingStorage)).toThrow('setupMain requires a storage adapter'); + }); + + it('sets up token persistence IPC handlers with the provided storage', () => { + setupMain({ storage }); + + expect(ipcMain.handle).toHaveBeenCalledTimes(3); + }); + + it('returns a cleanup function for registered handlers', () => { + const clerk = setupMain({ storage }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/electron/src/main/ipc-handlers.ts b/packages/electron/src/main/ipc-handlers.ts new file mode 100644 index 00000000000..5114729404b --- /dev/null +++ b/packages/electron/src/main/ipc-handlers.ts @@ -0,0 +1,24 @@ +import { ipcMain } from 'electron'; + +import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; +import type { TokenStorage } from '../shared/types'; + +export function setupTokenCacheIpcHandlers(storage: TokenStorage): () => void { + ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (_event, key: string) => { + return storage.getItem(key); + }); + + ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (_event, key: string, value: string) => { + return storage.setItem(key, value); + }); + + ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (_event, key: string) => { + return storage.removeItem(key); + }); + + return () => { + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.getToken); + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.saveToken); + ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.clearToken); + }; +} diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts new file mode 100644 index 00000000000..64c1853f2d8 --- /dev/null +++ b/packages/electron/src/main/setup-main.ts @@ -0,0 +1,18 @@ +import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; +import { setupTokenCacheIpcHandlers } from './ipc-handlers'; + +export function setupMain(options: SetupMainOptions): SetupMainReturn { + if (!options.storage) { + throw new Error( + 'Clerk: setupMain requires a storage adapter. Pass setupMain({ storage: storage() }) from @clerk/electron/storage, or provide a custom storage adapter.', + ); + } + + const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + + return { + cleanup() { + cleanupTokenPersistence(); + }, + }; +} diff --git a/packages/electron/src/preload/__tests__/index.test.ts b/packages/electron/src/preload/__tests__/index.test.ts new file mode 100644 index 00000000000..28c329ed7b4 --- /dev/null +++ b/packages/electron/src/preload/__tests__/index.test.ts @@ -0,0 +1,71 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc'; +import { setupPreload } from '../index'; + +vi.mock('electron', () => ({ + contextBridge: { + exposeInMainWorld: vi.fn(), + }, + ipcRenderer: { + invoke: vi.fn(), + }, +})); + +describe('setupPreload', () => { + const originalContextIsolated = process.contextIsolated; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }); + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(() => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: originalContextIsolated }); + }); + + it('exposes the Clerk Electron bridge through contextBridge when context isolation is enabled', () => { + setupPreload(); + + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith('__clerk_internal_electron', { + tokenCache: { + getToken: expect.any(Function), + saveToken: expect.any(Function), + clearToken: expect.any(Function), + }, + }); + }); + + it('exposes the Clerk Electron bridge on window when context isolation is disabled', () => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: false }); + + setupPreload(); + + expect(window.__clerk_internal_electron?.tokenCache).toEqual({ + getToken: expect.any(Function), + saveToken: expect.any(Function), + clearToken: expect.any(Function), + }); + }); + + it('forwards token cache calls over IPC', async () => { + setupPreload(); + + const bridge = vi.mocked(contextBridge.exposeInMainWorld).mock + .calls[0][1] as typeof window.__clerk_internal_electron; + + await bridge?.tokenCache.getToken('token-key'); + await bridge?.tokenCache.saveToken('token-key', 'jwt'); + await bridge?.tokenCache.clearToken('token-key'); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, 'token-key'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, 'token-key', 'jwt'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, 'token-key'); + }); +}); diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index 81876ea7a2a..8b9903b1c4d 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -1,2 +1,18 @@ -// TODO: Implement the Clerk preload bridge for the Electron preload context. -export const SDK_NAME = PACKAGE_NAME; +import { contextBridge, ipcRenderer } from 'electron'; + +import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; +import type { TokenCache } from '../shared/types'; + +export function setupPreload(): void { + const tokenCache: TokenCache = { + getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), + saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value), + clearToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.clearToken, key), + }; + + if (process.contextIsolated) { + contextBridge.exposeInMainWorld('__clerk_internal_electron', { tokenCache }); + } else { + window.__clerk_internal_electron = { tokenCache }; + } +} diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts new file mode 100644 index 00000000000..e50db4bb9c3 --- /dev/null +++ b/packages/electron/src/shared/ipc.ts @@ -0,0 +1,5 @@ +export const TOKEN_CACHE_CHANNELS = { + getToken: 'clerk:token-cache:get', + saveToken: 'clerk:token-cache:save', + clearToken: 'clerk:token-cache:clear', +} as const; diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts new file mode 100644 index 00000000000..9c51778cc28 --- /dev/null +++ b/packages/electron/src/shared/types.ts @@ -0,0 +1,21 @@ +type Awaitable = T | Promise; + +export type TokenStorage = { + getItem: (key: string) => Awaitable; + setItem: (key: string, value: string) => Awaitable; + removeItem: (key: string) => Awaitable; +}; + +export type SetupMainOptions = { + storage: TokenStorage; +}; + +export type SetupMainReturn = { + cleanup: () => void; +}; + +export type TokenCache = { + getToken: (key: string) => Promise; + saveToken: (key: string, value: string) => Promise; + clearToken: (key: string) => Promise; +}; diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts new file mode 100644 index 00000000000..406d98651b3 --- /dev/null +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -0,0 +1,80 @@ +import { safeStorage } from 'electron'; +import Store from 'electron-store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { storage } from '../index'; + +const storeGet = vi.fn(); +const storeSet = vi.fn(); +const storeDelete = vi.fn(); + +vi.mock('electron', () => ({ + safeStorage: { + decryptString: vi.fn(), + encryptString: vi.fn(), + }, +})); + +vi.mock('electron-store', () => ({ + default: vi.fn(() => ({ + get: storeGet, + set: storeSet, + delete: storeDelete, + })), +})); + +describe('storage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates an electron-store instance with the default store name', () => { + storage(); + + expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens' }); + }); + + it('supports a custom store name', () => { + storage({ name: 'custom-clerk-tokens' }); + + expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' }); + }); + + it('returns null when a token is missing', () => { + storeGet.mockReturnValue(undefined); + + expect(storage().getItem('token-key')).toBeNull(); + }); + + it('decrypts stored tokens', () => { + storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + expect(storage().getItem('token-key')).toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token')); + }); + + it('returns null when token decryption fails', () => { + storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + expect(storage().getItem('token-key')).toBeNull(); + }); + + it('encrypts tokens before storing them', async () => { + vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token')); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64')); + }); + + it('removes stored tokens', async () => { + await storage().removeItem('token-key'); + + expect(storeDelete).toHaveBeenCalledWith('token-key'); + }); +}); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts new file mode 100644 index 00000000000..ce130078db0 --- /dev/null +++ b/packages/electron/src/storage/index.ts @@ -0,0 +1,35 @@ +import { safeStorage } from 'electron'; +import Store from 'electron-store'; + +import type { TokenStorage } from '../shared/types'; + +type StorageOptions = { + name?: string; +}; + +export function storage(options: StorageOptions = {}): TokenStorage { + const store = new Store>({ name: options.name ?? 'clerk-tokens' }); + + return { + getItem(key) { + const encrypted = store.get(key); + + if (!encrypted) { + return null; + } + + try { + return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); + } catch { + return null; + } + }, + setItem(key, value) { + const encrypted = safeStorage.encryptString(value); + store.set(key, encrypted.toString('base64')); + }, + removeItem(key) { + store.delete(key); + }, + }; +} diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts index ff369e0c90d..5a598814cb3 100644 --- a/packages/electron/tsup.config.ts +++ b/packages/electron/tsup.config.ts @@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/index.ts', './src/preload/index.ts'], + entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'], bundle: true, clean: true, minify: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c4b1dd91a..7d1a9aa86d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -524,6 +524,12 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + electron: + specifier: ^39.2.6 + version: 39.8.10 + electron-store: + specifier: ^8.2.0 + version: 8.2.0 packages/expo: dependencies: @@ -2313,6 +2319,10 @@ packages: resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==} engines: {node: '>=18'} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2893,7 +2903,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -5448,6 +5458,10 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -5762,6 +5776,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -5834,6 +5851,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -5864,6 +5884,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5926,6 +5949,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -6908,6 +6934,10 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -7131,6 +7161,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} @@ -7256,6 +7290,14 @@ packages: resolution: {integrity: sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==} engines: {node: ^16.14.0 || >=18.0.0} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + cachedir@2.4.0: resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} engines: {node: '>=6'} @@ -7507,6 +7549,9 @@ packages: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -7665,6 +7710,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -8020,6 +8069,10 @@ packages: de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -8088,6 +8141,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -8134,6 +8191,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -8286,6 +8347,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -8360,9 +8425,17 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + electron-store@8.2.0: + resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} + electron-to-chromium@1.5.331: resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + electron@39.8.10: + resolution: {integrity: sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -8513,6 +8586,9 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -9529,6 +9605,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -9577,6 +9657,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -9848,6 +9932,10 @@ packages: resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} engines: {node: '>=0.10'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -10549,6 +10637,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -10982,6 +11073,10 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -11081,6 +11176,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -11479,6 +11578,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -11487,6 +11590,14 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -11800,6 +11911,10 @@ packages: resolution: {integrity: sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==} engines: {node: '>=4'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-package-arg@11.0.3: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -12056,6 +12171,10 @@ packages: peerDependencies: oxc-parser: '>=0.98.0' + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-event@5.0.1: resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -12356,6 +12475,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + pkglab-darwin-arm64@0.17.1: resolution: {integrity: sha512-7cy7ck7iQW6xYDBkV3NHW1wz4bub9sikFkrMj11AT/1PXVYFDhI7SaaZ6jxQgL3Mcvj34rQlUXKyVWrAzhrUDw==} engines: {node: '>=18'} @@ -12886,6 +13009,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + quick-lru@6.1.2: resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} engines: {node: '>=12'} @@ -13183,6 +13310,9 @@ packages: reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -13221,6 +13351,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -13286,6 +13419,10 @@ packages: engines: {node: 20 || >=22} hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rolldown-plugin-dts@0.16.12: resolution: {integrity: sha512-9dGjm5oqtKcbZNhpzyBgb8KrYiU616A7IqcFWG7Msp1RKAXQ/hapjivRg+g5IYWSiFhnk3OKYV5T4Ft1t8Cczg==} engines: {node: '>=20.18.0'} @@ -13438,6 +13575,9 @@ packages: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver-regex@4.0.5: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} @@ -13463,6 +13603,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@7.0.5: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} @@ -13748,6 +13892,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + srvx@0.10.1: resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} @@ -14018,6 +14165,10 @@ packages: suf-log@2.5.3: resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} @@ -14418,6 +14569,10 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -17150,6 +17305,20 @@ snapshots: dependencies: '@edge-runtime/primitives': 6.0.0 + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 7.7.4 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -21104,6 +21273,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -21487,6 +21660,13 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.17 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -21580,6 +21760,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -21611,6 +21793,10 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.17 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -21669,6 +21855,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.17 + '@types/retry@0.12.2': {} '@types/semver@7.7.1': {} @@ -22942,6 +23132,8 @@ snapshots: atomic-sleep@1.0.0: {} + atomically@1.7.0: {} + autoprefixer@10.5.0(postcss@8.5.13): dependencies: browserslist: 4.28.2 @@ -23217,6 +23409,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + borsh@0.7.0: dependencies: bn.js: 5.2.3 @@ -23384,6 +23579,18 @@ snapshots: tar: 7.5.11 unique-filename: 3.0.0 + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + cachedir@2.4.0: {} call-bind-apply-helpers@1.0.2: @@ -23661,6 +23868,10 @@ snapshots: kind-of: 6.0.3 shallow-clone: 3.0.1 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@1.2.1: {} @@ -23782,6 +23993,19 @@ snapshots: concat-map@0.0.1: {} + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.7.4 + confbox@0.1.8: {} confbox@0.2.4: {} @@ -24198,6 +24422,10 @@ snapshots: de-indent@1.0.2: {} + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + debounce@1.2.1: {} debug@2.6.9: @@ -24241,6 +24469,10 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -24294,6 +24526,8 @@ snapshots: dependencies: clone: 1.0.4 + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -24433,6 +24667,10 @@ snapshots: dependencies: is-obj: 2.0.0 + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -24492,8 +24730,21 @@ snapshots: dependencies: jake: 10.9.4 + electron-store@8.2.0: + dependencies: + conf: 10.2.0 + type-fest: 2.19.0 + electron-to-chromium@1.5.331: {} + electron@39.8.10: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.17 + extract-zip: 2.0.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -24708,6 +24959,9 @@ snapshots: es-toolkit@1.47.0: {} + es6-error@4.1.1: + optional: true + es6-promise@4.2.8: {} es6-promisify@5.0.0: @@ -26094,6 +26348,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -26149,6 +26413,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} graphql@16.13.2: {} @@ -26541,6 +26819,11 @@ snapshots: jsprim: 2.0.2 sshpk: 1.18.0 + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -27233,6 +27516,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: {} json-schema@0.4.0: {} @@ -27648,6 +27933,8 @@ snapshots: dependencies: tslib: 2.8.1 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.3.5: {} @@ -27749,6 +28036,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} md5-file@3.2.3: @@ -28636,10 +28928,16 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimalistic-assert@1.0.1: {} @@ -29097,6 +29395,8 @@ snapshots: query-string: 5.1.1 sort-keys: 2.0.0 + normalize-url@6.1.0: {} + npm-package-arg@11.0.3: dependencies: hosted-git-info: 7.0.2 @@ -29606,6 +29906,8 @@ snapshots: magic-regexp: 0.10.0 oxc-parser: 0.128.0 + p-cancelable@2.1.1: {} + p-event@5.0.1: dependencies: p-timeout: 5.1.0 @@ -29879,6 +30181,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + pkglab-darwin-arm64@0.17.1: optional: true @@ -30303,6 +30609,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + quick-lru@6.1.2: {} radix3@1.1.2: {} @@ -30746,6 +31054,8 @@ snapshots: reselect@5.2.0: {} + resolve-alpn@1.2.1: {} + resolve-from@3.0.0: optional: true @@ -30779,6 +31089,10 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -30849,6 +31163,16 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.47(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.8.3)(vue-tsc@3.1.4(typescript@5.8.3)): dependencies: '@babel/generator': 7.29.1 @@ -31057,6 +31381,9 @@ snapshots: '@types/node-forge': 1.3.14 node-forge: 1.4.0 + semver-compare@1.0.0: + optional: true + semver-regex@4.0.5: {} semver@7.7.4: {} @@ -31115,6 +31442,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serialize-javascript@7.0.5: {} seroval-plugins@1.5.0(seroval@1.5.2): @@ -31552,6 +31884,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + srvx@0.10.1: {} srvx@0.11.15: {} @@ -31840,6 +32175,12 @@ snapshots: dependencies: s.color: 0.0.15 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + superagent@8.1.2: dependencies: component-emitter: 1.3.1 @@ -32239,6 +32580,9 @@ snapshots: type-detect@4.1.0: {} + type-fest@0.13.1: + optional: true + type-fest@0.16.0: {} type-fest@0.21.3: {} From 1c224106253eaa8622a0d4678b2e1b844e6ebf96 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 04:39:39 +0400 Subject: [PATCH 07/19] feat(electron): harden token storage encryption (#8791) --- .../src/storage/__tests__/index.test.ts | 269 ++++++++++++++++-- packages/electron/src/storage/index.ts | 214 +++++++++++++- 2 files changed, 450 insertions(+), 33 deletions(-) diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 406d98651b3..0afedbd498f 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -8,11 +8,11 @@ const storeGet = vi.fn(); const storeSet = vi.fn(); const storeDelete = vi.fn(); +// `safeStorage` starts empty; each test installs only the methods for the backend it exercises. +// This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the +// async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it. vi.mock('electron', () => ({ - safeStorage: { - decryptString: vi.fn(), - encryptString: vi.fn(), - }, + safeStorage: {}, })); vi.mock('electron-store', () => ({ @@ -23,10 +23,32 @@ vi.mock('electron-store', () => ({ })), })); -describe('storage', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +const ss = safeStorage as unknown as Record; + +/** Installs the synchronous `safeStorage` API. */ +function installSync({ available = true }: { available?: boolean } = {}) { + ss.isEncryptionAvailable = vi.fn(() => available); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); +} + +/** Adds the Electron 42+ asynchronous `safeStorage` API. */ +function installAsync({ available = true }: { available?: boolean } = {}) { + ss.isAsyncEncryptionAvailable = vi.fn(() => Promise.resolve(available)); + ss.encryptStringAsync = vi.fn((value: string) => Promise.resolve(Buffer.from(`enc(${value})`))); + ss.decryptStringAsync = vi.fn(); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset the mocked `safeStorage` shape so backend detection starts from a clean slate. + for (const key of Object.keys(ss)) { + delete ss[key]; + } +}); + +describe('storage options', () => { + beforeEach(() => installSync()); it('creates an electron-store instance with the default store name', () => { storage(); @@ -40,38 +62,239 @@ describe('storage', () => { expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' }); }); - it('returns null when a token is missing', () => { + it('forwards a custom path as electron-store `cwd`', () => { + storage({ path: '/tmp/clerk' }); + + expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens', cwd: '/tmp/clerk' }); + }); + + it('omits `cwd` when no path is provided', () => { + storage(); + + expect(Store).toHaveBeenCalledWith(expect.not.objectContaining({ cwd: expect.anything() })); + }); +}); + +describe('getItem', () => { + it('returns null when a token is missing', async () => { + installSync(); storeGet.mockReturnValue(undefined); - expect(storage().getItem('token-key')).toBeNull(); + await expect(storage().getItem('token-key')).resolves.toBeNull(); + }); + + it('returns an unencrypted (raw:) value as-is without decrypting', async () => { + installSync(); + storeGet.mockReturnValue('raw:jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).not.toHaveBeenCalled(); + }); + + it('deletes and returns null for an unrecognized value format', async () => { + installSync(); + storeGet.mockReturnValue('garbage-without-prefix'); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).toHaveBeenCalledWith('token-key'); + }); + + it('preserves the entry and returns null when no OS encryption is available', async () => { + installSync({ available: false }); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + + describe('sync backend', () => { + it('decrypts stored tokens', async () => { + installSync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); + }); + + it('returns null without deleting the entry when decryption fails', async () => { + installSync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + }); + + describe('async backend', () => { + it('decrypts stored tokens', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); + }); + + it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('returns null without deleting the entry when decryption rejects', async () => { + installSync(); + installAsync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); + + await expect(storage().getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).not.toHaveBeenCalled(); + }); + + it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => { + installSync(); + installAsync({ available: false }); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + + await expect(storage().getItem('token-key')).resolves.toBe('jwt'); + expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); + // Critical: calling the async crypto while unavailable crashes the process on Electron 42.x. + expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled(); + }); + }); +}); + +describe('setItem', () => { + it('encrypts tokens before storing them (sync backend)', async () => { + installSync(); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('encrypts tokens before storing them (async backend)', async () => { + installSync(); + installAsync(); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => { + installSync(); + installAsync({ available: false }); + + await storage().setItem('token-key', 'jwt'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); + expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + }); + + it('does not persist when no encryption is available and no fallback is configured', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await storage().setItem('token-key', 'jwt'); + + expect(storeSet).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); }); - it('decrypts stored tokens', () => { - storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); - vi.mocked(safeStorage.decryptString).mockReturnValue('jwt'); + it('persists unencrypted when no encryption is available and unencryptedFallback is enabled', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt'); - expect(storage().getItem('token-key')).toBe('jwt'); - expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token')); + expect(storeSet).toHaveBeenCalledWith('token-key', 'raw:jwt'); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); }); - it('returns null when token decryption fails', () => { - storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64')); - vi.mocked(safeStorage.decryptString).mockImplementation(() => { - throw new Error('decrypt failed'); + it('only warns once across repeated saves', async () => { + installSync({ available: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const adapter = storage(); + await adapter.setItem('a', '1'); + await adapter.setItem('b', '2'); + + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); + + it('does not downgrade to plaintext when encryption is available but encrypt() fails', async () => { + ss.isEncryptionAvailable = vi.fn(() => true); + ss.encryptString = vi.fn(() => { + throw new Error('encrypt failed'); }); + ss.decryptString = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Even with the fallback enabled, a *failed encrypt* (vs. unavailable encryption) must not be + // persisted in the clear. + await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt'); + + expect(storeSet).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); - expect(storage().getItem('token-key')).toBeNull(); + it('resolves the cipher lazily and retries when it was initially unavailable (e.g. before app ready)', async () => { + // Unavailable on the first probe (pre-`ready`), available afterwards. + ss.isEncryptionAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const adapter = storage(); + + await adapter.setItem('token-key', 'jwt'); + expect(storeSet).not.toHaveBeenCalled(); // not persisted while unavailable + + await adapter.setItem('token-key', 'jwt'); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + + warn.mockRestore(); }); - it('encrypts tokens before storing them', async () => { - vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token')); + it('ignores a partial async surface and uses the sync API', async () => { + // `encryptStringAsync` exists but the rest of the async trio does not — must not take the async + // path (and must not call the async crypto). + ss.encryptStringAsync = vi.fn(); + ss.isEncryptionAvailable = vi.fn(() => true); + ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`)); + ss.decryptString = vi.fn(); await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64')); + expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); }); +}); +describe('removeItem', () => { it('removes stored tokens', async () => { await storage().removeItem('token-key'); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts index ce130078db0..8dd10f5f95b 100644 --- a/packages/electron/src/storage/index.ts +++ b/packages/electron/src/storage/index.ts @@ -4,30 +4,224 @@ import Store from 'electron-store'; import type { TokenStorage } from '../shared/types'; type StorageOptions = { + /** + * The name of the file (without extension) used to persist tokens. + * + * @default 'clerk-tokens' + */ name?: string; + /** + * The directory in which the token file is stored. Maps to `electron-store`'s `cwd` option. + * When omitted, the OS default user config directory is used. + */ + path?: string; + /** + * When OS-level encryption is unavailable (e.g. a Linux machine without a keyring), tokens are + * not persisted by default, which signs the user out on the next app launch. Set this to `true` + * to instead persist tokens **unencrypted** in that scenario, keeping the user signed in across + * restarts at the cost of storing tokens in plaintext on disk. + * + * @default false + */ + unencryptedFallback?: boolean; }; +/** + * Marks a value that was encrypted via {@link safeStorage}; the remainder is base64 ciphertext. + * values will be stored as: `enc:` + */ +const ENCRYPTED_PREFIX = 'enc:'; +/** + * Marks a value persisted unencrypted via the `unencryptedFallback` option. + * values will be stored as: `raw:` + */ +const RAW_PREFIX = 'raw:'; + +type Cipher = { + encrypt: (value: string) => Promise; + /** + * Decrypts a base64 payload. `shouldReEncrypt` is `true` (async backend only) when the OS key + * has rotated and the payload should be re-encrypted with the new key. + */ + decrypt: (payload: string) => Promise<{ value: string; shouldReEncrypt: boolean }>; +}; + +type AsyncSafeStorage = { + encryptStringAsync: (plainText: string) => Promise; + decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>; + isAsyncEncryptionAvailable: () => Promise; +}; + +const syncCipher: Cipher = { + encrypt: value => Promise.resolve(safeStorage.encryptString(value).toString('base64')), + decrypt: payload => + Promise.resolve({ value: safeStorage.decryptString(Buffer.from(payload, 'base64')), shouldReEncrypt: false }), +}; + +function hasAsyncSafeStorage(storage: typeof safeStorage): storage is typeof safeStorage & AsyncSafeStorage { + const candidate = storage as Partial; + + return ( + typeof candidate.encryptStringAsync === 'function' && + typeof candidate.decryptStringAsync === 'function' && + typeof candidate.isAsyncEncryptionAvailable === 'function' + ); +} + +function createAsyncCipher(storage: AsyncSafeStorage): Cipher { + return { + encrypt: async value => (await storage.encryptStringAsync(value)).toString('base64'), + decrypt: async payload => { + const { result, shouldReEncrypt } = await storage.decryptStringAsync(Buffer.from(payload, 'base64')); + return { value: result, shouldReEncrypt }; + }, + }; +} + +/** + * Resolves the crypto backend to use, or `null` when no OS encryption is currently available. + */ +async function resolveCipher(): Promise { + // Prefer the async API, but only when the full optional function surface exists. + if (hasAsyncSafeStorage(safeStorage)) { + try { + if (await safeStorage.isAsyncEncryptionAvailable()) { + return createAsyncCipher(safeStorage); + } + } catch { + /* fall through to the synchronous API */ + } + } + + // The synchronous API blocks the calling thread on the OS prompt during the first encrypt/decrypt, + if (typeof safeStorage.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()) { + return syncCipher; + } + + return null; +} + +/** + * Creates a secure {@link TokenStorage} adapter for the Electron main process. + * + * Tokens are persisted with `electron-store` and encrypted at rest using Electron's + * {@link safeStorage} API, which is backed by the OS keystore (Keychain on macOS, DPAPI on + * Windows, libsecret/kwallet on Linux). It uses Electron 42's async `safeStorage` API only when it + * reports itself available (which generally requires a code-signed app) and otherwise falls back to + * the synchronous API. Pass the result to `setupMain({ storage: storage() })`. + * + * Behavior is secure by default: when OS encryption is unavailable the adapter does not persist + * tokens (the user will be signed out on restart) unless {@link StorageOptions.unencryptedFallback} + * is enabled. Undecryptable entries return `null`. + * + * @example + * ```ts + * import { setupMain } from '@clerk/electron'; + * import { storage } from '@clerk/electron/storage'; + * + * setupMain({ storage: storage({ name: 'my-app-tokens' }) }); + * ``` + */ export function storage(options: StorageOptions = {}): TokenStorage { - const store = new Store>({ name: options.name ?? 'clerk-tokens' }); + const store = new Store>({ + name: options.name ?? 'clerk-tokens', + ...(options.path ? { cwd: options.path } : {}), + }); + + let cachedCipher: Cipher | null = null; + let resolving: Promise | null = null; + const getCipher = (): Promise => { + if (cachedCipher) { + return Promise.resolve(cachedCipher); + } + resolving ??= resolveCipher().then(resolved => { + resolving = null; + if (resolved) { + cachedCipher = resolved; + } + return resolved; + }); + return resolving; + }; + + let warned = false; + const warnOnce = (message: string) => { + if (warned) { + return; + } + warned = true; + console.warn(message); + }; return { - getItem(key) { - const encrypted = store.get(key); + async getItem(key) { + const stored = store.get(key); - if (!encrypted) { + if (!stored) { return null; } + if (stored.startsWith(RAW_PREFIX)) { + return stored.slice(RAW_PREFIX.length); + } + + if (stored.startsWith(ENCRYPTED_PREFIX)) { + const cipher = await getCipher(); + + // No usable OS encryption, preserve entry. + if (!cipher) { + return null; + } + + const payload = stored.slice(ENCRYPTED_PREFIX.length); + + try { + const { value, shouldReEncrypt } = await cipher.decrypt(payload); + + if (shouldReEncrypt) { + // OS key has rotated, persist with new value + try { + store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); + } catch { + // keep the existing payload; it still decrypts for now + } + } + + return value; + } catch { + // Decryption failed, preserve the entry, new write on next sign-in. + return null; + } + } + + // Unknown or unrecognized format, drop the entry so we don't repeatedly fail on it. + store.delete(key); + return null; + }, + async setItem(key, value) { + const cipher = await getCipher(); + + if (!cipher) { + if (options.unencryptedFallback) { + warnOnce( + 'Clerk: OS encryption is unavailable; falling back to unencrypted storage. Session tokens are being stored unencrypted on local disk.', + ); + store.set(key, RAW_PREFIX + value); + } else { + warnOnce( + 'Clerk: OS encryption is unavailable and unencryptedFallback is not enabled, so tokens are not being persisted. The user will be signed out on the next launch. Pass `storage({ unencryptedFallback: true })` to persist unencrypted (less secure).', + ); + } + return; + } + try { - return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); + store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); } catch { - return null; + // Encryption is available but encryption failed + warnOnce('Clerk: failed to encrypt the session token; it was not persisted.'); } }, - setItem(key, value) { - const encrypted = safeStorage.encryptString(value); - store.set(key, encrypted.toString('base64')); - }, removeItem(key) { store.delete(key); }, From 74eb5c32e42b70c2c070e2b58cb175b6736965dd Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 06:25:29 +0400 Subject: [PATCH 08/19] fix(electron): resolve storage type errors (#8792) --- .../src/storage/__tests__/index.test.ts | 25 ++++++++++++------- packages/electron/tsconfig.declarations.json | 3 ++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 0afedbd498f..aa930e632b3 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -8,6 +8,12 @@ const storeGet = vi.fn(); const storeSet = vi.fn(); const storeDelete = vi.fn(); +type AsyncSafeStorage = { + encryptStringAsync: (value: string) => Promise; + decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>; + isAsyncEncryptionAvailable: () => Promise; +}; + // `safeStorage` starts empty; each test installs only the methods for the backend it exercises. // This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the // async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it. @@ -24,6 +30,7 @@ vi.mock('electron-store', () => ({ })); const ss = safeStorage as unknown as Record; +const asyncSafeStorage = safeStorage as typeof safeStorage & AsyncSafeStorage; /** Installs the synchronous `safeStorage` API. */ function installSync({ available = true }: { available?: boolean } = {}) { @@ -134,20 +141,20 @@ describe('getItem', () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false }); await expect(storage().getItem('token-key')).resolves.toBe('jwt'); - expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); + expect(asyncSafeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher')); }); it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); await expect(storage().getItem('token-key')).resolves.toBe('jwt'); - expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -155,7 +162,7 @@ describe('getItem', () => { installSync(); installAsync(); storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); - vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed')); await expect(storage().getItem('token-key')).resolves.toBeNull(); expect(storeDelete).not.toHaveBeenCalled(); @@ -170,7 +177,7 @@ describe('getItem', () => { await expect(storage().getItem('token-key')).resolves.toBe('jwt'); expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher')); // Critical: calling the async crypto while unavailable crashes the process on Electron 42.x. - expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.decryptStringAsync).not.toHaveBeenCalled(); }); }); }); @@ -191,7 +198,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); - expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); + expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt'); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -202,7 +209,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled(); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); @@ -290,7 +297,7 @@ describe('setItem', () => { await storage().setItem('token-key', 'jwt'); expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt'); - expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled(); + expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled(); }); }); diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json index c42a5efd18e..860b72f67d1 100644 --- a/packages/electron/tsconfig.declarations.json +++ b/packages/electron/tsconfig.declarations.json @@ -8,5 +8,6 @@ "declarationMap": true, "sourceMap": false, "declarationDir": "./dist/types" - } + }, + "exclude": ["**/__tests__/**/*"] } From 1b938fafce0b24030814aed21750e2ecf60bf920 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 10 Jun 2026 11:52:10 -0700 Subject: [PATCH 09/19] feat(electron): Add initial React integration export (#8806) --- packages/electron/README.md | 21 +++- packages/electron/package.json | 20 +++- .../react/__tests__/ClerkProvider.test.tsx | 111 ++++++++++++++++++ .../src/react/create-clerk-instance.ts | 40 +++++++ packages/electron/src/react/index.tsx | 35 ++++++ packages/electron/tsconfig.json | 3 +- packages/electron/tsup.config.ts | 2 +- pnpm-lock.yaml | 21 +++- 8 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 packages/electron/src/react/__tests__/ClerkProvider.test.tsx create mode 100644 packages/electron/src/react/create-clerk-instance.ts create mode 100644 packages/electron/src/react/index.tsx diff --git a/packages/electron/README.md b/packages/electron/README.md index 65101286b3e..f1306339367 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -31,10 +31,29 @@ > [!WARNING] > `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change. -This package exposes two entrypoints, targeting Electron's distinct runtime contexts: +This package exposes entrypoints for Electron's distinct runtime contexts: - `@clerk/electron` — for use in the Electron **main** process. - `@clerk/electron/preload` — for use in Electron **preload** scripts. +- `@clerk/electron/react` — for use in the Electron **renderer** process. +- `@clerk/electron/storage` — default token storage backed by `electron-store`. + +```ts +// main.ts +import { setupMain } from '@clerk/electron'; +import { storage } from '@clerk/electron/storage'; + +setupMain({ + storage: storage(), +}); +``` + +```tsx +// renderer.tsx +import { ClerkProvider } from '@clerk/electron/react'; + +{/* ... */}; +``` ## Support diff --git a/packages/electron/package.json b/packages/electron/package.json index 30c26ce3f0a..5347bcfed38 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -54,6 +54,16 @@ "default": "./dist/cjs/storage/index.js" } }, + "./react": { + "import": { + "types": "./dist/types/react/index.d.ts", + "default": "./dist/esm/react/index.js" + }, + "require": { + "types": "./dist/types/react/index.d.ts", + "default": "./dist/cjs/react/index.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/cjs/index.js", @@ -78,6 +88,9 @@ "test:watch": "vitest" }, "dependencies": { + "@clerk/clerk-js": "workspace:^", + "@clerk/react": "workspace:^", + "@clerk/ui": "workspace:^", "tslib": "catalog:repo" }, "devDependencies": { @@ -87,11 +100,16 @@ }, "peerDependencies": { "electron": ">=28", - "electron-store": "^8.2.0" + "electron-store": "^8.2.0", + "react": "catalog:peer-react", + "react-dom": "catalog:peer-react" }, "peerDependenciesMeta": { "electron-store": { "optional": true + }, + "react-dom": { + "optional": true } }, "engines": { diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx new file mode 100644 index 00000000000..1aa7cdb90ce --- /dev/null +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -0,0 +1,111 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkProvider } from '../index'; + +let capturedProviderProps: Record | null = null; +let beforeRequest: + | ((request: { credentials?: RequestCredentials; headers?: Headers; url?: URL }) => Promise) + | null = null; +let afterResponse: ((request: unknown, response: Response) => Promise) | null = null; + +const clerkConstructor = vi.hoisted(() => vi.fn()); + +vi.mock('@clerk/clerk-js', () => ({ + Clerk: class MockClerk { + constructor(publishableKey: string) { + clerkConstructor(publishableKey); + } + + __internal_onBeforeRequest(cb: typeof beforeRequest) { + beforeRequest = cb; + } + + __internal_onAfterResponse(cb: typeof afterResponse) { + afterResponse = cb; + } + }, +})); + +vi.mock('@clerk/react/internal', () => ({ + InternalClerkProvider: (props: Record) => { + capturedProviderProps = props; + return
{props.children as React.ReactNode}
; + }, +})); + +vi.mock('@clerk/ui', () => ({ + ui: { ClerkUI: 'mock-ui' }, +})); + +describe('Electron ClerkProvider', () => { + const tokenCache = { + clearToken: vi.fn(), + getToken: vi.fn(), + saveToken: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + capturedProviderProps = null; + beforeRequest = null; + afterResponse = null; + vi.stubGlobal('window', { + __clerk_internal_electron: { + tokenCache, + }, + }); + }); + + it('renders React ClerkProvider with Electron defaults', () => { + renderToStaticMarkup( + + App + , + ); + + expect(clerkConstructor).toHaveBeenCalledWith('pk_test_provider'); + expect(capturedProviderProps).toMatchObject({ + publishableKey: 'pk_test_provider', + signInUrl: '/sign-in', + standardBrowser: false, + ui: { ClerkUI: 'mock-ui' }, + }); + expect(capturedProviderProps?.Clerk).toBeDefined(); + expect(capturedProviderProps?.__internal_nativeOAuthHandler).toBeUndefined(); + }); + + it('adds native request params and Authorization from the Electron token cache', async () => { + tokenCache.getToken.mockResolvedValue('client-jwt'); + renderToStaticMarkup(App); + + const request = { + headers: new Headers(), + url: new URL('https://api.clerk.test/v1/client'), + }; + await beforeRequest?.(request); + + expect(request.credentials).toBe('omit'); + expect(request.url.searchParams.get('_is_native')).toBe('1'); + expect(request.headers.get('Authorization')).toBe('Bearer client-jwt'); + expect(tokenCache.getToken).toHaveBeenCalledWith('__clerk_client_jwt'); + }); + + it('stores Authorization response headers in the Electron token cache', async () => { + renderToStaticMarkup(App); + + await afterResponse?.( + {}, + new Response(null, { + headers: { + Authorization: 'Bearer updated-client-jwt', + }, + }), + ); + + expect(tokenCache.saveToken).toHaveBeenCalledWith('__clerk_client_jwt', 'updated-client-jwt'); + }); +}); diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts new file mode 100644 index 00000000000..a56a9f6d850 --- /dev/null +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -0,0 +1,40 @@ +import { Clerk } from '@clerk/clerk-js'; + +const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; + +type ClerkInstance = InstanceType; + +let cached: { instance: ClerkInstance; publishableKey: string } | null = null; + +export function createClerkInstance(publishableKey: string): ClerkInstance { + if (cached?.publishableKey === publishableKey) { + return cached.instance; + } + + const clerk = new Clerk(publishableKey); + + clerk.__internal_onBeforeRequest(async request => { + request.credentials = 'omit'; + request.url?.searchParams.append('_is_native', '1'); + + const token = await window.__clerk_internal_electron?.tokenCache.getToken(CLERK_CLIENT_JWT_KEY); + if (token) { + const headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${token}`); + request.headers = headers; + } + }); + + clerk.__internal_onAfterResponse(async (_request, response) => { + const authorization = (response as Response).headers.get('Authorization'); + if (!authorization) { + return; + } + + const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization; + await window.__clerk_internal_electron?.tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); + }); + + cached = { instance: clerk, publishableKey }; + return clerk; +} diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx new file mode 100644 index 00000000000..ddfa6de5607 --- /dev/null +++ b/packages/electron/src/react/index.tsx @@ -0,0 +1,35 @@ +import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; +import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal'; +import { ui } from '@clerk/ui'; +import type { ReactNode } from 'react'; + +import { createClerkInstance } from './create-clerk-instance'; + +export type ClerkProviderProps = Omit< + ReactClerkProviderProps, + 'Clerk' | 'children' | 'publishableKey' | 'standardBrowser' | 'ui' +> & { + children: ReactNode; + /** + * Your Clerk publishable key, available in the Clerk Dashboard. + */ + publishableKey: string; +}; + +export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element { + const clerk = createClerkInstance(publishableKey); + + return ( + + {children} + + ); +} + +export * from '@clerk/react'; diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json index 274fd384521..3969b0a2146 100644 --- a/packages/electron/tsconfig.json +++ b/packages/electron/tsconfig.json @@ -19,5 +19,6 @@ "declarationMap": true, "emitDeclarationOnly": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/__tests__/**"] } diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts index 5a598814cb3..a8beef878b5 100644 --- a/packages/electron/tsup.config.ts +++ b/packages/electron/tsup.config.ts @@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'], + entry: ['./src/index.ts', './src/preload/index.ts', './src/react/index.tsx', './src/storage/index.ts'], bundle: true, clean: true, minify: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d1a9aa86d0..74f44f28c86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,9 +392,6 @@ importers: '@clerk/react': specifier: workspace:^ version: link:../react - '@clerk/shared': - specifier: workspace:^ - version: link:../shared '@clerk/ui': specifier: workspace:^ version: link:../ui @@ -517,6 +514,24 @@ importers: packages/electron: dependencies: + '@clerk/clerk-js': + specifier: workspace:^ + version: link:../clerk-js + '@clerk/react': + specifier: workspace:^ + version: link:../react + '@clerk/shared': + specifier: workspace:^ + version: link:../shared + '@clerk/ui': + specifier: workspace:^ + version: link:../ui + react: + specifier: catalog:peer-react + version: 18.3.1 + react-dom: + specifier: catalog:peer-react + version: 18.3.1(react@18.3.1) tslib: specifier: catalog:repo version: 2.8.1 From 6c75c4e1670e4c846c1d40ec8f1bcf47b965dd66 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 23:02:44 +0400 Subject: [PATCH 10/19] feat(electron): add passkey support --- packages/electron-passkeys/.gitignore | 6 + packages/electron-passkeys/Cargo.lock | 480 ++++++++ packages/electron-passkeys/Cargo.toml | 70 ++ packages/electron-passkeys/README.md | 39 + packages/electron-passkeys/build.rs | 3 + packages/electron-passkeys/index.d.ts | 20 + packages/electron-passkeys/index.js | 61 + .../npm/darwin-arm64/package.json | 28 + .../npm/darwin-x64/package.json | 28 + .../npm/win32-arm64-msvc/package.json | 28 + .../npm/win32-x64-msvc/package.json | 28 + packages/electron-passkeys/package.json | 60 + packages/electron-passkeys/rustfmt.toml | 2 + packages/electron-passkeys/src/lib.rs | 258 ++++ packages/electron-passkeys/src/macos.rs | 615 ++++++++++ packages/electron-passkeys/src/windows.rs | 468 ++++++++ .../electron-passkeys/test/loader.test.cjs | 35 + packages/electron/package.json | 16 + packages/electron/src/global.d.ts | 3 +- packages/electron/src/index.ts | 1 + .../main/__tests__/passkey-handlers.test.ts | 142 +++ .../electron/src/main/passkey-handlers.ts | 138 +++ .../src/passkeys/__tests__/errors.test.ts | 32 + .../src/passkeys/__tests__/index.test.ts | 283 +++++ .../src/passkeys/__tests__/preload.test.ts | 88 ++ .../passkeys/__tests__/serialization.test.ts | 157 +++ .../src/passkeys/__tests__/strategy.test.ts | 96 ++ packages/electron/src/passkeys/index.ts | 171 +++ packages/electron/src/passkeys/preload.ts | 21 + .../src/passkeys/renderer/native-bridge.ts | 57 + .../src/passkeys/renderer/strategy.ts | 40 + .../electron/src/passkeys/shared/errors.ts | 34 + .../src/passkeys/shared/serialization.ts | 101 ++ packages/electron/src/preload/index.ts | 2 + packages/electron/src/shared/ipc.ts | 6 + packages/electron/src/shared/types.ts | 92 ++ packages/electron/tsup.config.ts | 3 +- pnpm-lock.yaml | 1056 ++++++++++++++++- pnpm-workspace.yaml | 1 + 39 files changed, 4766 insertions(+), 3 deletions(-) create mode 100644 packages/electron-passkeys/.gitignore create mode 100644 packages/electron-passkeys/Cargo.lock create mode 100644 packages/electron-passkeys/Cargo.toml create mode 100644 packages/electron-passkeys/README.md create mode 100644 packages/electron-passkeys/build.rs create mode 100644 packages/electron-passkeys/index.d.ts create mode 100644 packages/electron-passkeys/index.js create mode 100644 packages/electron-passkeys/npm/darwin-arm64/package.json create mode 100644 packages/electron-passkeys/npm/darwin-x64/package.json create mode 100644 packages/electron-passkeys/npm/win32-arm64-msvc/package.json create mode 100644 packages/electron-passkeys/npm/win32-x64-msvc/package.json create mode 100644 packages/electron-passkeys/package.json create mode 100644 packages/electron-passkeys/rustfmt.toml create mode 100644 packages/electron-passkeys/src/lib.rs create mode 100644 packages/electron-passkeys/src/macos.rs create mode 100644 packages/electron-passkeys/src/windows.rs create mode 100644 packages/electron-passkeys/test/loader.test.cjs create mode 100644 packages/electron/src/main/__tests__/passkey-handlers.test.ts create mode 100644 packages/electron/src/main/passkey-handlers.ts create mode 100644 packages/electron/src/passkeys/__tests__/errors.test.ts create mode 100644 packages/electron/src/passkeys/__tests__/index.test.ts create mode 100644 packages/electron/src/passkeys/__tests__/preload.test.ts create mode 100644 packages/electron/src/passkeys/__tests__/serialization.test.ts create mode 100644 packages/electron/src/passkeys/__tests__/strategy.test.ts create mode 100644 packages/electron/src/passkeys/index.ts create mode 100644 packages/electron/src/passkeys/preload.ts create mode 100644 packages/electron/src/passkeys/renderer/native-bridge.ts create mode 100644 packages/electron/src/passkeys/renderer/strategy.ts create mode 100644 packages/electron/src/passkeys/shared/errors.ts create mode 100644 packages/electron/src/passkeys/shared/serialization.ts diff --git a/packages/electron-passkeys/.gitignore b/packages/electron-passkeys/.gitignore new file mode 100644 index 00000000000..2b422d37c4e --- /dev/null +++ b/packages/electron-passkeys/.gitignore @@ -0,0 +1,6 @@ +target/ +*.node +artifacts/ +# napi-generated type defs for the raw native binding (the public surface is +# the hand-written index.js/index.d.ts loader) +native.d.ts diff --git a/packages/electron-passkeys/Cargo.lock b/packages/electron-passkeys/Cargo.lock new file mode 100644 index 00000000000..f27fdf796a1 --- /dev/null +++ b/packages/electron-passkeys/Cargo.lock @@ -0,0 +1,480 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "electron_passkeys" +version = "0.0.0" +dependencies = [ + "base64", + "napi", + "napi-build", + "napi-derive", + "objc2", + "objc2-app-kit", + "objc2-authentication-services", + "objc2-foundation", + "serde", + "serde_json", + "tokio", + "windows", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-authentication-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6d6f7dab884a28adaec1012eb3889257a49cc145724e35f93ece2d209f8b25" +dependencies = [ + "bitflags", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/electron-passkeys/Cargo.toml b/packages/electron-passkeys/Cargo.toml new file mode 100644 index 00000000000..4456b1e8046 --- /dev/null +++ b/packages/electron-passkeys/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "electron_passkeys" +version = "0.0.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "2", default-features = false, features = ["napi8", "async"] } +napi-derive = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +tokio = { version = "1", features = ["sync", "rt"] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSArray", + "NSData", + "NSError", + "NSObject", + "NSProcessInfo", + "NSString", +] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSResponder", + "NSView", + "NSWindow", +] } +objc2-authentication-services = { version = "0.3", default-features = false, features = [ + "std", + "ASFoundation", + "ASAuthorization", + "ASAuthorizationController", + "ASAuthorizationCredential", + "ASAuthorizationRequest", + "ASAuthorizationPlatformPublicKeyCredentialProvider", + "ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPlatformPublicKeyCredentialAssertionRequest", + "ASAuthorizationSecurityKeyPublicKeyCredentialProvider", + "ASAuthorizationSecurityKeyPublicKeyCredentialRegistrationRequest", + "ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest", + "ASAuthorizationPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPublicKeyCredentialAssertionRequest", + "ASAuthorizationPublicKeyCredentialConstants", + "ASAuthorizationPublicKeyCredentialDescriptor", + "ASAuthorizationPlatformPublicKeyCredentialDescriptor", + "ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor", + "ASAuthorizationPublicKeyCredentialParameters", +] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Networking_WindowsWebServices", + "Win32_System_LibraryLoader", +] } + +[build-dependencies] +napi-build = "2" + +[profile.release] +lto = true +strip = "symbols" diff --git a/packages/electron-passkeys/README.md b/packages/electron-passkeys/README.md new file mode 100644 index 00000000000..f587add3f0d --- /dev/null +++ b/packages/electron-passkeys/README.md @@ -0,0 +1,39 @@ +# @clerk/electron-passkeys + +Native passkey (WebAuthn) support for [`@clerk/electron`](https://github.com/clerk/javascript/tree/main/packages/electron), used when an Electron window loads a local bundle (`file://` or a custom protocol) and the renderer's built-in WebAuthn cannot satisfy the RP ID's origin checks. + +> [!WARNING] +> This package is under active development and is not yet ready for production use. + +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. + +| Platform | Backend | Authenticators | +| ---------------- | ---------------------------------------------------- | ---------------------------------------- | +| macOS 12+ | AuthenticationServices (`ASAuthorizationController`) | Touch ID, iCloud Keychain, security keys | +| Windows 10 1903+ | `webauthn.dll` (Windows WebAuthn API) | Windows Hello, security keys | +| Linux | — | Not supported (use renderer WebAuthn) | + +Prebuilt binaries ship as per-platform optional dependencies (`@clerk/electron-passkeys-darwin-arm64`, `-darwin-x64`, `-win32-x64-msvc`, `-win32-arm64-msvc`). + +## API + +```ts +isAvailable(): boolean; +capabilities(): { platformAuthenticator: boolean; securityKeys: boolean }; +createCredential(windowHandle: Buffer, optionsJson: string): Promise; +getCredential(windowHandle: Buffer, optionsJson: string): Promise; +``` + +`createCredential`/`getCredential` take the window handle from `BrowserWindow#getNativeWindowHandle()` (to anchor the OS dialog) and JSON-encoded WebAuthn options with base64url binary fields. They always resolve with a JSON envelope — `{ ok: true, credential }` or `{ ok: false, error: { code, message } }` with `code` one of `cancelled | invalid_rp | not_supported | timeout | unknown` — and never reject for ceremony failures. + +## Building from source + +Requires a Rust toolchain. + +```sh +pnpm --filter @clerk/electron-passkeys build +``` + +## License + +MIT — see [LICENSE](https://github.com/clerk/javascript/blob/main/packages/electron/LICENSE). diff --git a/packages/electron-passkeys/build.rs b/packages/electron-passkeys/build.rs new file mode 100644 index 00000000000..0f1b01002b0 --- /dev/null +++ b/packages/electron-passkeys/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/packages/electron-passkeys/index.d.ts b/packages/electron-passkeys/index.d.ts new file mode 100644 index 00000000000..7821ca8d0e8 --- /dev/null +++ b/packages/electron-passkeys/index.d.ts @@ -0,0 +1,20 @@ +/** Whether this platform has a native passkey backend. */ +export function isAvailable(): boolean; + +export function capabilities(): { + platformAuthenticator: boolean; + securityKeys: boolean; +}; + +/** + * Runs a WebAuthn registration ceremony. + * `windowHandle` anchors the OS dialog; `optionsJson` is the serialized public key options. + * Resolves with a JSON result envelope. + */ +export function createCredential(windowHandle: Buffer, optionsJson: string): Promise; + +/** + * Runs a WebAuthn authentication ceremony. + * Resolves with a JSON result envelope. + */ +export function getCredential(windowHandle: Buffer, optionsJson: string): Promise; diff --git a/packages/electron-passkeys/index.js b/packages/electron-passkeys/index.js new file mode 100644 index 00000000000..822950d12df --- /dev/null +++ b/packages/electron-passkeys/index.js @@ -0,0 +1,61 @@ +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@clerk/electron-passkeys-darwin-arm64', + 'darwin-x64': '@clerk/electron-passkeys-darwin-x64', + 'win32-arm64': '@clerk/electron-passkeys-win32-arm64-msvc', + 'win32-x64': '@clerk/electron-passkeys-win32-x64-msvc', +}; + +function loadNative() { + const key = `${process.platform}-${process.arch}`; + + // Local napi builds land next to this file. + const localBinary = join(__dirname, `electron-passkeys.${key}.node`); + if (existsSync(localBinary)) { + return require(localBinary); + } + + const platformPackage = PLATFORM_PACKAGES[key]; + if (platformPackage) { + try { + return require(platformPackage); + } catch { + // Missing or unloadable optional package: report unsupported. + } + } + return null; +} + +const native = loadNative(); + +const notSupportedResult = () => + JSON.stringify({ + ok: false, + error: { code: 'not_supported', message: 'Native passkeys are not supported on this platform.' }, + }); + +module.exports = { + isAvailable() { + return !!native && native.isAvailable(); + }, + capabilities() { + if (!native || !native.isAvailable()) { + return { platformAuthenticator: false, securityKeys: false }; + } + return native.capabilities(); + }, + createCredential(windowHandle, optionsJson) { + if (!native || !native.isAvailable()) { + return Promise.resolve(notSupportedResult()); + } + return native.createCredential(windowHandle, optionsJson); + }, + getCredential(windowHandle, optionsJson) { + if (!native || !native.isAvailable()) { + return Promise.resolve(notSupportedResult()); + } + return native.getCredential(windowHandle, optionsJson); + }, +}; diff --git a/packages/electron-passkeys/npm/darwin-arm64/package.json b/packages/electron-passkeys/npm/darwin-arm64/package.json new file mode 100644 index 00000000000..5668b5293b5 --- /dev/null +++ b/packages/electron-passkeys/npm/darwin-arm64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-darwin-arm64", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (macOS arm64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.darwin-arm64.node", + "files": [ + "electron-passkeys.darwin-arm64.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/darwin-x64/package.json b/packages/electron-passkeys/npm/darwin-x64/package.json new file mode 100644 index 00000000000..4848e7e3de3 --- /dev/null +++ b/packages/electron-passkeys/npm/darwin-x64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-darwin-x64", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (macOS x64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.darwin-x64.node", + "files": [ + "electron-passkeys.darwin-x64.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-arm64-msvc/package.json b/packages/electron-passkeys/npm/win32-arm64-msvc/package.json new file mode 100644 index 00000000000..8ae61b2924f --- /dev/null +++ b/packages/electron-passkeys/npm/win32-arm64-msvc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-win32-arm64-msvc", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (Windows arm64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.win32-arm64-msvc.node", + "files": [ + "electron-passkeys.win32-arm64-msvc.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-x64-msvc/package.json b/packages/electron-passkeys/npm/win32-x64-msvc/package.json new file mode 100644 index 00000000000..83c3eebad0b --- /dev/null +++ b/packages/electron-passkeys/npm/win32-x64-msvc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-win32-x64-msvc", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (Windows x64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.win32-x64-msvc.node", + "files": [ + "electron-passkeys.win32-x64-msvc.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/package.json b/packages/electron-passkeys/package.json new file mode 100644 index 00000000000..cb16ff17313 --- /dev/null +++ b/packages/electron-passkeys/package.json @@ -0,0 +1,60 @@ +{ + "name": "@clerk/electron-passkeys", + "version": "0.0.0", + "description": "Native passkey (WebAuthn) support for Clerk's Electron SDK", + "keywords": [ + "clerk", + "electron", + "passkeys", + "webauthn", + "auth", + "authentication" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts" + ], + "scripts": { + "artifacts": "napi artifacts --output-dir artifacts --npm-dir npm", + "build": "napi build --platform --release --no-js --dts native.d.ts", + "build:debug": "napi build --platform --no-js --dts native.d.ts", + "test": "node --test test/loader.test.cjs" + }, + "devDependencies": { + "@napi-rs/cli": "^3.0.0" + }, + "optionalDependencies": { + "@clerk/electron-passkeys-darwin-arm64": "workspace:*", + "@clerk/electron-passkeys-darwin-x64": "workspace:*", + "@clerk/electron-passkeys-win32-arm64-msvc": "workspace:*", + "@clerk/electron-passkeys-win32-x64-msvc": "workspace:*" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + }, + "napi": { + "binaryName": "electron-passkeys", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + } +} diff --git a/packages/electron-passkeys/rustfmt.toml b/packages/electron-passkeys/rustfmt.toml new file mode 100644 index 00000000000..a54c7795ebc --- /dev/null +++ b/packages/electron-passkeys/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2021" +max_width = 110 diff --git a/packages/electron-passkeys/src/lib.rs b/packages/electron-passkeys/src/lib.rs new file mode 100644 index 00000000000..bcfb8a525db --- /dev/null +++ b/packages/electron-passkeys/src/lib.rs @@ -0,0 +1,258 @@ +//! Native passkey (WebAuthn) support for Electron, exposed through napi-rs. +//! +//! Ceremony failures resolve to a JSON result envelope instead of rejecting, so +//! JS callers can handle user-facing WebAuthn failures without try/catch. +//! Invalid input uses the same error envelope. + +#![deny(clippy::all)] + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use serde::Deserialize; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +// Binary fields are base64url without padding. Some fields are platform-specific, +// but the wire contract stays the same across targets. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RpEntity { + pub id: String, + #[allow(dead_code)] + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserEntity { + /// base64url-encoded user handle. + pub id: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PubKeyCredParam { + #[serde(rename = "type", default)] + pub _type: Option, + pub alg: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AuthenticatorSelection { + #[serde(default)] + pub authenticator_attachment: Option, + #[serde(default)] + pub require_resident_key: Option, + #[serde(default)] + pub resident_key: Option, + #[serde(default)] + pub user_verification: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CredentialDescriptor { + #[serde(rename = "type", default)] + pub _type: Option, + /// base64url-encoded credential id. + pub id: String, + #[allow(dead_code)] + #[serde(default)] + pub transports: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreationOptions { + pub rp: RpEntity, + pub user: UserEntity, + /// base64url-encoded challenge. + pub challenge: String, + #[serde(default)] + pub pub_key_cred_params: Option>, + #[allow(dead_code)] + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub authenticator_selection: Option, + #[serde(default)] + pub attestation: Option, + #[serde(default)] + pub exclude_credentials: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RequestOptions { + /// base64url-encoded challenge. + pub challenge: String, + pub rp_id: String, + #[allow(dead_code)] + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub user_verification: Option, + #[serde(default)] + pub allow_credentials: Option>, +} + +pub(crate) fn ok_envelope(credential: serde_json::Value) -> String { + serde_json::json!({ "ok": true, "credential": credential }).to_string() +} + +pub(crate) fn err_envelope(code: &str, message: &str) -> String { + serde_json::json!({ + "ok": false, + "error": { "code": code, "message": message }, + }) + .to_string() +} + +pub(crate) fn b64url_encode(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +pub(crate) fn b64url_decode(input: &str) -> Result, base64::DecodeError> { + URL_SAFE_NO_PAD.decode(input.trim_end_matches('=')) +} + +/// Reads the native pointer from Electron's `BrowserWindow#getNativeWindowHandle()`. +#[allow(dead_code)] +pub(crate) fn window_handle_from_bytes(bytes: &[u8]) -> Option { + const PTR_LEN: usize = std::mem::size_of::(); + if bytes.len() < PTR_LEN { + return None; + } + let mut raw = [0u8; PTR_LEN]; + raw.copy_from_slice(&bytes[..PTR_LEN]); + Some(usize::from_le_bytes(raw)) +} + +#[napi(object)] +pub struct Capabilities { + pub platform_authenticator: bool, + pub security_keys: bool, +} + +#[napi] +pub fn is_available() -> bool { + #[cfg(target_os = "macos")] + { + macos::is_available() + } + #[cfg(target_os = "windows")] + { + windows::is_available() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + false + } +} + +#[napi] +pub fn capabilities() -> Capabilities { + #[cfg(target_os = "macos")] + { + let (platform_authenticator, security_keys) = macos::capabilities(); + Capabilities { + platform_authenticator, + security_keys, + } + } + #[cfg(target_os = "windows")] + { + let (platform_authenticator, security_keys) = windows::capabilities(); + Capabilities { + platform_authenticator, + security_keys, + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Capabilities { + platform_authenticator: false, + security_keys: false, + } + } +} + +#[napi] +pub async fn create_credential(window_handle: Buffer, options_json: String) -> napi::Result { + // Do not hold JS-owned memory across await points or threads. + let handle_bytes = window_handle.to_vec(); + Ok(create_credential_impl(handle_bytes, options_json).await) +} + +#[napi] +pub async fn get_credential(window_handle: Buffer, options_json: String) -> napi::Result { + let handle_bytes = window_handle.to_vec(); + Ok(get_credential_impl(handle_bytes, options_json).await) +} + +#[allow(unused_variables)] +async fn create_credential_impl(handle_bytes: Vec, options_json: String) -> String { + let handle = match window_handle_from_bytes(&handle_bytes) { + Some(h) => h, + None => return err_envelope("unknown", "Invalid window handle buffer"), + }; + let options: CreationOptions = match serde_json::from_str(&options_json) { + Ok(o) => o, + Err(e) => return err_envelope("unknown", &format!("Failed to parse creation options: {e}")), + }; + + #[cfg(target_os = "macos")] + { + macos::create_credential(handle, options).await + } + #[cfg(target_os = "windows")] + { + windows::create_credential(handle, options).await + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + err_envelope( + "not_supported", + "Native passkeys are not supported on this platform", + ) + } +} + +#[allow(unused_variables)] +async fn get_credential_impl(handle_bytes: Vec, options_json: String) -> String { + let handle = match window_handle_from_bytes(&handle_bytes) { + Some(h) => h, + None => return err_envelope("unknown", "Invalid window handle buffer"), + }; + let options: RequestOptions = match serde_json::from_str(&options_json) { + Ok(o) => o, + Err(e) => return err_envelope("unknown", &format!("Failed to parse request options: {e}")), + }; + + #[cfg(target_os = "macos")] + { + macos::get_credential(handle, options).await + } + #[cfg(target_os = "windows")] + { + windows::get_credential(handle, options).await + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + err_envelope( + "not_supported", + "Native passkeys are not supported on this platform", + ) + } +} diff --git a/packages/electron-passkeys/src/macos.rs b/packages/electron-passkeys/src/macos.rs new file mode 100644 index 00000000000..a552a6797f3 --- /dev/null +++ b/packages/electron-passkeys/src/macos.rs @@ -0,0 +1,615 @@ +//! macOS passkey ceremonies via the AuthenticationServices framework. +//! +//! AuthenticationServices requires `performRequests` on the main thread. The +//! napi async entrypoints dispatch setup to the libdispatch main queue, then +//! await a oneshot resolved by the authorization delegate. + +use std::cell::RefCell; +use std::ffi::c_void; + +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2::{ + class, define_class, msg_send, sel, AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, Message, +}; +use objc2_app_kit::{NSView, NSWindow}; +use objc2_authentication_services::{ + ASAuthorization, ASAuthorizationController, ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding, + ASAuthorizationPlatformPublicKeyCredentialDescriptor, ASAuthorizationPlatformPublicKeyCredentialProvider, + ASAuthorizationPublicKeyCredentialParameters, ASAuthorizationRequest, + ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor, + ASAuthorizationSecurityKeyPublicKeyCredentialProvider, ASPresentationAnchor, +}; +use objc2_foundation::{ + NSArray, NSData, NSError, NSObject, NSObjectProtocol, NSOperatingSystemVersion, NSProcessInfo, NSString, +}; +use tokio::sync::oneshot; + +use crate::{b64url_decode, b64url_encode, err_envelope, ok_envelope, CreationOptions, RequestOptions}; + +/// Outcome of a ceremony: a credential JSON value, or an (error code, message) pair. +type CeremonyResult = Result; + +pub(crate) fn is_available() -> bool { + // ASAuthorizationPlatformPublicKeyCredentialProvider is macOS 12+. + let version = NSOperatingSystemVersion { + majorVersion: 12, + minorVersion: 0, + patchVersion: 0, + }; + NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(version) +} + +pub(crate) fn capabilities() -> (bool, bool) { + let available = is_available(); + // Security keys are supported through the same OS sheet whenever the + // passkey API itself is available. + (available, available) +} + +// Raw libdispatch C ABI; `_dispatch_main_q` is the symbol behind +// `dispatch_get_main_queue()`. + +#[repr(C)] +struct DispatchQueueOpaque { + _private: [u8; 0], +} + +extern "C" { + static _dispatch_main_q: DispatchQueueOpaque; + fn dispatch_async_f( + queue: *const DispatchQueueOpaque, + context: *mut c_void, + work: extern "C" fn(*mut c_void), + ); +} + +fn dispatch_to_main(f: impl FnOnce() + Send + 'static) { + extern "C" fn trampoline(context: *mut c_void) { + // Re-box the closure and run it. Never let a panic unwind across the + // C trampoline frame. + let closure = unsafe { Box::from_raw(context.cast::>()) }; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || (*closure)())); + } + + let boxed: Box = Box::new(f); + let context = Box::into_raw(Box::new(boxed)).cast::(); + unsafe { dispatch_async_f(std::ptr::addr_of!(_dispatch_main_q), context, trampoline) }; +} + +// ASAuthorizationController keeps weak references to its delegate and +// presentation provider, so active ceremonies retain their delegate in a +// main-thread registry until completion. +thread_local! { + static ACTIVE_DELEGATES: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +struct DelegateIvars { + window: Retained, + sender: RefCell>>, + // Strong reference for the weak delegate/presentation-provider relationship. + controller: RefCell>>, +} + +define_class!( + // SAFETY: + // - NSObject has no subclassing requirements. + // - `CeremonyDelegate` does not implement `Drop`. + // MainThreadOnly is required by ASAuthorizationControllerPresentationContextProviding. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "ClerkElectronPasskeysDelegate"] + #[ivars = DelegateIvars] + struct CeremonyDelegate; + + unsafe impl NSObjectProtocol for CeremonyDelegate {} + + unsafe impl ASAuthorizationControllerDelegate for CeremonyDelegate { + #[unsafe(method(authorizationController:didCompleteWithAuthorization:))] + fn did_complete_with_authorization( + &self, + _controller: &ASAuthorizationController, + authorization: &ASAuthorization, + ) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + let credential = authorization.credential(); + // SAFETY: ProtocolObject wraps a plain Objective-C object, so + // reinterpreting the pointer as AnyObject is valid. We only + // use it for `isKindOfClass:` checks and dynamic getters. + let obj: &AnyObject = &*(Retained::as_ptr(&credential) as *const AnyObject); + credential_to_json(obj) + })) + .unwrap_or_else(|_| { + Err(( + "unknown".to_string(), + "Panic while reading credential".to_string(), + )) + }); + self.complete(result); + } + + #[unsafe(method(authorizationController:didCompleteWithError:))] + fn did_complete_with_error(&self, _controller: &ASAuthorizationController, error: &NSError) { + let (code, message) = map_nserror(error); + self.complete(Err((code, message))); + } + } + + unsafe impl ASAuthorizationControllerPresentationContextProviding for CeremonyDelegate { + #[unsafe(method_id(presentationAnchorForAuthorizationController:))] + fn presentation_anchor( + &self, + _controller: &ASAuthorizationController, + ) -> Retained { + // ASPresentationAnchor is NSWindow on macOS, but the generated + // bindings alias it to NSObject; upcast the window accordingly. + let window = self.ivars().window.clone(); + unsafe { Retained::cast_unchecked::(window) } + } + } +); + +impl CeremonyDelegate { + fn new(mtm: MainThreadMarker, ivars: DelegateIvars) -> Retained { + let this = Self::alloc(mtm).set_ivars(ivars); + unsafe { msg_send![super(this), init] } + } + + fn complete(&self, result: CeremonyResult) { + if let Some(sender) = self.ivars().sender.borrow_mut().take() { + let _ = sender.send(result); + } + // Release the controller and delegate now that callbacks are done. + self.ivars().controller.borrow_mut().take(); + let this = self as *const Self; + ACTIVE_DELEGATES.with(|delegates| { + delegates.borrow_mut().retain(|d| Retained::as_ptr(d) != this); + }); + } +} + +fn map_nserror(error: &NSError) -> (String, String) { + let domain = error.domain().to_string(); + let code = error.code(); + let message = error.localizedDescription().to_string(); + let lowered = message.to_lowercase(); + + let mapped = if lowered.contains("timed out") || lowered.contains("timeout") { + "timeout" + } else if domain == "ASAuthorizationError" { + match code { + // ASAuthorizationError.canceled + 1001 => "cancelled", + // ASAuthorizationError.failed also covers RP ID / associated-domain + // mismatches, so use the localized description for classification. + 1004 if lowered.contains("not associated") || lowered.contains("associated domain") => { + "invalid_rp" + } + _ => "unknown", + } + } else { + "unknown" + }; + (mapped.to_string(), message) +} + +type BuildError = (String, String); + +fn decode_b64(field: &str, value: &str) -> Result, BuildError> { + b64url_decode(value).map_err(|e| { + ( + "unknown".to_string(), + format!("Invalid base64url in `{field}`: {e}"), + ) + }) +} + +fn platform_descriptors( + creds: &[crate::CredentialDescriptor], +) -> Result>, BuildError> { + let mut out = Vec::with_capacity(creds.len()); + for cred in creds { + let id = decode_b64("credential id", &cred.id)?; + let data = NSData::with_bytes(&id); + let descriptor: Retained = unsafe { + msg_send![ + ASAuthorizationPlatformPublicKeyCredentialDescriptor::alloc(), + initWithCredentialID: &*data + ] + }; + out.push(descriptor); + } + Ok(NSArray::from_retained_slice(&out)) +} + +fn security_key_descriptors( + creds: &[crate::CredentialDescriptor], +) -> Result>, BuildError> { + let mut out = Vec::with_capacity(creds.len()); + for cred in creds { + let id = decode_b64("credential id", &cred.id)?; + let data = NSData::with_bytes(&id); + // An empty transports array means "all transports". + let transports: Retained> = NSArray::new(); + let descriptor: Retained = unsafe { + msg_send![ + ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor::alloc(), + initWithCredentialID: &*data, + transports: &*transports + ] + }; + out.push(descriptor); + } + Ok(NSArray::from_retained_slice(&out)) +} + +/// Sets a typed-NSString preference (e.g. user verification, resident key, +/// attestation) on a request. The WebAuthn JSON values ("required", +/// "preferred", "discouraged", "none", "direct", ...) are exactly the raw +/// values of the corresponding `ASAuthorizationPublicKeyCredential*` typed +/// string constants, so we can pass them straight through. +fn set_string_pref(target: &T, setter: objc2::runtime::Sel, value: &str) { + let string = NSString::from_str(value); + let responds: bool = unsafe { msg_send![target, respondsToSelector: setter] }; + if responds { + // Dynamic dispatch keeps us compatible with older macOS versions + // where some setters do not exist. + let _: () = unsafe { objc2::runtime::MessageReceiver::send_message(target, setter, (&*string,)) }; + } +} + +fn build_create_requests( + options: &CreationOptions, +) -> Result>, BuildError> { + let challenge = decode_b64("challenge", &options.challenge)?; + let user_id = decode_b64("user.id", &options.user.id)?; + let rp_id = NSString::from_str(&options.rp.id); + let challenge_data = NSData::with_bytes(&challenge); + let user_id_data = NSData::with_bytes(&user_id); + let name = NSString::from_str( + options + .user + .name + .as_deref() + .or(options.user.display_name.as_deref()) + .unwrap_or(""), + ); + let display_name = NSString::from_str( + options + .user + .display_name + .as_deref() + .or(options.user.name.as_deref()) + .unwrap_or(""), + ); + + let selection = options.authenticator_selection.as_ref(); + let attachment = selection.and_then(|s| s.authenticator_attachment.as_deref()); + let user_verification = selection.and_then(|s| s.user_verification.as_deref()); + let resident_key = selection.and_then(|s| { + s.resident_key.as_deref().map(str::to_string).or_else(|| { + s.require_resident_key + .map(|required| if required { "required" } else { "discouraged" }.to_string()) + }) + }); + + let mut requests: Vec> = Vec::new(); + + if attachment != Some("cross-platform") { + let provider = unsafe { + ASAuthorizationPlatformPublicKeyCredentialProvider::initWithRelyingPartyIdentifier( + ASAuthorizationPlatformPublicKeyCredentialProvider::alloc(), + &rp_id, + ) + }; + let request = unsafe { + provider.createCredentialRegistrationRequestWithChallenge_name_userID( + &challenge_data, + &name, + &user_id_data, + ) + }; + if let Some(uv) = user_verification { + set_string_pref(&*request, sel!(setUserVerificationPreference:), uv); + } + // iCloud Keychain passkeys fail when attestation is requested. Browsers + // downgrade these registrations to fmt "none", so do not forward the RP + // attestation preference for platform passkeys. + if let Some(exclude) = options.exclude_credentials.as_deref() { + if !exclude.is_empty() { + // `excludedCredentials` only exists on macOS 14+. + let responds: bool = + unsafe { msg_send![&*request, respondsToSelector: sel!(setExcludedCredentials:)] }; + if responds { + let descriptors = platform_descriptors(exclude)?; + let _: () = unsafe { msg_send![&*request, setExcludedCredentials: &*descriptors] }; + } + } + } + requests.push(unsafe { Retained::cast_unchecked::(request) }); + } + + if attachment != Some("platform") { + let provider = unsafe { + ASAuthorizationSecurityKeyPublicKeyCredentialProvider::initWithRelyingPartyIdentifier( + ASAuthorizationSecurityKeyPublicKeyCredentialProvider::alloc(), + &rp_id, + ) + }; + let request = unsafe { + provider.createCredentialRegistrationRequestWithChallenge_displayName_name_userID( + &challenge_data, + &display_name, + &name, + &user_id_data, + ) + }; + + // Security key registrations require explicit COSE algorithms. + let algorithms: Vec = options + .pub_key_cred_params + .as_deref() + .map(|params| params.iter().map(|p| p.alg).collect()) + .filter(|algs: &Vec| !algs.is_empty()) + .unwrap_or_else(|| vec![-7 /* ES256 */]); + let mut parameters = Vec::with_capacity(algorithms.len()); + for alg in algorithms { + let parameter: Retained = unsafe { + msg_send![ + ASAuthorizationPublicKeyCredentialParameters::alloc(), + initWithAlgorithm: alg as isize + ] + }; + parameters.push(parameter); + } + let parameters = NSArray::from_retained_slice(¶meters); + let _: () = unsafe { msg_send![&*request, setCredentialParameters: &*parameters] }; + + if let Some(uv) = user_verification { + set_string_pref(&*request, sel!(setUserVerificationPreference:), uv); + } + if let Some(rk) = resident_key.as_deref() { + set_string_pref(&*request, sel!(setResidentKeyPreference:), rk); + } + if let Some(att) = options.attestation.as_deref() { + set_string_pref(&*request, sel!(setAttestationPreference:), att); + } + if let Some(exclude) = options.exclude_credentials.as_deref() { + if !exclude.is_empty() { + let descriptors = security_key_descriptors(exclude)?; + let _: () = unsafe { msg_send![&*request, setExcludedCredentials: &*descriptors] }; + } + } + requests.push(unsafe { Retained::cast_unchecked::(request) }); + } + + if requests.is_empty() { + return Err(( + "not_supported".to_string(), + "No usable authenticator type requested".to_string(), + )); + } + Ok(requests) +} + +fn build_get_requests(options: &RequestOptions) -> Result>, BuildError> { + let challenge = decode_b64("challenge", &options.challenge)?; + let rp_id = NSString::from_str(&options.rp_id); + let challenge_data = NSData::with_bytes(&challenge); + let allow = options.allow_credentials.as_deref().unwrap_or(&[]); + + let mut requests: Vec> = Vec::new(); + + // Platform (passkey) assertion. + { + let provider = unsafe { + ASAuthorizationPlatformPublicKeyCredentialProvider::initWithRelyingPartyIdentifier( + ASAuthorizationPlatformPublicKeyCredentialProvider::alloc(), + &rp_id, + ) + }; + let request = unsafe { provider.createCredentialAssertionRequestWithChallenge(&challenge_data) }; + if let Some(uv) = options.user_verification.as_deref() { + set_string_pref(&*request, sel!(setUserVerificationPreference:), uv); + } + if !allow.is_empty() { + let descriptors = platform_descriptors(allow)?; + let _: () = unsafe { msg_send![&*request, setAllowedCredentials: &*descriptors] }; + } + requests.push(unsafe { Retained::cast_unchecked::(request) }); + } + + // Security key assertion, offered in the same OS sheet. + { + let provider = unsafe { + ASAuthorizationSecurityKeyPublicKeyCredentialProvider::initWithRelyingPartyIdentifier( + ASAuthorizationSecurityKeyPublicKeyCredentialProvider::alloc(), + &rp_id, + ) + }; + let request = unsafe { provider.createCredentialAssertionRequestWithChallenge(&challenge_data) }; + if let Some(uv) = options.user_verification.as_deref() { + set_string_pref(&*request, sel!(setUserVerificationPreference:), uv); + } + if !allow.is_empty() { + let descriptors = security_key_descriptors(allow)?; + let _: () = unsafe { msg_send![&*request, setAllowedCredentials: &*descriptors] }; + } + requests.push(unsafe { Retained::cast_unchecked::(request) }); + } + + Ok(requests) +} + +// Dynamic getters let platform and security-key credential classes share this path. + +unsafe fn credential_to_json(obj: &AnyObject) -> CeremonyResult { + let is_platform_reg: bool = + msg_send![obj, isKindOfClass: class!(ASAuthorizationPlatformPublicKeyCredentialRegistration)]; + let is_security_reg: bool = msg_send![ + obj, + isKindOfClass: class!(ASAuthorizationSecurityKeyPublicKeyCredentialRegistration) + ]; + let is_platform_assertion: bool = + msg_send![obj, isKindOfClass: class!(ASAuthorizationPlatformPublicKeyCredentialAssertion)]; + let is_security_assertion: bool = msg_send![ + obj, + isKindOfClass: class!(ASAuthorizationSecurityKeyPublicKeyCredentialAssertion) + ]; + + if is_platform_reg || is_security_reg { + registration_to_json(obj, is_platform_reg) + } else if is_platform_assertion || is_security_assertion { + assertion_to_json(obj, is_platform_assertion) + } else { + Err(( + "unknown".to_string(), + "Unexpected ASAuthorization credential type".to_string(), + )) + } +} + +unsafe fn registration_to_json(obj: &AnyObject, platform: bool) -> CeremonyResult { + let credential_id: Retained = msg_send![obj, credentialID]; + let client_data: Retained = msg_send![obj, rawClientDataJSON]; + let attestation: Option> = msg_send![obj, rawAttestationObject]; + let attestation = attestation.ok_or_else(|| { + ( + "unknown".to_string(), + "Authenticator returned no attestation object".to_string(), + ) + })?; + + let id = b64url_encode(&credential_id.to_vec()); + let transports: Vec<&str> = if platform { + vec!["internal", "hybrid"] + } else { + vec!["usb", "nfc", "ble"] + }; + + Ok(serde_json::json!({ + "id": id, + "rawId": id, + "type": "public-key", + "authenticatorAttachment": if platform { "platform" } else { "cross-platform" }, + "response": { + "clientDataJSON": b64url_encode(&client_data.to_vec()), + "attestationObject": b64url_encode(&attestation.to_vec()), + "transports": transports, + }, + })) +} + +unsafe fn assertion_to_json(obj: &AnyObject, platform: bool) -> CeremonyResult { + let credential_id: Retained = msg_send![obj, credentialID]; + let client_data: Retained = msg_send![obj, rawClientDataJSON]; + let authenticator_data: Retained = msg_send![obj, rawAuthenticatorData]; + let signature: Retained = msg_send![obj, signature]; + // The user handle may be absent (non-resident security key credentials). + let user_id: Option> = msg_send![obj, userID]; + + let id = b64url_encode(&credential_id.to_vec()); + let mut response = serde_json::json!({ + "clientDataJSON": b64url_encode(&client_data.to_vec()), + "authenticatorData": b64url_encode(&authenticator_data.to_vec()), + "signature": b64url_encode(&signature.to_vec()), + }); + if let Some(user_id) = user_id { + let bytes = user_id.to_vec(); + if !bytes.is_empty() { + response["userHandle"] = serde_json::Value::String(b64url_encode(&bytes)); + } + } + + Ok(serde_json::json!({ + "id": id, + "rawId": id, + "type": "public-key", + "authenticatorAttachment": if platform { "platform" } else { "cross-platform" }, + "response": response, + })) +} + +async fn run_ceremony(handle: usize, build: F) -> String +where + F: FnOnce() -> Result>, BuildError> + Send + 'static, +{ + let (sender, receiver) = oneshot::channel::(); + + dispatch_to_main(move || { + let mut sender = Some(sender); + let setup = (|| -> Result<(), BuildError> { + let mtm = MainThreadMarker::new() + .ok_or_else(|| ("unknown".to_string(), "Not on the main thread".to_string()))?; + + // The buffer from BrowserWindow#getNativeWindowHandle() holds an + // NSView*; the OS sheet is anchored to the view's window. + let view = handle as *mut NSView; + if view.is_null() { + return Err(("unknown".to_string(), "Window handle is null".to_string())); + } + // SAFETY: Electron guarantees the handle is a live NSView* for + // the BrowserWindow, and we are on the main thread. + let view: &NSView = unsafe { &*view }; + let window = view.window().ok_or_else(|| { + ( + "unknown".to_string(), + "NSView is not attached to a window".to_string(), + ) + })?; + + let requests = build()?; + let request_array = NSArray::from_retained_slice(&requests); + let controller = unsafe { + ASAuthorizationController::initWithAuthorizationRequests( + ASAuthorizationController::alloc(), + &request_array, + ) + }; + + let delegate = CeremonyDelegate::new( + mtm, + DelegateIvars { + window, + sender: RefCell::new(sender.take()), + controller: RefCell::new(Some(controller.clone())), + }, + ); + + unsafe { + controller.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + controller.setPresentationContextProvider(Some(ProtocolObject::from_ref(&*delegate))); + controller.performRequests(); + } + + // Keep the delegate alive until a completion callback fires (the + // controller only references it weakly). + ACTIVE_DELEGATES.with(|delegates| delegates.borrow_mut().push(delegate)); + Ok(()) + })(); + + if let Err((code, message)) = setup { + if let Some(sender) = sender.take() { + let _ = sender.send(Err((code, message))); + } + } + }); + + match receiver.await { + Ok(Ok(credential)) => ok_envelope(credential), + Ok(Err((code, message))) => err_envelope(&code, &message), + Err(_) => err_envelope("unknown", "Passkey ceremony was dropped without completing"), + } +} + +// Note: AuthenticationServices has no per-request timeout on macOS, so the +// `timeout` option is intentionally ignored here; the OS sheet stays open +// until the user completes or cancels it. +pub(crate) async fn create_credential(handle: usize, options: CreationOptions) -> String { + run_ceremony(handle, move || build_create_requests(&options)).await +} + +pub(crate) async fn get_credential(handle: usize, options: RequestOptions) -> String { + run_ceremony(handle, move || build_get_requests(&options)).await +} diff --git a/packages/electron-passkeys/src/windows.rs b/packages/electron-passkeys/src/windows.rs new file mode 100644 index 00000000000..0a964297fdd --- /dev/null +++ b/packages/electron-passkeys/src/windows.rs @@ -0,0 +1,468 @@ +//! Windows passkey ceremonies via the WebAuthn API (webauthn.dll). +//! +//! WebAuthN* calls block while the system dialog is open, so ceremonies run in +//! `spawn_blocking`. The HWND is only used as the dialog owner and can be +//! passed from that worker thread. + +use std::ffi::c_void; + +use windows::core::{w, BOOL, PCWSTR}; +use windows::Win32::Foundation::HWND; +use windows::Win32::Networking::WindowsWebServices::{ + WebAuthNAuthenticatorGetAssertion, WebAuthNAuthenticatorMakeCredential, WebAuthNFreeAssertion, + WebAuthNFreeCredentialAttestation, WebAuthNGetApiVersionNumber, WebAuthNGetErrorName, + WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable, WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS, + WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS, WEBAUTHN_CLIENT_DATA, WEBAUTHN_COSE_CREDENTIAL_PARAMETER, + WEBAUTHN_COSE_CREDENTIAL_PARAMETERS, WEBAUTHN_CREDENTIAL_EX, WEBAUTHN_CREDENTIAL_LIST, + WEBAUTHN_RP_ENTITY_INFORMATION, WEBAUTHN_USER_ENTITY_INFORMATION, +}; +use windows::Win32::System::LibraryLoader::LoadLibraryW; + +use crate::{b64url_decode, b64url_encode, err_envelope, ok_envelope, CreationOptions, RequestOptions}; + +// WebAuthn API constants from , defined here to avoid binding renames. +const CLIENT_DATA_VERSION_1: u32 = 1; +const RP_ENTITY_VERSION_1: u32 = 1; +const USER_ENTITY_VERSION_1: u32 = 1; +const COSE_PARAMETER_VERSION_1: u32 = 1; +const CREDENTIAL_EX_VERSION_1: u32 = 1; +/// Matches WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS v3; newer fields stay zeroed. +const MAKE_CREDENTIAL_OPTIONS_VERSION_3: u32 = 3; +/// Matches WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS v4; newer fields stay zeroed. +const GET_ASSERTION_OPTIONS_VERSION_4: u32 = 4; + +const ATTACHMENT_ANY: u32 = 0; +const ATTACHMENT_PLATFORM: u32 = 1; +const ATTACHMENT_CROSS_PLATFORM: u32 = 2; + +const UV_ANY: u32 = 0; +const UV_REQUIRED: u32 = 1; +const UV_PREFERRED: u32 = 2; +const UV_DISCOURAGED: u32 = 3; + +const ATTESTATION_ANY: u32 = 0; +const ATTESTATION_NONE: u32 = 1; +const ATTESTATION_INDIRECT: u32 = 2; +const ATTESTATION_DIRECT: u32 = 3; + +// dwUsedTransport bit flags. +const TRANSPORT_USB: u32 = 0x1; +const TRANSPORT_NFC: u32 = 0x2; +const TRANSPORT_BLE: u32 = 0x4; +const TRANSPORT_INTERNAL: u32 = 0x10; +const TRANSPORT_HYBRID: u32 = 0x20; + +// HRESULTs of interest. +const E_CANCELLED: u32 = 0x800704C7; // ERROR_CANCELLED +const NTE_USER_CANCELLED: u32 = 0x80090036; +const E_TIMEOUT: u32 = 0x800705B4; // ERROR_TIMEOUT + +pub(crate) fn is_available() -> bool { + // webauthn.dll exists on Windows 10 1903+. If it is missing the import + // can't be satisfied at all, so probe with LoadLibrary first. + if unsafe { LoadLibraryW(w!("webauthn.dll")) }.is_err() { + return false; + } + (unsafe { WebAuthNGetApiVersionNumber() }) >= 1 +} + +pub(crate) fn capabilities() -> (bool, bool) { + if !is_available() { + return (false, false); + } + let platform = unsafe { WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable() } + .map(|b| b.as_bool()) + .unwrap_or(false); + (platform, true) +} + +/// NUL-terminated UTF-16 buffer; must stay alive while its PCWSTR is in use. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn pcwstr(buf: &[u16]) -> PCWSTR { + PCWSTR(buf.as_ptr()) +} + +fn bytes_from_raw(ptr: *mut u8, len: u32) -> Vec { + if ptr.is_null() || len == 0 { + return Vec::new(); + } + unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec() +} + +/// The caller constructs clientDataJSON on Windows; the OS hashes it with the +/// algorithm named in WEBAUTHN_CLIENT_DATA. +fn build_client_data_json(ceremony_type: &str, challenge_b64url: &str, rp_id: &str) -> Vec { + serde_json::json!({ + "type": ceremony_type, + "challenge": challenge_b64url, + "origin": format!("https://{rp_id}"), + "crossOrigin": false, + }) + .to_string() + .into_bytes() +} + +fn map_user_verification(value: Option<&str>) -> u32 { + match value { + Some("required") => UV_REQUIRED, + Some("preferred") => UV_PREFERRED, + Some("discouraged") => UV_DISCOURAGED, + _ => UV_ANY, + } +} + +fn transports_from_mask(mask: u32) -> Vec<&'static str> { + let mut out = Vec::new(); + if mask & TRANSPORT_USB != 0 { + out.push("usb"); + } + if mask & TRANSPORT_NFC != 0 { + out.push("nfc"); + } + if mask & TRANSPORT_BLE != 0 { + out.push("ble"); + } + if mask & TRANSPORT_INTERNAL != 0 { + out.push("internal"); + } + if mask & TRANSPORT_HYBRID != 0 { + out.push("hybrid"); + } + out +} + +fn attachment_from_mask(mask: u32) -> &'static str { + if mask & TRANSPORT_INTERNAL != 0 { + "platform" + } else { + "cross-platform" + } +} + +fn map_error(error: &windows::core::Error) -> (String, String) { + let hr = error.code(); + let hr_u32 = hr.0 as u32; + let message = error.message(); + + if hr_u32 == E_CANCELLED || hr_u32 == NTE_USER_CANCELLED { + return ("cancelled".to_string(), message); + } + if hr_u32 == E_TIMEOUT { + return ("timeout".to_string(), message); + } + + // WebAuthNGetErrorName maps HRESULTs to WebAuthn DOMException names. + let name = unsafe { WebAuthNGetErrorName(hr) }; + let name = if name.is_null() { + String::new() + } else { + unsafe { name.to_string() }.unwrap_or_default() + }; + let code = match name.as_str() { + "NotAllowedError" => "cancelled", + "SecurityError" => "invalid_rp", + "NotSupportedError" | "ConstraintError" => "not_supported", + _ => "unknown", + }; + (code.to_string(), message) +} + +/// Owns the buffers behind a WEBAUTHN_CREDENTIAL_LIST so the pointers stay +/// valid for the duration of the FFI call. +struct CredentialList { + _ids: Vec>, + _credentials: Vec, + pointers: Vec<*mut WEBAUTHN_CREDENTIAL_EX>, + list: WEBAUTHN_CREDENTIAL_LIST, +} + +fn build_credential_list( + creds: &[crate::CredentialDescriptor], +) -> Result>, (String, String)> { + if creds.is_empty() { + return Ok(None); + } + let mut ids: Vec> = Vec::with_capacity(creds.len()); + for cred in creds { + let id = b64url_decode(&cred.id).map_err(|e| { + ( + "unknown".to_string(), + format!("Invalid base64url credential id: {e}"), + ) + })?; + ids.push(id); + } + let mut credentials: Vec = ids + .iter() + .map(|id| WEBAUTHN_CREDENTIAL_EX { + dwVersion: CREDENTIAL_EX_VERSION_1, + cbId: id.len() as u32, + pbId: id.as_ptr() as *mut u8, + pwszCredentialType: w!("public-key"), + // 0 == no transport restriction. + dwTransports: 0, + }) + .collect(); + let pointers: Vec<*mut WEBAUTHN_CREDENTIAL_EX> = credentials + .iter_mut() + .map(|c| c as *mut WEBAUTHN_CREDENTIAL_EX) + .collect(); + + let mut boxed = Box::new(CredentialList { + _ids: ids, + _credentials: credentials, + pointers, + list: WEBAUTHN_CREDENTIAL_LIST { + cCredentials: 0, + ppCredentials: std::ptr::null_mut(), + }, + }); + boxed.list = WEBAUTHN_CREDENTIAL_LIST { + cCredentials: boxed.pointers.len() as u32, + ppCredentials: boxed.pointers.as_mut_ptr(), + }; + Ok(Some(boxed)) +} + +pub(crate) async fn create_credential(handle: usize, options: CreationOptions) -> String { + tokio::task::spawn_blocking(move || make_credential_blocking(handle, &options)) + .await + .unwrap_or_else(|e| err_envelope("unknown", &format!("Passkey task failed: {e}"))) +} + +pub(crate) async fn get_credential(handle: usize, options: RequestOptions) -> String { + tokio::task::spawn_blocking(move || get_assertion_blocking(handle, &options)) + .await + .unwrap_or_else(|e| err_envelope("unknown", &format!("Passkey task failed: {e}"))) +} + +fn make_credential_blocking(handle: usize, options: &CreationOptions) -> String { + let hwnd = HWND(handle as *mut c_void); + + let challenge = match b64url_decode(&options.challenge) { + Ok(c) => c, + Err(e) => return err_envelope("unknown", &format!("Invalid base64url challenge: {e}")), + }; + let user_id = match b64url_decode(&options.user.id) { + Ok(u) => u, + Err(e) => return err_envelope("unknown", &format!("Invalid base64url user id: {e}")), + }; + + // Keep every wide-string buffer alive until after the FFI call. + let rp_id_w = wide(&options.rp.id); + let rp_name_w = wide(options.rp.name.as_deref().unwrap_or(&options.rp.id)); + let user_name_w = wide( + options + .user + .name + .as_deref() + .or(options.user.display_name.as_deref()) + .unwrap_or(""), + ); + let display_name_w = wide( + options + .user + .display_name + .as_deref() + .or(options.user.name.as_deref()) + .unwrap_or(""), + ); + + let rp = WEBAUTHN_RP_ENTITY_INFORMATION { + dwVersion: RP_ENTITY_VERSION_1, + pwszId: pcwstr(&rp_id_w), + pwszName: pcwstr(&rp_name_w), + pwszIcon: PCWSTR::null(), + }; + let user = WEBAUTHN_USER_ENTITY_INFORMATION { + dwVersion: USER_ENTITY_VERSION_1, + cbId: user_id.len() as u32, + pbId: user_id.as_ptr() as *mut u8, + pwszName: pcwstr(&user_name_w), + pwszIcon: PCWSTR::null(), + pwszDisplayName: pcwstr(&display_name_w), + }; + + let algorithms: Vec = options + .pub_key_cred_params + .as_deref() + .map(|params| params.iter().map(|p| p.alg).collect::>()) + .filter(|algs| !algs.is_empty()) + .unwrap_or_else(|| vec![-7 /* ES256 */, -257 /* RS256 */]); + let cose_params: Vec = algorithms + .iter() + .map(|alg| WEBAUTHN_COSE_CREDENTIAL_PARAMETER { + dwVersion: COSE_PARAMETER_VERSION_1, + pwszCredentialType: w!("public-key"), + lAlg: *alg as i32, + }) + .collect(); + let cose = WEBAUTHN_COSE_CREDENTIAL_PARAMETERS { + cCredentialParameters: cose_params.len() as u32, + pCredentialParameters: cose_params.as_ptr() as *mut WEBAUTHN_COSE_CREDENTIAL_PARAMETER, + }; + + let challenge_b64 = b64url_encode(&challenge); + let client_data_json = build_client_data_json("webauthn.create", &challenge_b64, &options.rp.id); + let client_data = WEBAUTHN_CLIENT_DATA { + dwVersion: CLIENT_DATA_VERSION_1, + cbClientDataJSON: client_data_json.len() as u32, + pbClientDataJSON: client_data_json.as_ptr() as *mut u8, + pwszHashAlgId: w!("SHA-256"), + }; + + let selection = options.authenticator_selection.as_ref(); + let attachment = match selection.and_then(|s| s.authenticator_attachment.as_deref()) { + Some("platform") => ATTACHMENT_PLATFORM, + Some("cross-platform") => ATTACHMENT_CROSS_PLATFORM, + _ => ATTACHMENT_ANY, + }; + let require_resident_key = selection + .and_then(|s| s.require_resident_key) + .or_else(|| selection.and_then(|s| s.resident_key.as_deref().map(|rk| rk == "required"))) + .unwrap_or(false); + let attestation = match options.attestation.as_deref() { + Some("none") => ATTESTATION_NONE, + Some("indirect") => ATTESTATION_INDIRECT, + Some("direct") | Some("enterprise") => ATTESTATION_DIRECT, + _ => ATTESTATION_ANY, + }; + + let exclude_list = match build_credential_list(options.exclude_credentials.as_deref().unwrap_or(&[])) { + Ok(list) => list, + Err((code, message)) => return err_envelope(&code, &message), + }; + + let mut make_options = WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS { + dwVersion: MAKE_CREDENTIAL_OPTIONS_VERSION_3, + dwTimeoutMilliseconds: options.timeout.unwrap_or(60_000), + dwAuthenticatorAttachment: attachment, + bRequireResidentKey: BOOL::from(require_resident_key), + dwUserVerificationRequirement: map_user_verification( + selection.and_then(|s| s.user_verification.as_deref()), + ), + dwAttestationConveyancePreference: attestation, + ..Default::default() + }; + if let Some(list) = exclude_list.as_deref() { + make_options.pExcludeCredentialList = + &list.list as *const WEBAUTHN_CREDENTIAL_LIST as *mut WEBAUTHN_CREDENTIAL_LIST; + } + + let result = unsafe { + WebAuthNAuthenticatorMakeCredential(hwnd, &rp, &user, &cose, &client_data, Some(&make_options)) + }; + + match result { + Ok(attestation_ptr) => { + if attestation_ptr.is_null() { + return err_envelope("unknown", "WebAuthn returned a null attestation"); + } + let envelope = { + let att = unsafe { &*attestation_ptr }; + let credential_id = bytes_from_raw(att.pbCredentialId, att.cbCredentialId); + let attestation_object = bytes_from_raw(att.pbAttestationObject, att.cbAttestationObject); + let id = b64url_encode(&credential_id); + ok_envelope(serde_json::json!({ + "id": id, + "rawId": id, + "type": "public-key", + "authenticatorAttachment": attachment_from_mask(att.dwUsedTransport), + "response": { + "clientDataJSON": b64url_encode(&client_data_json), + "attestationObject": b64url_encode(&attestation_object), + "transports": transports_from_mask(att.dwUsedTransport), + }, + })) + }; + unsafe { WebAuthNFreeCredentialAttestation(Some(attestation_ptr)) }; + envelope + } + Err(error) => { + let (code, message) = map_error(&error); + err_envelope(&code, &message) + } + } +} + +fn get_assertion_blocking(handle: usize, options: &RequestOptions) -> String { + let hwnd = HWND(handle as *mut c_void); + + let challenge = match b64url_decode(&options.challenge) { + Ok(c) => c, + Err(e) => return err_envelope("unknown", &format!("Invalid base64url challenge: {e}")), + }; + + let rp_id_w = wide(&options.rp_id); + let challenge_b64 = b64url_encode(&challenge); + let client_data_json = build_client_data_json("webauthn.get", &challenge_b64, &options.rp_id); + let client_data = WEBAUTHN_CLIENT_DATA { + dwVersion: CLIENT_DATA_VERSION_1, + cbClientDataJSON: client_data_json.len() as u32, + pbClientDataJSON: client_data_json.as_ptr() as *mut u8, + pwszHashAlgId: w!("SHA-256"), + }; + + let allow_list = match build_credential_list(options.allow_credentials.as_deref().unwrap_or(&[])) { + Ok(list) => list, + Err((code, message)) => return err_envelope(&code, &message), + }; + + let mut assertion_options = WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS { + dwVersion: GET_ASSERTION_OPTIONS_VERSION_4, + dwTimeoutMilliseconds: options.timeout.unwrap_or(60_000), + dwAuthenticatorAttachment: ATTACHMENT_ANY, + dwUserVerificationRequirement: map_user_verification(options.user_verification.as_deref()), + ..Default::default() + }; + if let Some(list) = allow_list.as_deref() { + assertion_options.pAllowCredentialList = + &list.list as *const WEBAUTHN_CREDENTIAL_LIST as *mut WEBAUTHN_CREDENTIAL_LIST; + } + + let result = unsafe { + WebAuthNAuthenticatorGetAssertion(hwnd, pcwstr(&rp_id_w), &client_data, Some(&assertion_options)) + }; + + match result { + Ok(assertion_ptr) => { + if assertion_ptr.is_null() { + return err_envelope("unknown", "WebAuthn returned a null assertion"); + } + let envelope = { + let assertion = unsafe { &*assertion_ptr }; + let credential_id = bytes_from_raw(assertion.Credential.pbId, assertion.Credential.cbId); + let authenticator_data = + bytes_from_raw(assertion.pbAuthenticatorData, assertion.cbAuthenticatorData); + let signature = bytes_from_raw(assertion.pbSignature, assertion.cbSignature); + let user_handle = bytes_from_raw(assertion.pbUserId, assertion.cbUserId); + + let id = b64url_encode(&credential_id); + let mut response = serde_json::json!({ + "clientDataJSON": b64url_encode(&client_data_json), + "authenticatorData": b64url_encode(&authenticator_data), + "signature": b64url_encode(&signature), + }); + if !user_handle.is_empty() { + response["userHandle"] = serde_json::Value::String(b64url_encode(&user_handle)); + } + ok_envelope(serde_json::json!({ + "id": id, + "rawId": id, + "type": "public-key", + "authenticatorAttachment": attachment_from_mask(assertion.dwUsedTransport), + "response": response, + })) + }; + unsafe { WebAuthNFreeAssertion(assertion_ptr) }; + envelope + } + Err(error) => { + let (code, message) = map_error(&error); + err_envelope(&code, &message) + } + } +} diff --git a/packages/electron-passkeys/test/loader.test.cjs b/packages/electron-passkeys/test/loader.test.cjs new file mode 100644 index 00000000000..8990719b956 --- /dev/null +++ b/packages/electron-passkeys/test/loader.test.cjs @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +// Without a built native binary (the default in development and on Linux), +// the loader must degrade gracefully instead of crashing the main process. +const loader = require('../index.js'); + +const hasNativeBinary = (() => { + try { + return loader.isAvailable(); + } catch { + return false; + } +})(); + +test('exposes the full native module surface', () => { + assert.equal(typeof loader.isAvailable, 'function'); + assert.equal(typeof loader.capabilities, 'function'); + assert.equal(typeof loader.createCredential, 'function'); + assert.equal(typeof loader.getCredential, 'function'); +}); + +test('capabilities never throws', () => { + const capabilities = loader.capabilities(); + assert.equal(typeof capabilities.platformAuthenticator, 'boolean'); + assert.equal(typeof capabilities.securityKeys, 'boolean'); +}); + +test('credential calls resolve with a not_supported envelope when no binary is present', { skip: hasNativeBinary }, async () => { + for (const method of ['createCredential', 'getCredential']) { + const envelope = JSON.parse(await loader[method](Buffer.alloc(8), '{}')); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, 'not_supported'); + } +}); diff --git a/packages/electron/package.json b/packages/electron/package.json index 30c26ce3f0a..c2cb2fe2d69 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -54,6 +54,16 @@ "default": "./dist/cjs/storage/index.js" } }, + "./passkeys": { + "import": { + "types": "./dist/types/passkeys/index.d.ts", + "default": "./dist/esm/passkeys/index.js" + }, + "require": { + "types": "./dist/types/passkeys/index.d.ts", + "default": "./dist/cjs/passkeys/index.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/cjs/index.js", @@ -78,18 +88,24 @@ "test:watch": "vitest" }, "dependencies": { + "@clerk/shared": "workspace:^", "tslib": "catalog:repo" }, "devDependencies": { + "@clerk/electron-passkeys": "workspace:*", "@types/node": "^22.19.17", "electron": "^39.2.6", "electron-store": "^8.2.0" }, "peerDependencies": { + "@clerk/electron-passkeys": "*", "electron": ">=28", "electron-store": "^8.2.0" }, "peerDependenciesMeta": { + "@clerk/electron-passkeys": { + "optional": true + }, "electron-store": { "optional": true } diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts index 6c762af29a5..9dad6abf3c3 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,4 +1,4 @@ -import type { TokenCache } from './shared/types'; +import type { PasskeyBridge, TokenCache } from './shared/types'; declare const PACKAGE_NAME: string; declare const PACKAGE_VERSION: string; @@ -9,5 +9,6 @@ declare global { __clerk_internal_electron?: { tokenCache: TokenCache; }; + __clerk_internal_electron_passkeys?: PasskeyBridge; } } diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 80aa04dd58e..b27a3627806 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,2 +1,3 @@ export { setupMain } from './main/setup-main'; +export { setupPasskeysMain } from './main/passkey-handlers'; export type { TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/passkey-handlers.test.ts b/packages/electron/src/main/__tests__/passkey-handlers.test.ts new file mode 100644 index 00000000000..ee0ea3da6d0 --- /dev/null +++ b/packages/electron/src/main/__tests__/passkey-handlers.test.ts @@ -0,0 +1,142 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import { BrowserWindow, ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PASSKEY_CHANNELS } from '../../shared/ipc'; +import { setupPasskeysMain } from '../passkey-handlers'; + +const native = vi.hoisted(() => ({ + isAvailable: vi.fn(() => true), + capabilities: vi.fn(() => ({ platformAuthenticator: true, securityKeys: true })), + createCredential: vi.fn(), + getCredential: vi.fn(), +})); + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, + BrowserWindow: { + fromWebContents: vi.fn(), + }, +})); + +vi.mock('@clerk/electron-passkeys', () => ({ default: native })); + +type Handler = (event: IpcMainInvokeEvent, options?: unknown) => unknown; + +const getHandler = (channel: string): Handler => { + const call = vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel); + if (!call) { + throw new Error(`No handler registered for ${channel}`); + } + return call[1] as Handler; +}; + +const windowHandle = Buffer.from([1, 2, 3, 4]); +const event = { sender: {} } as IpcMainInvokeEvent; + +const creationOptions = { challenge: 'abc', rp: { id: 'example.com', name: 'Example' } }; +const registrationJSON = { id: 'cred', rawId: 'cred', type: 'public-key', response: {} }; + +describe('setupPasskeysMain', () => { + beforeEach(() => { + vi.clearAllMocks(); + native.isAvailable.mockReturnValue(true); + vi.mocked(BrowserWindow.fromWebContents).mockReturnValue({ + getNativeWindowHandle: () => windowHandle, + } as unknown as BrowserWindow); + }); + + it('registers handlers for all passkey channels and cleans them up', () => { + const { cleanup } = setupPasskeysMain(); + + expect(ipcMain.handle).toHaveBeenCalledWith(PASSKEY_CHANNELS.create, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(PASSKEY_CHANNELS.get, expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith(PASSKEY_CHANNELS.capabilities, expect.any(Function)); + + cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledWith(PASSKEY_CHANNELS.create); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(PASSKEY_CHANNELS.get); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(PASSKEY_CHANNELS.capabilities); + }); + + it('relays a successful native envelope from create', async () => { + native.createCredential.mockResolvedValue(JSON.stringify({ ok: true, credential: registrationJSON })); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.create)(event, creationOptions); + + expect(native.createCredential).toHaveBeenCalledWith(windowHandle, JSON.stringify(creationOptions)); + expect(result).toEqual({ ok: true, credential: registrationJSON }); + }); + + it('relays a native error envelope from get', async () => { + native.getCredential.mockResolvedValue( + JSON.stringify({ ok: false, error: { code: 'cancelled', message: 'user cancelled' } }), + ); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.get)(event, { challenge: 'abc', rpId: 'example.com' }); + + expect(result).toEqual({ ok: false, error: { code: 'cancelled', message: 'user cancelled' } }); + }); + + it('returns not_supported when the native module reports unavailability', async () => { + native.isAvailable.mockReturnValue(false); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.create)(event, creationOptions); + + expect(result).toMatchObject({ ok: false, error: { code: 'not_supported' } }); + expect(native.createCredential).not.toHaveBeenCalled(); + }); + + it('returns an error when the request does not originate from a window', async () => { + vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(null); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.create)(event, creationOptions); + + expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } }); + }); + + it('wraps malformed native output in an unknown error envelope', async () => { + native.createCredential.mockResolvedValue('not json'); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.create)(event, creationOptions); + + expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } }); + }); + + it('rejects envelopes with unrecognized error codes', async () => { + native.createCredential.mockResolvedValue( + JSON.stringify({ ok: false, error: { code: 'something_else', message: 'nope' } }), + ); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.create)(event, creationOptions); + + expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } }); + }); + + it('reports capabilities from the native module', async () => { + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.capabilities)(event); + + expect(result).toEqual({ available: true, platformAuthenticator: true, securityKeys: true }); + }); + + it('reports unavailable capabilities when the platform is unsupported', async () => { + native.isAvailable.mockReturnValue(false); + setupPasskeysMain(); + + const result = await getHandler(PASSKEY_CHANNELS.capabilities)(event); + + expect(result).toEqual({ available: false, platformAuthenticator: false, securityKeys: false }); + }); +}); diff --git a/packages/electron/src/main/passkey-handlers.ts b/packages/electron/src/main/passkey-handlers.ts new file mode 100644 index 00000000000..b8922a67067 --- /dev/null +++ b/packages/electron/src/main/passkey-handlers.ts @@ -0,0 +1,138 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import { BrowserWindow, ipcMain } from 'electron'; + +import { PASSKEY_CHANNELS } from '../shared/ipc'; +import type { + AuthenticationResponseJSON, + PasskeyCapabilities, + PasskeyIpcResult, + PasskeyNativeErrorCode, + RegistrationResponseJSON, + SerializedPublicKeyCredentialCreationOptions, + SerializedPublicKeyCredentialRequestOptions, + SetupPasskeysMainReturn, +} from '../shared/types'; + +/** + * Optional native module. Ceremony failures resolve as JSON envelopes so error + * codes survive both the FFI and Electron IPC boundaries. + */ +type NativePasskeysModule = { + isAvailable: () => boolean; + capabilities: () => Omit; + createCredential: (windowHandle: Buffer, optionsJson: string) => Promise; + getCredential: (windowHandle: Buffer, optionsJson: string) => Promise; +}; + +let nativeModulePromise: Promise | undefined; + +function loadNativeModule(): Promise { + // Keep the native module optional for apps that do not use passkeys. + nativeModulePromise ??= import('@clerk/electron-passkeys').then( + (module: { default?: NativePasskeysModule } & NativePasskeysModule) => module.default ?? module, + error => { + nativeModulePromise = undefined; + throw new Error( + 'Clerk: setupPasskeysMain requires the optional @clerk/electron-passkeys package. Install it with your package manager to enable native passkey support.', + { cause: error }, + ); + }, + ); + return nativeModulePromise; +} + +const NATIVE_ERROR_CODES: PasskeyNativeErrorCode[] = ['cancelled', 'invalid_rp', 'not_supported', 'timeout', 'unknown']; + +function isPasskeyIpcResult(value: unknown): value is PasskeyIpcResult { + if (!value || typeof value !== 'object' || typeof (value as { ok?: unknown }).ok !== 'boolean') { + return false; + } + const result = value as { ok: boolean; credential?: unknown; error?: { code?: unknown } }; + return result.ok + ? result.credential !== undefined + : NATIVE_ERROR_CODES.includes(result.error?.code as PasskeyNativeErrorCode); +} + +async function invokeNative( + method: 'createCredential' | 'getCredential', + event: IpcMainInvokeEvent, + options: SerializedPublicKeyCredentialCreationOptions | SerializedPublicKeyCredentialRequestOptions, +): Promise> { + let native: NativePasskeysModule; + try { + native = await loadNativeModule(); + } catch (error) { + return { + ok: false, + error: { code: 'not_supported', message: error instanceof Error ? error.message : String(error) }, + }; + } + + if (!native.isAvailable()) { + return { + ok: false, + error: { code: 'not_supported', message: 'Native passkeys are not supported on this platform.' }, + }; + } + + const window = BrowserWindow.fromWebContents(event.sender); + if (!window) { + return { + ok: false, + error: { code: 'unknown', message: 'The passkey request did not originate from a visible window.' }, + }; + } + + try { + const resultJson = await native[method](window.getNativeWindowHandle(), JSON.stringify(options)); + const result: unknown = JSON.parse(resultJson); + if (!isPasskeyIpcResult(result)) { + return { ok: false, error: { code: 'unknown', message: 'The native module returned an unexpected result.' } }; + } + return result; + } catch (error) { + return { ok: false, error: { code: 'unknown', message: error instanceof Error ? error.message : String(error) } }; + } +} + +/** Registers IPC handlers for native platform WebAuthn. */ +export function setupPasskeysMain(): SetupPasskeysMainReturn { + // Surface a missing optional dependency during setup, before the first ceremony. + loadNativeModule().catch((error: Error) => console.warn(error.message)); + + ipcMain.handle( + PASSKEY_CHANNELS.create, + ( + event, + options: SerializedPublicKeyCredentialCreationOptions, + ): Promise> => invokeNative('createCredential', event, options), + ); + + ipcMain.handle( + PASSKEY_CHANNELS.get, + ( + event, + options: SerializedPublicKeyCredentialRequestOptions, + ): Promise> => invokeNative('getCredential', event, options), + ); + + ipcMain.handle(PASSKEY_CHANNELS.capabilities, async (): Promise => { + try { + const native = await loadNativeModule(); + if (!native.isAvailable()) { + return { available: false, platformAuthenticator: false, securityKeys: false }; + } + return { available: true, ...native.capabilities() }; + } catch { + return { available: false, platformAuthenticator: false, securityKeys: false }; + } + }); + + return { + cleanup() { + ipcMain.removeHandler(PASSKEY_CHANNELS.create); + ipcMain.removeHandler(PASSKEY_CHANNELS.get); + ipcMain.removeHandler(PASSKEY_CHANNELS.capabilities); + }, + }; +} diff --git a/packages/electron/src/passkeys/__tests__/errors.test.ts b/packages/electron/src/passkeys/__tests__/errors.test.ts new file mode 100644 index 00000000000..86230f3024b --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/errors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import type { PasskeyNativeErrorCode } from '../../shared/types'; +import { mapPasskeyIpcError } from '../shared/errors'; + +describe('mapPasskeyIpcError', () => { + it.each<[PasskeyNativeErrorCode, 'create' | 'get', string]>([ + ['cancelled', 'create', 'passkey_registration_cancelled'], + ['cancelled', 'get', 'passkey_retrieval_cancelled'], + ['invalid_rp', 'create', 'passkey_invalid_rpID_or_domain'], + ['invalid_rp', 'get', 'passkey_invalid_rpID_or_domain'], + ['timeout', 'create', 'passkey_operation_aborted'], + ['timeout', 'get', 'passkey_operation_aborted'], + ['not_supported', 'create', 'passkey_not_supported'], + ['not_supported', 'get', 'passkey_not_supported'], + ['unknown', 'create', 'passkey_registration_failed'], + ['unknown', 'get', 'passkey_retrieval_failed'], + ])('maps %s during %s to %s', (code, action, expected) => { + const error = mapPasskeyIpcError({ code, message: 'boom' }, action); + + // Shape assertion instead of instanceof: the test and the source may load + // ClerkWebAuthnError through different module formats (dual-package hazard). + expect(error.clerkRuntimeError).toBe(true); + expect(error.code).toBe(expected); + expect(error.message).toContain('boom'); + }); + + it('includes a docs URL for RP ID mismatches', () => { + const error = mapPasskeyIpcError({ code: 'invalid_rp', message: 'bad rp' }, 'create'); + expect(error.longMessage ?? error.message).toBeDefined(); + }); +}); diff --git a/packages/electron/src/passkeys/__tests__/index.test.ts b/packages/electron/src/passkeys/__tests__/index.test.ts new file mode 100644 index 00000000000..a093b73f510 --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/index.test.ts @@ -0,0 +1,283 @@ +import { webAuthnCreateCredential, webAuthnGetCredential } from '@clerk/shared/internal/clerk-js/passkeys'; +import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PasskeyBridge } from '../../shared/types'; +import type { ClerkPasskeyHost } from '../index'; +import { createPasskeyProvider, createPasskeys } from '../index'; + +vi.mock('@clerk/shared/internal/clerk-js/passkeys', async importOriginal => { + const original = await importOriginal>(); + return { + ...original, + webAuthnCreateCredential: vi.fn(), + webAuthnGetCredential: vi.fn(), + }; +}); + +vi.mock('@clerk/shared/webauthn', () => ({ + isWebAuthnAutofillSupported: vi.fn(() => Promise.resolve(true)), + isWebAuthnPlatformAuthenticatorSupported: vi.fn(() => Promise.resolve(true)), +})); + +const HELLO_B64URL = 'aGVsbG8'; + +const creationOptions = () => + ({ + rp: { id: 'example.com', name: 'Example' }, + user: { id: new Uint8Array([1]).buffer, name: 'jdoe', displayName: 'J Doe' }, + challenge: new Uint8Array([1, 2, 3]).buffer, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + timeout: 60_000, + authenticatorSelection: { + authenticatorAttachment: 'platform', + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, + attestation: 'none', + excludeCredentials: [], + }) as never; + +const requestOptions = () => + ({ + challenge: new Uint8Array([1, 2, 3]).buffer, + rpId: 'example.com', + timeout: 60_000, + userVerification: 'required', + allowCredentials: [], + }) as never; + +const registrationJSON = { + id: HELLO_B64URL, + rawId: HELLO_B64URL, + type: 'public-key', + response: { clientDataJSON: HELLO_B64URL, attestationObject: HELLO_B64URL }, +}; + +const authenticationJSON = { + id: HELLO_B64URL, + rawId: HELLO_B64URL, + type: 'public-key', + response: { + clientDataJSON: HELLO_B64URL, + authenticatorData: HELLO_B64URL, + signature: HELLO_B64URL, + }, +}; + +const makeBridge = (overrides: Partial = {}): PasskeyBridge => ({ + create: vi.fn(() => Promise.resolve({ ok: true as const, credential: registrationJSON as never })), + get: vi.fn(() => Promise.resolve({ ok: true as const, credential: authenticationJSON as never })), + capabilities: vi.fn(() => Promise.resolve({ available: true, platformAuthenticator: true, securityKeys: true })), + electronMajor: 42, + platform: 'darwin', + ...overrides, +}); + +type Env = { + protocol?: string; + hostname?: string; + hasWebAuthn?: boolean; + bridge?: PasskeyBridge; +}; + +function stubEnvironment({ protocol = 'https:', hostname = 'example.com', hasWebAuthn = true, bridge }: Env) { + vi.stubGlobal('location', { protocol, hostname }); + vi.stubGlobal('window', { + ...(hasWebAuthn ? { PublicKeyCredential: function PublicKeyCredential() {} } : {}), + ...(bridge ? { __clerk_internal_electron_passkeys: bridge } : {}), + }); +} + +describe('createPasskeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('create', () => { + it('uses the renderer path on a matching https origin', async () => { + const bridge = makeBridge(); + stubEnvironment({ bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys().create(creationOptions()); + + expect(result).toBe(rendererResult); + expect(bridge.create).not.toHaveBeenCalled(); + }); + + it('uses the native path for local bundles', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'file:', hostname: '', bridge }); + + const result = await createPasskeys().create(creationOptions()); + + expect(bridge.create).toHaveBeenCalledWith( + expect.objectContaining({ rp: { id: 'example.com', name: 'Example' }, challenge: 'AQID' }), + ); + expect(webAuthnCreateCredential).not.toHaveBeenCalled(); + expect(result.error).toBeNull(); + expect(result.publicKeyCredential?.id).toBe(HELLO_B64URL); + expect(result.publicKeyCredential?.toJSON()).toEqual(registrationJSON); + }); + + it('retries natively when the renderer rejects the RP ID', async () => { + const bridge = makeBridge(); + stubEnvironment({ bridge }); + vi.mocked(webAuthnCreateCredential).mockResolvedValue({ + publicKeyCredential: null, + error: Object.assign(new Error('rp mismatch'), { code: 'passkey_invalid_rpID_or_domain' }), + } as never); + + const result = await createPasskeys().create(creationOptions()); + + expect(bridge.create).toHaveBeenCalled(); + expect(result.error).toBeNull(); + }); + + it('does not retry natively when the user cancels in the renderer', async () => { + const bridge = makeBridge(); + stubEnvironment({ bridge }); + const cancelled = { + publicKeyCredential: null, + error: Object.assign(new Error('cancelled'), { code: 'passkey_registration_cancelled' }), + }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(cancelled as never); + + const result = await createPasskeys().create(creationOptions()); + + expect(result).toBe(cancelled); + expect(bridge.create).not.toHaveBeenCalled(); + }); + + it('maps native error envelopes to ClerkWebAuthnError', async () => { + const bridge = makeBridge({ + create: vi.fn(() => + Promise.resolve({ + ok: false as const, + error: { code: 'cancelled' as const, message: 'user cancelled' }, + }), + ), + }); + stubEnvironment({ protocol: 'file:', hostname: '', bridge }); + + const result = await createPasskeys().create(creationOptions()); + + expect(result.publicKeyCredential).toBeNull(); + expect(result.error).toMatchObject({ code: 'passkey_registration_cancelled' }); + }); + + it('returns passkey_not_supported when no path is available', async () => { + stubEnvironment({ protocol: 'file:', hostname: '', hasWebAuthn: false }); + + const result = await createPasskeys().create(creationOptions()); + + expect(result.publicKeyCredential).toBeNull(); + expect(result.error).toMatchObject({ code: 'passkey_not_supported' }); + }); + + it('ignores the bridge on platforms without a native implementation', async () => { + const bridge = makeBridge({ platform: 'linux' }); + stubEnvironment({ protocol: 'file:', hostname: '', bridge }); + + const result = await createPasskeys().create(creationOptions()); + + expect(bridge.create).not.toHaveBeenCalled(); + expect(result.error).toMatchObject({ code: 'passkey_not_supported' }); + }); + + it('honors mode: native on a matching origin', async () => { + const bridge = makeBridge(); + stubEnvironment({ bridge }); + + await createPasskeys({ mode: 'native' }).create(creationOptions()); + + expect(bridge.create).toHaveBeenCalled(); + expect(webAuthnCreateCredential).not.toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('uses the renderer path on a matching https origin without conditional UI', async () => { + stubEnvironment({ bridge: makeBridge() }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnGetCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys().get({ publicKeyOptions: requestOptions() }); + + expect(webAuthnGetCredential).toHaveBeenCalledWith({ + publicKeyOptions: expect.anything(), + conditionalUI: false, + }); + expect(result).toBe(rendererResult); + }); + + it('uses the native path for local bundles', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'file:', hostname: '', bridge }); + + const result = await createPasskeys().get({ publicKeyOptions: requestOptions() }); + + expect(bridge.get).toHaveBeenCalledWith(expect.objectContaining({ rpId: 'example.com', challenge: 'AQID' })); + expect(result.error).toBeNull(); + expect(result.publicKeyCredential?.toJSON()).toEqual(authenticationJSON); + }); + }); + + describe('capability checks', () => { + it('isSupported reflects either available path in auto mode', () => { + stubEnvironment({ protocol: 'file:', hostname: '', hasWebAuthn: false, bridge: makeBridge() }); + expect(createPasskeys().isSupported()).toBe(true); + + stubEnvironment({ protocol: 'file:', hostname: '', hasWebAuthn: false }); + expect(createPasskeys().isSupported()).toBe(false); + + stubEnvironment({ hasWebAuthn: true }); + expect(createPasskeys().isSupported()).toBe(true); + }); + + it('isAutoFillSupported is false in native mode', async () => { + stubEnvironment({ bridge: makeBridge() }); + + await expect(createPasskeys({ mode: 'native' }).isAutoFillSupported()).resolves.toBe(false); + await expect(createPasskeys().isAutoFillSupported()).resolves.toBe(true); + expect(isWebAuthnAutofillSupported).toHaveBeenCalledTimes(1); + }); + + it('isPlatformAuthenticatorSupported prefers native capabilities when available', async () => { + const bridge = makeBridge(); + stubEnvironment({ bridge }); + + await expect(createPasskeys().isPlatformAuthenticatorSupported()).resolves.toBe(true); + expect(bridge.capabilities).toHaveBeenCalled(); + }); + }); +}); + +describe('createPasskeyProvider', () => { + it('assigns the clerk-js passkey provider contract', () => { + vi.stubGlobal('window', {}); + const clerk: ClerkPasskeyHost = { + __internal_createPublicCredentials: undefined, + __internal_getPublicCredentials: undefined, + __internal_isWebAuthnSupported: undefined, + __internal_isWebAuthnAutofillSupported: undefined, + __internal_isWebAuthnPlatformAuthenticatorSupported: undefined, + }; + + const passkeys = createPasskeyProvider(clerk); + + expect(clerk.__internal_createPublicCredentials).toBe(passkeys.create); + expect(clerk.__internal_getPublicCredentials).toBe(passkeys.get); + expect(clerk.__internal_isWebAuthnSupported).toBe(passkeys.isSupported); + expect(clerk.__internal_isWebAuthnAutofillSupported).toBe(passkeys.isAutoFillSupported); + expect(clerk.__internal_isWebAuthnPlatformAuthenticatorSupported).toBe(passkeys.isPlatformAuthenticatorSupported); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/electron/src/passkeys/__tests__/preload.test.ts b/packages/electron/src/passkeys/__tests__/preload.test.ts new file mode 100644 index 00000000000..553e733435e --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/preload.test.ts @@ -0,0 +1,88 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PASSKEY_CHANNELS } from '../../shared/ipc'; +import { setupPasskeysPreload } from '../preload'; + +vi.mock('electron', () => ({ + contextBridge: { + exposeInMainWorld: vi.fn(), + }, + ipcRenderer: { + invoke: vi.fn(), + }, +})); + +describe('setupPasskeysPreload', () => { + const originalContextIsolated = process.contextIsolated; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }); + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(() => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: originalContextIsolated }); + }); + + it('exposes the passkey bridge through contextBridge when context isolation is enabled', () => { + setupPasskeysPreload(); + + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith('__clerk_internal_electron_passkeys', { + create: expect.any(Function), + get: expect.any(Function), + capabilities: expect.any(Function), + electronMajor: expect.any(Number), + platform: process.platform, + }); + }); + + it('exposes the passkey bridge on window when context isolation is disabled', () => { + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: false }); + + setupPasskeysPreload(); + + expect(window.__clerk_internal_electron_passkeys).toEqual({ + create: expect.any(Function), + get: expect.any(Function), + capabilities: expect.any(Function), + electronMajor: expect.any(Number), + platform: process.platform, + }); + }); + + it('forwards passkey calls over IPC', async () => { + setupPasskeysPreload(); + + const bridge = vi.mocked(contextBridge.exposeInMainWorld).mock.calls[0][1] as NonNullable< + typeof window.__clerk_internal_electron_passkeys + >; + + const createOptions = { challenge: 'abc' }; + const getOptions = { challenge: 'def', rpId: 'example.com' }; + + await bridge.create(createOptions as never); + await bridge.get(getOptions as never); + await bridge.capabilities(); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(PASSKEY_CHANNELS.create, createOptions); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(PASSKEY_CHANNELS.get, getOptions); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(PASSKEY_CHANNELS.capabilities); + }); + + it('reports the Electron major version', () => { + setupPasskeysPreload(); + + const bridge = vi.mocked(contextBridge.exposeInMainWorld).mock.calls[0][1] as NonNullable< + typeof window.__clerk_internal_electron_passkeys + >; + + const expected = Number.parseInt(process.versions.electron ?? '', 10) || 0; + expect(bridge.electronMajor).toBe(expected); + }); +}); diff --git a/packages/electron/src/passkeys/__tests__/serialization.test.ts b/packages/electron/src/passkeys/__tests__/serialization.test.ts new file mode 100644 index 00000000000..3a967c4519a --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/serialization.test.ts @@ -0,0 +1,157 @@ +import type { + PublicKeyCredentialCreationOptionsWithoutExtensions, + PublicKeyCredentialRequestOptionsWithoutExtensions, +} from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '../../shared/types'; +import { + deserializeCreationResponse, + deserializeRequestResponse, + serializeCreationOptions, + serializeRequestOptions, +} from '../shared/serialization'; + +const bytes = (...values: number[]) => new Uint8Array(values).buffer; + +// 'hello' in base64url +const HELLO_B64URL = 'aGVsbG8'; +const helloBuffer = () => new TextEncoder().encode('hello').buffer as ArrayBuffer; + +describe('serializeCreationOptions', () => { + const options: PublicKeyCredentialCreationOptionsWithoutExtensions = { + rp: { id: 'example.com', name: 'Example' }, + user: { id: helloBuffer(), name: 'jdoe', displayName: 'J Doe' }, + challenge: bytes(1, 2, 3, 250), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + timeout: 60_000, + authenticatorSelection: { + authenticatorAttachment: 'platform', + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, + attestation: 'none', + excludeCredentials: [{ type: 'public-key', id: bytes(9, 9), transports: ['internal'] }], + }; + + it('encodes binary fields as base64url and preserves the rest', () => { + const serialized = serializeCreationOptions(options); + + expect(serialized).toEqual({ + rp: { id: 'example.com', name: 'Example' }, + user: { id: HELLO_B64URL, name: 'jdoe', displayName: 'J Doe' }, + challenge: 'AQID-g', + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + timeout: 60_000, + authenticatorSelection: options.authenticatorSelection, + attestation: 'none', + excludeCredentials: [{ type: 'public-key', id: 'CQk', transports: ['internal'] }], + }); + }); + + it('accepts typed-array views over buffers', () => { + const serialized = serializeCreationOptions({ + ...options, + challenge: new Uint8Array([1, 2, 3, 250]), + }); + expect(serialized.challenge).toBe('AQID-g'); + }); + + it('survives a JSON round trip (IPC structured clone safety)', () => { + const serialized = serializeCreationOptions(options); + expect(JSON.parse(JSON.stringify(serialized))).toEqual(serialized); + }); +}); + +describe('serializeRequestOptions', () => { + const options: PublicKeyCredentialRequestOptionsWithoutExtensions = { + challenge: bytes(1, 2, 3, 250), + rpId: 'example.com', + timeout: 60_000, + userVerification: 'required', + allowCredentials: [{ type: 'public-key', id: bytes(9, 9) }], + }; + + it('encodes binary fields as base64url', () => { + expect(serializeRequestOptions(options)).toEqual({ + challenge: 'AQID-g', + rpId: 'example.com', + timeout: 60_000, + userVerification: 'required', + allowCredentials: [{ type: 'public-key', id: 'CQk' }], + }); + }); +}); + +describe('deserializeCreationResponse', () => { + const json: RegistrationResponseJSON = { + id: HELLO_B64URL, + rawId: HELLO_B64URL, + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: HELLO_B64URL, + attestationObject: HELLO_B64URL, + transports: ['internal', 'hybrid'], + }, + }; + + it('decodes base64url fields into ArrayBuffers', () => { + const credential = deserializeCreationResponse(json); + + expect(credential.id).toBe(HELLO_B64URL); + expect(new TextDecoder().decode(credential.rawId)).toBe('hello'); + expect(new TextDecoder().decode(credential.response.clientDataJSON)).toBe('hello'); + expect(new TextDecoder().decode(credential.response.attestationObject)).toBe('hello'); + expect(credential.response.getTransports()).toEqual(['internal', 'hybrid']); + expect(credential.authenticatorAttachment).toBe('platform'); + }); + + it('exposes the original JSON via toJSON', () => { + expect(deserializeCreationResponse(json).toJSON()).toBe(json); + }); + + it('defaults authenticatorAttachment to null and transports to []', () => { + const credential = deserializeCreationResponse({ + ...json, + authenticatorAttachment: undefined, + response: { clientDataJSON: HELLO_B64URL, attestationObject: HELLO_B64URL }, + }); + expect(credential.authenticatorAttachment).toBeNull(); + expect(credential.response.getTransports()).toEqual([]); + }); +}); + +describe('deserializeRequestResponse', () => { + const json: AuthenticationResponseJSON = { + id: HELLO_B64URL, + rawId: HELLO_B64URL, + type: 'public-key', + authenticatorAttachment: 'platform', + response: { + clientDataJSON: HELLO_B64URL, + authenticatorData: HELLO_B64URL, + signature: HELLO_B64URL, + userHandle: HELLO_B64URL, + }, + }; + + it('decodes base64url fields into ArrayBuffers', () => { + const credential = deserializeRequestResponse(json); + + expect(new TextDecoder().decode(credential.rawId)).toBe('hello'); + expect(new TextDecoder().decode(credential.response.authenticatorData)).toBe('hello'); + expect(new TextDecoder().decode(credential.response.signature)).toBe('hello'); + expect(new TextDecoder().decode(credential.response.userHandle as ArrayBuffer)).toBe('hello'); + expect(credential.toJSON()).toBe(json); + }); + + it('maps a missing userHandle to null', () => { + const credential = deserializeRequestResponse({ + ...json, + response: { ...json.response, userHandle: undefined }, + }); + expect(credential.response.userHandle).toBeNull(); + }); +}); diff --git a/packages/electron/src/passkeys/__tests__/strategy.test.ts b/packages/electron/src/passkeys/__tests__/strategy.test.ts new file mode 100644 index 00000000000..d01f793ba79 --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/strategy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import type { StrategyEnv } from '../renderer/strategy'; +import { decidePath, originSatisfiesRpId } from '../renderer/strategy'; + +const RP_ID = 'example.com'; + +const env = (overrides: Partial = {}): StrategyEnv => ({ + protocol: 'https:', + hostname: 'example.com', + hasWebAuthn: true, + nativeAvailable: true, + platform: 'darwin', + electronMajor: 42, + ...overrides, +}); + +describe('originSatisfiesRpId', () => { + it.each([ + ['https:', 'example.com', 'example.com', true], + ['https:', 'app.example.com', 'example.com', true], + ['https:', 'deep.app.example.com', 'example.com', true], + ['https:', 'badexample.com', 'example.com', false], + ['https:', 'example.com.evil.com', 'example.com', false], + ['https:', 'example.com', '', false], + ['http:', 'example.com', 'example.com', false], + ['file:', '', 'example.com', false], + ['app:', 'bundle', 'example.com', false], + ])('%s//%s with rpId %s -> %s', (protocol, hostname, rpId, expected) => { + expect(originSatisfiesRpId({ protocol, hostname }, rpId)).toBe(expected); + }); +}); + +describe('decidePath', () => { + describe('forced modes', () => { + it('renderer mode uses the renderer whenever WebAuthn exists, regardless of origin', () => { + expect(decidePath(RP_ID, 'renderer', env({ protocol: 'file:', hostname: '' }))).toBe('renderer'); + }); + + it('renderer mode is unsupported without WebAuthn', () => { + expect(decidePath(RP_ID, 'renderer', env({ hasWebAuthn: false }))).toBe('unsupported'); + }); + + it('native mode uses the native path when the bridge is available', () => { + expect(decidePath(RP_ID, 'native', env())).toBe('native'); + }); + + it('native mode is unsupported without the bridge', () => { + expect(decidePath(RP_ID, 'native', env({ nativeAvailable: false }))).toBe('unsupported'); + }); + }); + + describe('auto mode', () => { + it('prefers the renderer when the origin satisfies the RP ID', () => { + expect(decidePath(RP_ID, 'auto', env())).toBe('renderer'); + }); + + it('falls back to native for local bundles (file://)', () => { + expect(decidePath(RP_ID, 'auto', env({ protocol: 'file:', hostname: '' }))).toBe('native'); + }); + + it('falls back to native for custom protocols (app://)', () => { + expect(decidePath(RP_ID, 'auto', env({ protocol: 'app:', hostname: 'bundle' }))).toBe('native'); + }); + + it('falls back to native when the origin does not match the RP ID', () => { + expect(decidePath(RP_ID, 'auto', env({ hostname: 'other.com' }))).toBe('native'); + }); + + it('prefers native on macOS before Electron 42, where renderer platform authenticators are broken', () => { + expect(decidePath(RP_ID, 'auto', env({ electronMajor: 39 }))).toBe('native'); + }); + + it('prefers the renderer on macOS before Electron 42 when native is unavailable', () => { + expect(decidePath(RP_ID, 'auto', env({ electronMajor: 39, nativeAvailable: false }))).toBe('renderer'); + }); + + it('prefers the renderer on macOS when the Electron version is unknown', () => { + expect(decidePath(RP_ID, 'auto', env({ electronMajor: 0 }))).toBe('renderer'); + }); + + it('prefers the renderer on Windows regardless of Electron version', () => { + expect(decidePath(RP_ID, 'auto', env({ platform: 'win32', electronMajor: 30 }))).toBe('renderer'); + }); + + it('is unsupported when neither path is available', () => { + expect(decidePath(RP_ID, 'auto', env({ protocol: 'file:', hostname: '', nativeAvailable: false }))).toBe( + 'unsupported', + ); + }); + + it('uses the renderer for security keys on Linux remote origins', () => { + expect(decidePath(RP_ID, 'auto', env({ platform: 'linux', nativeAvailable: false }))).toBe('renderer'); + }); + }); +}); diff --git a/packages/electron/src/passkeys/index.ts b/packages/electron/src/passkeys/index.ts new file mode 100644 index 00000000000..7d1523f955e --- /dev/null +++ b/packages/electron/src/passkeys/index.ts @@ -0,0 +1,171 @@ +import { ClerkWebAuthnError } from '@clerk/shared/error'; +import { webAuthnCreateCredential, webAuthnGetCredential } from '@clerk/shared/internal/clerk-js/passkeys'; +import type { + CredentialReturn, + PublicKeyCredentialCreationOptionsWithoutExtensions, + PublicKeyCredentialRequestOptionsWithoutExtensions, + PublicKeyCredentialWithAuthenticatorAssertionResponse, + PublicKeyCredentialWithAuthenticatorAttestationResponse, +} from '@clerk/shared/types'; +import { isWebAuthnAutofillSupported, isWebAuthnPlatformAuthenticatorSupported } from '@clerk/shared/webauthn'; + +import { getPasskeyBridge, nativeCreateCredential, nativeGetCredential } from './renderer/native-bridge'; +import type { PasskeyMode, StrategyEnv } from './renderer/strategy'; +import { decidePath } from './renderer/strategy'; + +export type { PasskeyMode, PasskeyPath, StrategyEnv } from './renderer/strategy'; + +export type CreatePasskeysOptions = { + /** + * WebAuthn implementation to use: + * `auto` chooses renderer WebAuthn for valid HTTPS origins and native WebAuthn otherwise. + */ + mode?: PasskeyMode; +}; + +export type PasskeySupport = { + create: ( + publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions, + ) => Promise>; + get: (args: { + publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + }) => Promise>; + isSupported: () => boolean; + isAutoFillSupported: () => Promise; + isPlatformAuthenticatorSupported: () => Promise; +}; + +export type ClerkPasskeyHost = { + __internal_createPublicCredentials: PasskeySupport['create'] | undefined; + __internal_getPublicCredentials: PasskeySupport['get'] | undefined; + __internal_isWebAuthnSupported: PasskeySupport['isSupported'] | undefined; + __internal_isWebAuthnAutofillSupported: PasskeySupport['isAutoFillSupported'] | undefined; + __internal_isWebAuthnPlatformAuthenticatorSupported: PasskeySupport['isPlatformAuthenticatorSupported'] | undefined; +}; + +const NATIVE_PLATFORMS = ['darwin', 'win32']; + +function getEnv(): StrategyEnv { + const bridge = getPasskeyBridge(); + const hasLocation = typeof location !== 'undefined'; + return { + protocol: hasLocation ? location.protocol : '', + hostname: hasLocation ? location.hostname : '', + hasWebAuthn: typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function', + nativeAvailable: !!bridge && NATIVE_PLATFORMS.includes(bridge.platform), + platform: bridge?.platform ?? '', + electronMajor: bridge?.electronMajor ?? 0, + }; +} + +const unsupportedReturn = (): CredentialReturn => + ({ + publicKeyCredential: null, + error: new ClerkWebAuthnError( + 'Clerk: Passkeys are not supported in this window. Serve the page from an https origin matching the RP ID, or set up the native passkey module (setupPasskeysMain/setupPasskeysPreload).', + { code: 'passkey_not_supported' }, + ), + }) as CredentialReturn; + +const isRpIdMismatchError = (error: unknown): boolean => + !!error && typeof error === 'object' && (error as { code?: string }).code === 'passkey_invalid_rpID_or_domain'; + +/** Creates an Electron passkey provider for clerk-js. */ +export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport { + const mode: PasskeyMode = options?.mode ?? 'auto'; + + const create: PasskeySupport['create'] = async publicKey => { + const env = getEnv(); + const path = decidePath(publicKey.rp.id ?? '', mode, env); + + if (path === 'unsupported') { + return unsupportedReturn(); + } + if (path === 'native') { + return nativeCreateCredential(publicKey); + } + + const result = await webAuthnCreateCredential(publicKey); + // Retry with native WebAuthn when Chromium rejects the RP ID for the page origin. + if (result.error && isRpIdMismatchError(result.error) && mode === 'auto' && env.nativeAvailable) { + return nativeCreateCredential(publicKey); + } + return result; + }; + + const get: PasskeySupport['get'] = async ({ publicKeyOptions }) => { + const env = getEnv(); + const path = decidePath(publicKeyOptions.rpId ?? '', mode, env); + + if (path === 'unsupported') { + return unsupportedReturn(); + } + if (path === 'native') { + return nativeGetCredential(publicKeyOptions); + } + + const result = await webAuthnGetCredential({ publicKeyOptions, conditionalUI: false }); + if (result.error && isRpIdMismatchError(result.error) && mode === 'auto' && env.nativeAvailable) { + return nativeGetCredential(publicKeyOptions); + } + return result; + }; + + const isSupported: PasskeySupport['isSupported'] = () => { + const env = getEnv(); + if (mode === 'renderer') { + return env.hasWebAuthn; + } + if (mode === 'native') { + return env.nativeAvailable; + } + return env.hasWebAuthn || env.nativeAvailable; + }; + + const isAutoFillSupported: PasskeySupport['isAutoFillSupported'] = () => { + return mode === 'native' ? Promise.resolve(false) : isWebAuthnAutofillSupported(); + }; + + const isPlatformAuthenticatorSupported: PasskeySupport['isPlatformAuthenticatorSupported'] = async () => { + const env = getEnv(); + if (env.nativeAvailable && mode !== 'renderer') { + const bridge = getPasskeyBridge(); + try { + const capabilities = await bridge?.capabilities(); + if (capabilities?.available) { + return capabilities.platformAuthenticator; + } + } catch { + // Fall back to Chromium's capability check. + } + } + return isWebAuthnPlatformAuthenticatorSupported(); + }; + + return { create, get, isSupported, isAutoFillSupported, isPlatformAuthenticatorSupported }; +} + +/** + * Wires passkey support into a Clerk instance. Call before `clerk.load()`. + * + * @example + * ```ts + * import { Clerk } from '@clerk/clerk-js'; + * import { createPasskeyProvider } from '@clerk/electron/passkeys'; + * + * const clerk = new Clerk(publishableKey); + * createPasskeyProvider(clerk); + * await clerk.load(); + * ``` + */ +export function createPasskeyProvider(clerk: ClerkPasskeyHost, options?: CreatePasskeysOptions): PasskeySupport { + const passkeys = createPasskeys(options); + + clerk.__internal_createPublicCredentials = passkeys.create; + clerk.__internal_getPublicCredentials = passkeys.get; + clerk.__internal_isWebAuthnSupported = passkeys.isSupported; + clerk.__internal_isWebAuthnAutofillSupported = passkeys.isAutoFillSupported; + clerk.__internal_isWebAuthnPlatformAuthenticatorSupported = passkeys.isPlatformAuthenticatorSupported; + + return passkeys; +} diff --git a/packages/electron/src/passkeys/preload.ts b/packages/electron/src/passkeys/preload.ts new file mode 100644 index 00000000000..280789827ab --- /dev/null +++ b/packages/electron/src/passkeys/preload.ts @@ -0,0 +1,21 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +import { PASSKEY_CHANNELS } from '../shared/ipc'; +import type { PasskeyBridge } from '../shared/types'; + +/** Exposes the native passkey bridge to the renderer. */ +export function setupPasskeysPreload(): void { + const bridge: PasskeyBridge = { + create: options => ipcRenderer.invoke(PASSKEY_CHANNELS.create, options), + get: options => ipcRenderer.invoke(PASSKEY_CHANNELS.get, options), + capabilities: () => ipcRenderer.invoke(PASSKEY_CHANNELS.capabilities), + electronMajor: Number.parseInt(process.versions.electron ?? '', 10) || 0, + platform: process.platform, + }; + + if (process.contextIsolated) { + contextBridge.exposeInMainWorld('__clerk_internal_electron_passkeys', bridge); + } else { + window.__clerk_internal_electron_passkeys = bridge; + } +} diff --git a/packages/electron/src/passkeys/renderer/native-bridge.ts b/packages/electron/src/passkeys/renderer/native-bridge.ts new file mode 100644 index 00000000000..e0e2cfc4d7b --- /dev/null +++ b/packages/electron/src/passkeys/renderer/native-bridge.ts @@ -0,0 +1,57 @@ +import { ClerkWebAuthnError } from '@clerk/shared/error'; +import type { + CredentialReturn, + PublicKeyCredentialCreationOptionsWithoutExtensions, + PublicKeyCredentialRequestOptionsWithoutExtensions, + PublicKeyCredentialWithAuthenticatorAssertionResponse, + PublicKeyCredentialWithAuthenticatorAttestationResponse, +} from '@clerk/shared/types'; + +import type { PasskeyBridge } from '../../shared/types'; +import { mapPasskeyIpcError } from '../shared/errors'; +import { + deserializeCreationResponse, + deserializeRequestResponse, + serializeCreationOptions, + serializeRequestOptions, +} from '../shared/serialization'; + +export function getPasskeyBridge(): PasskeyBridge | undefined { + return typeof window !== 'undefined' ? window.__clerk_internal_electron_passkeys : undefined; +} + +const bridgeMissingError = () => + new ClerkWebAuthnError( + 'Clerk: The native passkey bridge is not available. Call setupPasskeysPreload() in your preload script and setupPasskeysMain() in the main process.', + { code: 'passkey_not_supported' }, + ); + +export async function nativeCreateCredential( + publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions, +): Promise> { + const bridge = getPasskeyBridge(); + if (!bridge) { + return { publicKeyCredential: null, error: bridgeMissingError() }; + } + + const result = await bridge.create(serializeCreationOptions(publicKey)); + if (!result.ok) { + return { publicKeyCredential: null, error: mapPasskeyIpcError(result.error, 'create') }; + } + return { publicKeyCredential: deserializeCreationResponse(result.credential), error: null }; +} + +export async function nativeGetCredential( + publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions, +): Promise> { + const bridge = getPasskeyBridge(); + if (!bridge) { + return { publicKeyCredential: null, error: bridgeMissingError() }; + } + + const result = await bridge.get(serializeRequestOptions(publicKeyOptions)); + if (!result.ok) { + return { publicKeyCredential: null, error: mapPasskeyIpcError(result.error, 'get') }; + } + return { publicKeyCredential: deserializeRequestResponse(result.credential), error: null }; +} diff --git a/packages/electron/src/passkeys/renderer/strategy.ts b/packages/electron/src/passkeys/renderer/strategy.ts new file mode 100644 index 00000000000..8e259fe8ae2 --- /dev/null +++ b/packages/electron/src/passkeys/renderer/strategy.ts @@ -0,0 +1,40 @@ +export type PasskeyMode = 'auto' | 'renderer' | 'native'; +export type PasskeyPath = 'renderer' | 'native' | 'unsupported'; + +export type StrategyEnv = { + protocol: string; + hostname: string; + hasWebAuthn: boolean; + nativeAvailable: boolean; + platform: string; + electronMajor: number; +}; + +export function originSatisfiesRpId(env: Pick, rpId: string): boolean { + if (env.protocol !== 'https:' || !rpId) { + return false; + } + return env.hostname === rpId || env.hostname.endsWith(`.${rpId}`); +} + +/** + * Prefer Chromium WebAuthn when the page origin can satisfy the RP ID. + * Local bundles and older macOS Electron builds use the native bridge when available. + */ +export function decidePath(rpId: string, mode: PasskeyMode, env: StrategyEnv): PasskeyPath { + if (mode === 'renderer') { + return env.hasWebAuthn ? 'renderer' : 'unsupported'; + } + if (mode === 'native') { + return env.nativeAvailable ? 'native' : 'unsupported'; + } + + if (env.hasWebAuthn && originSatisfiesRpId(env, rpId)) { + if (env.platform === 'darwin' && env.electronMajor > 0 && env.electronMajor < 42 && env.nativeAvailable) { + return 'native'; + } + return 'renderer'; + } + + return env.nativeAvailable ? 'native' : 'unsupported'; +} diff --git a/packages/electron/src/passkeys/shared/errors.ts b/packages/electron/src/passkeys/shared/errors.ts new file mode 100644 index 00000000000..bfcaf5385fb --- /dev/null +++ b/packages/electron/src/passkeys/shared/errors.ts @@ -0,0 +1,34 @@ +import { ClerkWebAuthnError } from '@clerk/shared/error'; + +import type { PasskeyNativeErrorCode } from '../../shared/types'; + +const RP_ID_DOCS_URL = 'https://clerk.com/docs/deployments/overview#authentication-across-subdomains'; + +/** Maps native bridge errors to clerk-js WebAuthn errors. */ +export function mapPasskeyIpcError( + error: { code: PasskeyNativeErrorCode; message: string }, + action: 'create' | 'get', +): ClerkWebAuthnError { + const { code, message } = error; + + switch (code) { + case 'cancelled': + return new ClerkWebAuthnError(message, { + code: action === 'create' ? 'passkey_registration_cancelled' : 'passkey_retrieval_cancelled', + }); + case 'invalid_rp': + return new ClerkWebAuthnError(message, { + code: 'passkey_invalid_rpID_or_domain', + docsUrl: RP_ID_DOCS_URL, + }); + case 'timeout': + return new ClerkWebAuthnError(message, { code: 'passkey_operation_aborted' }); + case 'not_supported': + return new ClerkWebAuthnError(message, { code: 'passkey_not_supported' }); + case 'unknown': + default: + return new ClerkWebAuthnError(message, { + code: action === 'create' ? 'passkey_registration_failed' : 'passkey_retrieval_failed', + }); + } +} diff --git a/packages/electron/src/passkeys/shared/serialization.ts b/packages/electron/src/passkeys/shared/serialization.ts new file mode 100644 index 00000000000..bfcd723a6e1 --- /dev/null +++ b/packages/electron/src/passkeys/shared/serialization.ts @@ -0,0 +1,101 @@ +import { base64UrlToBuffer, bufferToBase64Url } from '@clerk/shared/internal/clerk-js/passkeys'; +import type { + PublicKeyCredentialCreationOptionsWithoutExtensions, + PublicKeyCredentialRequestOptionsWithoutExtensions, + PublicKeyCredentialWithAuthenticatorAssertionResponse, + PublicKeyCredentialWithAuthenticatorAttestationResponse, +} from '@clerk/shared/types'; + +import type { + AuthenticationResponseJSON, + AuthenticatorTransport, + RegistrationResponseJSON, + SerializedPublicKeyCredentialCreationOptions, + SerializedPublicKeyCredentialRequestOptions, +} from '../../shared/types'; + +function toArrayBuffer(bufferSource: BufferSource): ArrayBuffer { + if (bufferSource instanceof ArrayBuffer) { + return bufferSource; + } + // Copy the view into a fresh ArrayBuffer; .buffer may be a SharedArrayBuffer. + const view = new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength); + const copy = new ArrayBuffer(view.byteLength); + new Uint8Array(copy).set(view); + return copy; +} + +const encode = (bufferSource: BufferSource) => bufferToBase64Url(toArrayBuffer(bufferSource)); + +export function serializeCreationOptions( + publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions, +): SerializedPublicKeyCredentialCreationOptions { + return { + rp: { id: publicKey.rp.id ?? '', name: publicKey.rp.name }, + user: { + id: encode(publicKey.user.id), + displayName: publicKey.user.displayName, + name: publicKey.user.name, + }, + challenge: encode(publicKey.challenge), + pubKeyCredParams: publicKey.pubKeyCredParams.map(p => ({ type: 'public-key', alg: p.alg })), + timeout: publicKey.timeout, + authenticatorSelection: publicKey.authenticatorSelection, + attestation: publicKey.attestation, + excludeCredentials: (publicKey.excludeCredentials ?? []).map(c => ({ + type: 'public-key', + id: encode(c.id), + transports: c.transports as AuthenticatorTransport[] | undefined, + })), + }; +} + +export function serializeRequestOptions( + publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions, +): SerializedPublicKeyCredentialRequestOptions { + return { + challenge: encode(publicKeyOptions.challenge), + rpId: publicKeyOptions.rpId ?? '', + timeout: publicKeyOptions.timeout, + userVerification: publicKeyOptions.userVerification, + allowCredentials: (publicKeyOptions.allowCredentials ?? []).map(c => ({ + type: 'public-key', + id: encode(c.id), + })), + }; +} + +export function deserializeCreationResponse( + credential: RegistrationResponseJSON, +): PublicKeyCredentialWithAuthenticatorAttestationResponse & { toJSON: () => RegistrationResponseJSON } { + return { + id: credential.id, + rawId: base64UrlToBuffer(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment ?? null, + response: { + clientDataJSON: base64UrlToBuffer(credential.response.clientDataJSON), + attestationObject: base64UrlToBuffer(credential.response.attestationObject), + getTransports: () => credential.response.transports ?? [], + }, + toJSON: () => credential, + }; +} + +export function deserializeRequestResponse( + credential: AuthenticationResponseJSON, +): PublicKeyCredentialWithAuthenticatorAssertionResponse & { toJSON: () => AuthenticationResponseJSON } { + return { + id: credential.id, + rawId: base64UrlToBuffer(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment ?? null, + response: { + clientDataJSON: base64UrlToBuffer(credential.response.clientDataJSON), + authenticatorData: base64UrlToBuffer(credential.response.authenticatorData), + signature: base64UrlToBuffer(credential.response.signature), + userHandle: credential.response.userHandle ? base64UrlToBuffer(credential.response.userHandle) : null, + }, + toJSON: () => credential, + } as PublicKeyCredentialWithAuthenticatorAssertionResponse & { toJSON: () => AuthenticationResponseJSON }; +} diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index 8b9903b1c4d..d0530139a3a 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer } from 'electron'; import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; import type { TokenCache } from '../shared/types'; +export { setupPasskeysPreload } from '../passkeys/preload'; + export function setupPreload(): void { const tokenCache: TokenCache = { getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts index e50db4bb9c3..05e834a2980 100644 --- a/packages/electron/src/shared/ipc.ts +++ b/packages/electron/src/shared/ipc.ts @@ -3,3 +3,9 @@ export const TOKEN_CACHE_CHANNELS = { saveToken: 'clerk:token-cache:save', clearToken: 'clerk:token-cache:clear', } as const; + +export const PASSKEY_CHANNELS = { + create: 'clerk:passkeys:create', + get: 'clerk:passkeys:get', + capabilities: 'clerk:passkeys:capabilities', +} as const; diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts index 9c51778cc28..ba79265fcde 100644 --- a/packages/electron/src/shared/types.ts +++ b/packages/electron/src/shared/types.ts @@ -19,3 +19,95 @@ export type TokenCache = { saveToken: (key: string, value: string) => Promise; clearToken: (key: string) => Promise; }; + +// JSON-safe WebAuthn shapes passed over Electron IPC. Binary fields are base64url strings. +type Base64UrlString = string; + +export type AuthenticatorTransport = 'ble' | 'cable' | 'hybrid' | 'internal' | 'nfc' | 'smart-card' | 'usb'; + +export type PublicKeyCredentialDescriptorJSON = { + type: 'public-key'; + id: Base64UrlString; + transports?: AuthenticatorTransport[]; +}; + +export type SerializedPublicKeyCredentialCreationOptions = { + rp: { id: string; name: string }; + user: { + id: Base64UrlString; + displayName: string; + name: string; + }; + challenge: Base64UrlString; + pubKeyCredParams: { type: 'public-key'; alg: number }[]; + timeout?: number; + authenticatorSelection?: { + authenticatorAttachment?: 'cross-platform' | 'platform'; + requireResidentKey?: boolean; + residentKey?: 'discouraged' | 'preferred' | 'required'; + userVerification?: 'discouraged' | 'preferred' | 'required'; + }; + attestation?: 'direct' | 'enterprise' | 'indirect' | 'none'; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; +}; + +export type SerializedPublicKeyCredentialRequestOptions = { + challenge: Base64UrlString; + rpId: string; + timeout?: number; + userVerification?: 'discouraged' | 'preferred' | 'required'; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; +}; + +export type RegistrationResponseJSON = { + id: Base64UrlString; + rawId: Base64UrlString; + response: { + clientDataJSON: Base64UrlString; + attestationObject: Base64UrlString; + transports?: AuthenticatorTransport[]; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + type: 'public-key'; +}; + +export type AuthenticationResponseJSON = { + id: Base64UrlString; + rawId: Base64UrlString; + response: { + clientDataJSON: Base64UrlString; + authenticatorData: Base64UrlString; + signature: Base64UrlString; + userHandle?: Base64UrlString; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + type: 'public-key'; +}; + +// Native error codes that the renderer maps to ClerkWebAuthnError codes. +export type PasskeyNativeErrorCode = 'cancelled' | 'invalid_rp' | 'not_supported' | 'timeout' | 'unknown'; + +// Keep errors inside the response; Electron loses structure when invoke promises reject. +export type PasskeyIpcResult = + | { ok: true; credential: T } + | { ok: false; error: { code: PasskeyNativeErrorCode; message: string } }; + +export type PasskeyCapabilities = { + available: boolean; + platformAuthenticator: boolean; + securityKeys: boolean; +}; + +export type PasskeyBridge = { + create: ( + options: SerializedPublicKeyCredentialCreationOptions, + ) => Promise>; + get: (options: SerializedPublicKeyCredentialRequestOptions) => Promise>; + capabilities: () => Promise; + electronMajor: number; + platform: string; +}; + +export type SetupPasskeysMainReturn = { + cleanup: () => void; +}; diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts index 5a598814cb3..d2cf9cb5eee 100644 --- a/packages/electron/tsup.config.ts +++ b/packages/electron/tsup.config.ts @@ -9,8 +9,9 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'], + entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts', './src/passkeys/index.ts'], bundle: true, + external: ['@clerk/electron-passkeys'], clean: true, minify: false, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccafc316a40..7a8bbeaa344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,10 +514,16 @@ importers: packages/electron: dependencies: + '@clerk/shared': + specifier: workspace:^ + version: link:../shared tslib: specifier: catalog:repo version: 2.8.1 devDependencies: + '@clerk/electron-passkeys': + specifier: workspace:* + version: link:../electron-passkeys '@types/node': specifier: ^22.19.17 version: 22.19.17 @@ -528,6 +534,33 @@ importers: specifier: ^8.2.0 version: 8.2.0 + packages/electron-passkeys: + devDependencies: + '@napi-rs/cli': + specifier: ^3.0.0 + version: 3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.0)(node-addon-api@7.1.1) + optionalDependencies: + '@clerk/electron-passkeys-darwin-arm64': + specifier: workspace:* + version: link:npm/darwin-arm64 + '@clerk/electron-passkeys-darwin-x64': + specifier: workspace:* + version: link:npm/darwin-x64 + '@clerk/electron-passkeys-win32-arm64-msvc': + specifier: workspace:* + version: link:npm/win32-arm64-msvc + '@clerk/electron-passkeys-win32-x64-msvc': + specifier: workspace:* + version: link:npm/win32-x64-msvc + + packages/electron-passkeys/npm/darwin-arm64: {} + + packages/electron-passkeys/npm/darwin-x64: {} + + packages/electron-passkeys/npm/win32-arm64-msvc: {} + + packages/electron-passkeys/npm/win32-x64-msvc: {} + packages/expo: dependencies: '@clerk/clerk-js': @@ -2721,7 +2754,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -3143,6 +3176,19 @@ packages: resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/confirm@6.0.12': resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -3152,6 +3198,15 @@ packages: '@types/node': optional: true + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@11.1.9': resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -3161,6 +3216,33 @@ packages: '@types/node': optional: true + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -3170,10 +3252,86 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@2.0.5': resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/type@4.0.5': resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -3183,6 +3341,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -3404,6 +3571,268 @@ packages: resolution: {integrity: sha512-D0nkS5+sx87mYpxFqASImCineYoEl9wGlUPrzkuS0ohzG8wfophLpE+I76qGJ0slLAVI19do5SI9pWJNCVf4fg==} engines: {node: '>=18'} + '@napi-rs/cli@3.7.0': + resolution: {integrity: sha512-3d3+rmxlOIV/G1zPWeX4PCxuYnhcCQM2BvY9rtimC8RO0dFR9gtYP+Grov+WoduZtfWRj5N1XvytWeRxxCk5zw==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + '@emnapi/runtime': ^1.7.1 + peerDependenciesMeta: + '@emnapi/runtime': + optional: true + + '@napi-rs/cross-toolchain@1.0.3': + resolution: {integrity: sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==} + peerDependencies: + '@napi-rs/cross-toolchain-arm64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-x86_64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-x86_64': ^1.0.3 + peerDependenciesMeta: + '@napi-rs/cross-toolchain-arm64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-arm64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-arm64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-arm64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-arm64-target-x86_64': + optional: true + '@napi-rs/cross-toolchain-x64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-x64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-x64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-x64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-x64-target-x86_64': + optional: true + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.4.5': + resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.4.5': + resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.4.5': + resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.4.5': + resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.4.5': + resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} + engines: {node: '>= 10'} + + '@napi-rs/tar-android-arm-eabi@1.1.0': + resolution: {integrity: sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/tar-android-arm64@1.1.0': + resolution: {integrity: sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/tar-darwin-arm64@1.1.0': + resolution: {integrity: sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/tar-darwin-x64@1.1.0': + resolution: {integrity: sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/tar-freebsd-x64@1.1.0': + resolution: {integrity: sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + resolution: {integrity: sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + resolution: {integrity: sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + resolution: {integrity: sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + resolution: {integrity: sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + resolution: {integrity: sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + resolution: {integrity: sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-musl@1.1.0': + resolution: {integrity: sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-wasm32-wasi@1.1.0': + resolution: {integrity: sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + resolution: {integrity: sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + resolution: {integrity: sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + resolution: {integrity: sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/tar@1.1.0': + resolution: {integrity: sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3416,6 +3845,91 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + resolution: {integrity: sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + resolution: {integrity: sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + resolution: {integrity: sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + resolution: {integrity: sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + resolution: {integrity: sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/wasm-tools@1.0.1': + resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==} + engines: {node: '>= 10'} + '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} @@ -3602,10 +4116,22 @@ packages: resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} engines: {node: '>= 18'} + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + '@octokit/core@5.2.2': resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} engines: {node: '>= 18'} + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + '@octokit/endpoint@9.0.6': resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} engines: {node: '>= 18'} @@ -3614,31 +4140,64 @@ packages: resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} engines: {node: '>= 18'} + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + '@octokit/openapi-types@24.2.0': resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-request-log@4.0.1': resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': ^5 + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/request-error@5.1.1': resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} engines: {node: '>= 18'} + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.10': + resolution: {integrity: sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==} + engines: {node: '>= 20'} + '@octokit/request@8.4.1': resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} engines: {node: '>= 18'} @@ -3647,9 +4206,16 @@ packages: resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} engines: {node: '>= 18'} + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} @@ -6631,6 +7197,9 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -7033,6 +7602,11 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -7240,6 +7814,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + conventional-changelog-angular@8.3.1: resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} engines: {node: '>=18'} @@ -7913,6 +8491,14 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + emnapi@1.10.0: + resolution: {integrity: sha512-swoyZjupDvLoe/KC3HZ4SY1JUN+tviT6eOZ3Px28TZAYdBHtRIiMWWrIUUH+2/9CYY4fNTID1YhYZ+kdFHszHg==} + peerDependencies: + node-addon-api: '>= 6.1.0' + peerDependenciesMeta: + node-addon-api: + optional: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -10005,6 +10591,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -13681,6 +14270,9 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -13945,6 +14537,9 @@ packages: universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -17448,6 +18043,17 @@ snapshots: '@inquirer/ansi@2.0.5': {} + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + '@inquirer/confirm@6.0.12(@types/node@22.19.17)': dependencies: '@inquirer/core': 11.1.9(@types/node@22.19.17) @@ -17463,6 +18069,13 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/confirm@6.1.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + '@inquirer/core@11.1.9(@types/node@22.19.17)': dependencies: '@inquirer/ansi': 2.0.5 @@ -17488,6 +18101,33 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/core@11.2.1(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.6.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/editor@5.2.2(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/external-editor': 3.0.3(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/expand@5.1.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + '@inquirer/external-editor@1.0.3(@types/node@22.19.17)': dependencies: chardet: 2.1.1 @@ -17495,8 +18135,78 @@ snapshots: optionalDependencies: '@types/node': 22.19.17 + '@inquirer/external-editor@3.0.3(@types/node@25.6.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.6.0 + '@inquirer/figures@2.0.5': {} + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/number@4.1.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/password@5.1.1(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/prompts@8.5.2(@types/node@25.6.0)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@25.6.0) + '@inquirer/confirm': 6.1.1(@types/node@25.6.0) + '@inquirer/editor': 5.2.2(@types/node@25.6.0) + '@inquirer/expand': 5.1.1(@types/node@25.6.0) + '@inquirer/input': 5.1.2(@types/node@25.6.0) + '@inquirer/number': 4.1.1(@types/node@25.6.0) + '@inquirer/password': 5.1.1(@types/node@25.6.0) + '@inquirer/rawlist': 5.3.1(@types/node@25.6.0) + '@inquirer/search': 4.2.1(@types/node@25.6.0) + '@inquirer/select': 5.2.1(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/rawlist@5.3.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/search@4.2.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/select@5.2.1(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.6.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + '@inquirer/type@4.0.5(@types/node@22.19.17)': optionalDependencies: '@types/node': 22.19.17 @@ -17506,6 +18216,10 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/type@4.0.7(@types/node@25.6.0)': + optionalDependencies: + '@types/node': 25.6.0 + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -17821,6 +18535,202 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/cli@3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.0)(node-addon-api@7.1.1)': + dependencies: + '@inquirer/prompts': 8.5.2(@types/node@25.6.0) + '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@octokit/rest': 22.0.1 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + colorette: 2.0.20 + emnapi: 1.10.0(node-addon-api@7.1.1) + es-toolkit: 1.46.1 + js-yaml: 4.1.1 + obug: 2.1.1 + semver: 7.7.4 + typanion: 3.14.0 + optionalDependencies: + '@emnapi/runtime': 1.10.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@napi-rs/cross-toolchain-arm64-target-aarch64' + - '@napi-rs/cross-toolchain-arm64-target-armv7' + - '@napi-rs/cross-toolchain-arm64-target-ppc64le' + - '@napi-rs/cross-toolchain-arm64-target-s390x' + - '@napi-rs/cross-toolchain-arm64-target-x86_64' + - '@napi-rs/cross-toolchain-x64-target-aarch64' + - '@napi-rs/cross-toolchain-x64-target-armv7' + - '@napi-rs/cross-toolchain-x64-target-ppc64le' + - '@napi-rs/cross-toolchain-x64-target-s390x' + - '@napi-rs/cross-toolchain-x64-target-x86_64' + - '@types/node' + - node-addon-api + - supports-color + + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/tar': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - supports-color + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + optional: true + + '@napi-rs/lzma-android-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-x64@1.4.5': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@napi-rs/tar-android-arm-eabi@1.1.0': + optional: true + + '@napi-rs/tar-android-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-x64@1.1.0': + optional: true + + '@napi-rs/tar-freebsd-x64@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + optional: true + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-musl@1.1.0': + optional: true + + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + optional: true + + '@napi-rs/tar@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + optionalDependencies: + '@napi-rs/tar-android-arm-eabi': 1.1.0 + '@napi-rs/tar-android-arm64': 1.1.0 + '@napi-rs/tar-darwin-arm64': 1.1.0 + '@napi-rs/tar-darwin-x64': 1.1.0 + '@napi-rs/tar-freebsd-x64': 1.1.0 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.0 + '@napi-rs/tar-linux-arm64-gnu': 1.1.0 + '@napi-rs/tar-linux-arm64-musl': 1.1.0 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.0 + '@napi-rs/tar-linux-s390x-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-musl': 1.1.0 + '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/tar-win32-arm64-msvc': 1.1.0 + '@napi-rs/tar-win32-ia32-msvc': 1.1.0 + '@napi-rs/tar-win32-x64-msvc': 1.1.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -17842,6 +18752,69 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + optional: true + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + optionalDependencies: + '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 + '@napi-rs/wasm-tools-android-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-x64': 1.0.1 + '@napi-rs/wasm-tools-freebsd-x64': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@next/env@15.5.18': {} '@next/swc-darwin-arm64@15.5.18': @@ -18205,6 +19178,8 @@ snapshots: '@octokit/auth-token@4.0.0': {} + '@octokit/auth-token@6.0.0': {} + '@octokit/core@5.2.2': dependencies: '@octokit/auth-token': 4.0.0 @@ -18215,6 +19190,21 @@ snapshots: before-after-hook: 2.2.3 universal-user-agent: 6.0.1 + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.10 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/endpoint@9.0.6': dependencies: '@octokit/types': 13.10.0 @@ -18226,28 +19216,63 @@ snapshots: '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.10 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/openapi-types@24.2.0': {} + '@octokit/openapi-types@27.0.0': {} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/request-error@5.1.1': dependencies: '@octokit/types': 13.10.0 deprecation: 2.3.1 once: 1.4.0 + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.10': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + content-type: 2.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + '@octokit/request@8.4.1': dependencies: '@octokit/endpoint': 9.0.6 @@ -18262,10 +19287,21 @@ snapshots: '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/types@13.10.0': dependencies: '@octokit/openapi-types': 24.2.0 + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@one-ini/wasm@0.1.1': {} '@oozcitak/dom@2.0.2': @@ -21825,6 +22861,8 @@ snapshots: before-after-hook@2.2.3: {} + before-after-hook@4.0.0: {} + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -22315,6 +23353,10 @@ snapshots: client-only@0.0.1: {} + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -22508,6 +23550,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + conventional-changelog-angular@8.3.1: dependencies: compare-func: 2.0.0 @@ -23205,6 +24249,10 @@ snapshots: transitivePeerDependencies: - supports-color + emnapi@1.10.0(node-addon-api@7.1.1): + optionalDependencies: + node-addon-api: 7.1.1 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -25825,6 +26873,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.8: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -30448,6 +31498,8 @@ snapshots: tweetnacl@0.14.5: {} + typanion@3.14.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -30734,6 +31786,8 @@ snapshots: universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3cdc3b057d..55e8aa4ce64 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - packages/electron-passkeys/npm/* catalogs: peer-react: From cbb1530a90ec5f523aac659eb837a7f9b2d1b0a9 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 10 Jun 2026 23:02:57 +0400 Subject: [PATCH 11/19] docs(electron): document passkey setup --- .../workflows/electron-passkeys.yml.template | 105 ++++++++++++++++++ packages/electron/README.md | 63 ++++++++++- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/electron-passkeys.yml.template diff --git a/.github/workflows/electron-passkeys.yml.template b/.github/workflows/electron-passkeys.yml.template new file mode 100644 index 00000000000..72b92be065b --- /dev/null +++ b/.github/workflows/electron-passkeys.yml.template @@ -0,0 +1,105 @@ +name: Electron Passkeys Native Build + +on: + workflow_dispatch: + pull_request: + paths: + - 'packages/electron-passkeys/**' + - '.github/workflows/electron-passkeys.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.host }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + settings: + - host: macos-14 + target: aarch64-apple-darwin + - host: macos-14 + target: x86_64-apple-darwin + - host: windows-latest + target: x86_64-pc-windows-msvc + - host: windows-latest + target: aarch64-pc-windows-msvc + defaults: + run: + working-directory: packages/electron-passkeys + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: pnpm/action-setup@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.settings.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/electron-passkeys + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + working-directory: . + + - name: Build native module + run: pnpm build --target ${{ matrix.settings.target }} + + - name: Smoke test (host-native targets only) + if: matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' + run: node -e "const m = require('./index.js'); console.log('isAvailable:', m.isAvailable(), 'capabilities:', JSON.stringify(m.capabilities()))" + + - uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.settings.target }} + path: packages/electron-passkeys/electron-passkeys.*.node + if-no-files-found: error + + package: + name: Assemble platform packages + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: packages/electron-passkeys + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + working-directory: . + + - uses: actions/download-artifact@v4 + with: + path: packages/electron-passkeys/artifacts + + - name: Move binaries into per-platform npm packages + run: pnpm artifacts + + - name: Verify every platform package contains its binary + run: | + for dir in npm/*/; do + count=$(find "$dir" -name '*.node' | wc -l) + if [ "$count" -ne 1 ]; then + echo "::error::$dir is missing its .node binary — publishing it would ship an empty package" + exit 1 + fi + (cd "$dir" && npm pack --dry-run) + done diff --git a/packages/electron/README.md b/packages/electron/README.md index 65101286b3e..6ce11006fd2 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -31,10 +31,71 @@ > [!WARNING] > `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change. -This package exposes two entrypoints, targeting Electron's distinct runtime contexts: +This package exposes entrypoints targeting Electron's distinct runtime contexts: - `@clerk/electron` — for use in the Electron **main** process. - `@clerk/electron/preload` — for use in Electron **preload** scripts. +- `@clerk/electron/storage` — secure, OS-encrypted token storage for the main process. +- `@clerk/electron/passkeys` — passkey (WebAuthn) support for the **renderer** process. + +## Passkeys + +Passkey support works in two modes, selected automatically per request: + +- **Renderer mode** — when your window loads content over `https://` from an origin that matches your passkey RP 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)). +- **Native mode** — when your window loads a local bundle (`file://` or a custom protocol), 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. + +### Setup + +Native mode requires the optional native module: + +```sh +pnpm add @clerk/electron-passkeys +``` + +```ts +// main process +import { setupMain, setupPasskeysMain } from '@clerk/electron'; +import { storage } from '@clerk/electron/storage'; + +setupMain({ storage: storage() }); +setupPasskeysMain(); +``` + +```ts +// preload script +import { setupPreload, setupPasskeysPreload } from '@clerk/electron/preload'; + +setupPreload(); +setupPasskeysPreload(); +``` + +```ts +// renderer process +import { Clerk } from '@clerk/clerk-js'; +import { createPasskeyProvider } from '@clerk/electron/passkeys'; + +const clerk = new Clerk(publishableKey); +createPasskeyProvider(clerk); // optionally { mode: 'auto' | 'renderer' | 'native' } +await clerk.load(); +``` + +### macOS requirements for native mode + +Like passkeys on iOS, the macOS platform APIs require a verified association between your app and your domain: + +1. Serve an `apple-app-site-association` file from `https:///.well-known/apple-app-site-association` (https, no redirect, `application/json`) listing your app: `{"webcredentials": {"apps": ["."]}}`. The RP domain must be publicly reachable — Apple's CDN fetches it. +2. Sign your app with `com.apple.developer.associated-domains` containing `webcredentials:`. This is a _restricted_ entitlement: the build must embed a provisioning profile with the Associated Domains capability for the bundle ID, and the entitlements must also include `com.apple.application-identifier` and `com.apple.developer.team-identifier` matching the profile. + +Hard-won development-build checklist (each of these failure modes produces the same opaque "not associated with domain" error): + +- Sign with an **Apple Development** identity (`mac.type: development` in electron-builder) and a **macOS App Development** profile that includes your Mac; also install the profile on the machine (`~/Library/Developer/Xcode/UserData/Provisioning Profiles/.provisionprofile`). +- Copy `.app` bundles with `ditto`, never `cp -R` — `cp` breaks the bundle seal, and macOS silently ignores the entitlements of an app whose signature fails `codesign --verify --deep --strict`. +- The system registers the domain association via `swcd` when the app launches; verify with `sudo swcutil show`. If state gets stuck, `sudo swcutil reset` and relaunch. +- Prefer the default (production/CDN) association route. `?mode=developer` + `sudo swcutil developer-mode -e true` exists but is flaky in practice. +- The system log tells the truth: `log stream --predicate 'process == "swcd" OR composedMessage CONTAINS "your.domain"'` while launching, and look for `taskgated-helper: allowing entitlement(s) ... due to provisioning profile`. + +Windows has no equivalent requirement. On Linux there is no native path; passkeys work in renderer mode only (including external security keys). ## Support From 1c0d40c28a8ca8228613c65835e091f0b145f034 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 10 Jun 2026 13:51:04 -0700 Subject: [PATCH 12/19] chore: Add custom renderer origin support (#8816) --- packages/electron/README.md | 26 ++++++++++ packages/electron/src/index.ts | 1 - .../src/main/__tests__/setup-main.test.ts | 52 ++++++++++++++++++- packages/electron/src/main/setup-main.ts | 33 ++++++++++++ packages/electron/src/shared/types.ts | 15 ++++++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/electron/README.md b/packages/electron/README.md index f1306339367..08bbb31c2bd 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -40,14 +40,40 @@ This package exposes entrypoints for Electron's distinct runtime contexts: ```ts // main.ts +import { app, BrowserWindow, net, protocol } from 'electron'; import { setupMain } from '@clerk/electron'; import { storage } from '@clerk/electron/storage'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; setupMain({ storage: storage(), + renderer: { + scheme: 'my-app', + host: 'renderer', + }, +}); + +app.whenReady().then(() => { + protocol.handle('my-app', request => { + const url = new URL(request.url); + const file = url.pathname === '/' ? 'index.html' : url.pathname; + + return net.fetch(pathToFileURL(join(__dirname, '../renderer', file)).toString()); + }); + + const win = new BrowserWindow({ + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + }, + }); + + win.loadURL('my-app://renderer/'); }); ``` +In `my-app://renderer/sign-in`, `my-app` is the scheme, `renderer` is the host, `my-app://renderer` is the origin, and `/sign-in` is the path. If your renderer uses path-based routing, serve every route from the same origin and fall back to your renderer entrypoint as needed. + ```tsx // renderer.tsx import { ClerkProvider } from '@clerk/electron/react'; diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 80aa04dd58e..ea95302fb59 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,2 +1 @@ export { setupMain } from './main/setup-main'; -export type { TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts index c1f90c90677..32701055806 100644 --- a/packages/electron/src/main/__tests__/setup-main.test.ts +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, protocol } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TokenStorage } from '../../shared/types'; @@ -9,6 +9,9 @@ vi.mock('electron', () => ({ handle: vi.fn(), removeHandler: vi.fn(), }, + protocol: { + registerSchemesAsPrivileged: vi.fn(), + }, })); describe('setupMain', () => { @@ -33,6 +36,53 @@ describe('setupMain', () => { expect(ipcMain.handle).toHaveBeenCalledTimes(3); }); + it('registers the configured renderer scheme as privileged before app ready', () => { + setupMain({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app', + }, + }); + + expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([ + { + scheme: 'my-app', + privileges: { + corsEnabled: true, + secure: true, + standard: true, + stream: true, + supportFetchAPI: true, + }, + }, + ]); + }); + + it('requires renderer.scheme to be a scheme name, not a URL', () => { + expect(() => + setupMain({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app://', + }, + }), + ).toThrow('renderer.scheme must be a scheme name'); + }); + + it('requires renderer.host to be a host name, not an origin', () => { + expect(() => + setupMain({ + storage, + renderer: { + host: 'my-app://renderer', + scheme: 'my-app', + }, + }), + ).toThrow('renderer.host must be a host name'); + }); + it('returns a cleanup function for registered handlers', () => { const clerk = setupMain({ storage }); diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts index 64c1853f2d8..aef67fda011 100644 --- a/packages/electron/src/main/setup-main.ts +++ b/packages/electron/src/main/setup-main.ts @@ -1,6 +1,22 @@ +import { protocol } from 'electron'; + import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; +function assertValidRendererOriginConfig(renderer: NonNullable): void { + if (renderer.scheme.includes(':') || renderer.scheme.includes('/')) { + throw new Error( + 'Clerk: renderer.scheme must be a scheme name like "my-app", not a URL or protocol like "my-app://".', + ); + } + + if (renderer.host.includes(':') || renderer.host.includes('/')) { + throw new Error( + 'Clerk: renderer.host must be a host name like "renderer", not a URL or origin like "my-app://renderer".', + ); + } +} + export function setupMain(options: SetupMainOptions): SetupMainReturn { if (!options.storage) { throw new Error( @@ -10,6 +26,23 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + if (options.renderer) { + assertValidRendererOriginConfig(options.renderer); + + protocol.registerSchemesAsPrivileged([ + { + scheme: options.renderer.scheme, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, + ]); + } + return { cleanup() { cleanupTokenPersistence(); diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts index 9c51778cc28..14aea2c2a70 100644 --- a/packages/electron/src/shared/types.ts +++ b/packages/electron/src/shared/types.ts @@ -8,12 +8,27 @@ export type TokenStorage = { export type SetupMainOptions = { storage: TokenStorage; + /** + * Registers the custom scheme used to serve the Electron renderer from a stable origin. + */ + renderer?: RendererSchemeOptions; }; export type SetupMainReturn = { cleanup: () => void; }; +export type RendererSchemeOptions = { + /** + * Custom scheme used for the renderer origin. + */ + scheme: string; + /** + * Custom host used for the renderer origin. + */ + host: string; +}; + export type TokenCache = { getToken: (key: string) => Promise; saveToken: (key: string, value: string) => Promise; From abef47caf6536b15b6b6b25479272d1040b23319 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 10 Jun 2026 14:00:57 -0700 Subject: [PATCH 13/19] chore: dedupe --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74f44f28c86..4f42f04b13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,9 @@ importers: '@clerk/react': specifier: workspace:^ version: link:../react + '@clerk/shared': + specifier: workspace:^ + version: link:../shared '@clerk/ui': specifier: workspace:^ version: link:../ui @@ -520,17 +523,14 @@ importers: '@clerk/react': specifier: workspace:^ version: link:../react - '@clerk/shared': - specifier: workspace:^ - version: link:../shared '@clerk/ui': specifier: workspace:^ version: link:../ui react: - specifier: catalog:peer-react + specifier: 18.3.1 version: 18.3.1 react-dom: - specifier: catalog:peer-react + specifier: 18.3.1 version: 18.3.1(react@18.3.1) tslib: specifier: catalog:repo From 56af1b0d23c8fd9df2a5c47cd6fe567d6369be80 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 12 Jun 2026 01:51:34 +0400 Subject: [PATCH 14/19] feat(electron): add passkey support --- .../workflows/electron-passkeys.yml.template | 2 +- packages/electron-passkeys/Cargo.toml | 7 ++ packages/electron-passkeys/README.md | 2 +- packages/electron-passkeys/index.js | 6 +- packages/electron-passkeys/src/macos.rs | 64 +++++++++++++++++-- packages/electron/README.md | 29 ++++++--- packages/electron/src/index.ts | 3 +- .../main/__tests__/passkey-handlers.test.ts | 13 +++- .../src/main/__tests__/setup-main.test.ts | 39 +++++++++++ .../electron/src/main/passkey-handlers.ts | 11 +++- packages/electron/src/main/setup-main.ts | 3 + packages/electron/src/passkeys/index.ts | 16 ++++- .../src/passkeys/renderer/native-bridge.ts | 2 +- .../src/passkeys/shared/serialization.ts | 2 +- .../src/preload/__tests__/index.test.ts | 20 ++++++ packages/electron/src/preload/index.ts | 11 ++-- .../react/__tests__/ClerkProvider.test.tsx | 60 +++++++++++++++++ .../src/react/create-clerk-instance.ts | 21 +++++- packages/electron/src/react/index.tsx | 17 ++++- packages/electron/src/shared/types.ts | 12 ++++ 20 files changed, 307 insertions(+), 33 deletions(-) diff --git a/.github/workflows/electron-passkeys.yml.template b/.github/workflows/electron-passkeys.yml.template index 72b92be065b..67d0d0bb1fc 100644 --- a/.github/workflows/electron-passkeys.yml.template +++ b/.github/workflows/electron-passkeys.yml.template @@ -57,7 +57,7 @@ jobs: - name: Smoke test (host-native targets only) if: matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' - run: node -e "const m = require('./index.js'); console.log('isAvailable:', m.isAvailable(), 'capabilities:', JSON.stringify(m.capabilities()))" + 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');" - uses: actions/upload-artifact@v4 with: diff --git a/packages/electron-passkeys/Cargo.toml b/packages/electron-passkeys/Cargo.toml index 4456b1e8046..c93298879b6 100644 --- a/packages/electron-passkeys/Cargo.toml +++ b/packages/electron-passkeys/Cargo.toml @@ -37,6 +37,7 @@ objc2-authentication-services = { version = "0.3", default-features = false, fea "std", "ASFoundation", "ASAuthorization", + "ASAuthorizationError", "ASAuthorizationController", "ASAuthorizationCredential", "ASAuthorizationRequest", @@ -55,6 +56,12 @@ objc2-authentication-services = { version = "0.3", default-features = false, fea "ASAuthorizationPublicKeyCredentialParameters", ] } +# NSDictionary is only needed to build NSError fixtures in unit tests. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-foundation = { version = "0.3", default-features = false, features = [ + "NSDictionary", +] } + [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = [ "Win32_Foundation", diff --git a/packages/electron-passkeys/README.md b/packages/electron-passkeys/README.md index f587add3f0d..23210838918 100644 --- a/packages/electron-passkeys/README.md +++ b/packages/electron-passkeys/README.md @@ -5,7 +5,7 @@ Native passkey (WebAuthn) support for [`@clerk/electron`](https://github.com/cle > [!WARNING] > This package is under active development and is not yet ready for production use. -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. +This package is a napi-rs native module loaded in the Electron **main** process by `setupMain({ passkeys: true })` from `@clerk/electron`. You should not need to call it directly. | Platform | Backend | Authenticators | | ---------------- | ---------------------------------------------------- | ---------------------------------------- | diff --git a/packages/electron-passkeys/index.js b/packages/electron-passkeys/index.js index 822950d12df..8a5019efb28 100644 --- a/packages/electron-passkeys/index.js +++ b/packages/electron-passkeys/index.js @@ -11,8 +11,10 @@ const PLATFORM_PACKAGES = { function loadNative() { const key = `${process.platform}-${process.arch}`; - // Local napi builds land next to this file. - const localBinary = join(__dirname, `electron-passkeys.${key}.node`); + // Local napi builds land next to this file; napi appends the ABI to the + // filename on Windows (e.g. electron-passkeys.win32-x64-msvc.node). + const localKey = process.platform === 'win32' ? `${key}-msvc` : key; + const localBinary = join(__dirname, `electron-passkeys.${localKey}.node`); if (existsSync(localBinary)) { return require(localBinary); } diff --git a/packages/electron-passkeys/src/macos.rs b/packages/electron-passkeys/src/macos.rs index a552a6797f3..0ed221576e7 100644 --- a/packages/electron-passkeys/src/macos.rs +++ b/packages/electron-passkeys/src/macos.rs @@ -15,7 +15,7 @@ use objc2::{ use objc2_app_kit::{NSView, NSWindow}; use objc2_authentication_services::{ ASAuthorization, ASAuthorizationController, ASAuthorizationControllerDelegate, - ASAuthorizationControllerPresentationContextProviding, + ASAuthorizationControllerPresentationContextProviding, ASAuthorizationError, ASAuthorizationErrorDomain, ASAuthorizationPlatformPublicKeyCredentialDescriptor, ASAuthorizationPlatformPublicKeyCredentialProvider, ASAuthorizationPublicKeyCredentialParameters, ASAuthorizationRequest, ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor, @@ -170,20 +170,21 @@ impl CeremonyDelegate { } fn map_nserror(error: &NSError) -> (String, String) { - let domain = error.domain().to_string(); - let code = error.code(); + let domain = error.domain(); + let code = ASAuthorizationError(error.code()); let message = error.localizedDescription().to_string(); let lowered = message.to_lowercase(); let mapped = if lowered.contains("timed out") || lowered.contains("timeout") { "timeout" - } else if domain == "ASAuthorizationError" { + } else if &*domain == unsafe { ASAuthorizationErrorDomain } { match code { - // ASAuthorizationError.canceled - 1001 => "cancelled", + ASAuthorizationError::Canceled => "cancelled", // ASAuthorizationError.failed also covers RP ID / associated-domain // mismatches, so use the localized description for classification. - 1004 if lowered.contains("not associated") || lowered.contains("associated domain") => { + ASAuthorizationError::Failed + if lowered.contains("not associated") || lowered.contains("associated domain") => + { "invalid_rp" } _ => "unknown", @@ -613,3 +614,52 @@ pub(crate) async fn create_credential(handle: usize, options: CreationOptions) - pub(crate) async fn get_credential(handle: usize, options: RequestOptions) -> String { run_ceremony(handle, move || build_get_requests(&options)).await } + +#[cfg(test)] +mod tests { + use objc2::rc::Retained; + use objc2::runtime::AnyObject; + use objc2_authentication_services::{ASAuthorizationError, ASAuthorizationErrorDomain}; + use objc2_foundation::{NSDictionary, NSError, NSLocalizedDescriptionKey, NSString}; + + use super::map_nserror; + + fn authorization_error(code: ASAuthorizationError, description: Option<&str>) -> Retained { + let user_info = description.map(|text| { + let value = NSString::from_str(text); + let object: &AnyObject = &value; + NSDictionary::from_slices(&[unsafe { NSLocalizedDescriptionKey }], &[object]) + }); + unsafe { + NSError::errorWithDomain_code_userInfo(ASAuthorizationErrorDomain, code.0, user_info.as_deref()) + } + } + + #[test] + fn maps_user_cancellation_to_cancelled() { + let error = authorization_error(ASAuthorizationError::Canceled, None); + assert_eq!(map_nserror(&error).0, "cancelled"); + } + + #[test] + fn maps_domain_association_failures_to_invalid_rp() { + let error = authorization_error( + ASAuthorizationError::Failed, + Some("Application with identifier ABC.com.example is not associated with domain example.com"), + ); + assert_eq!(map_nserror(&error).0, "invalid_rp"); + } + + #[test] + fn maps_other_authorization_failures_to_unknown() { + let error = authorization_error(ASAuthorizationError::Unknown, None); + assert_eq!(map_nserror(&error).0, "unknown"); + } + + #[test] + fn maps_foreign_domains_to_unknown() { + let domain = NSString::from_str("com.example.SomeOtherDomain"); + let error = unsafe { NSError::errorWithDomain_code_userInfo(&domain, 1001, None) }; + assert_eq!(map_nserror(&error).0, "unknown"); + } +} diff --git a/packages/electron/README.md b/packages/electron/README.md index f86315ce45b..06bcc75e938 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -99,28 +99,41 @@ pnpm add @clerk/electron-passkeys ```ts // main process -import { setupMain, setupPasskeysMain } from '@clerk/electron'; +import { setupMain } from '@clerk/electron'; import { storage } from '@clerk/electron/storage'; -setupMain({ storage: storage() }); -setupPasskeysMain(); +setupMain({ storage: storage(), passkeys: true }); ``` ```ts // preload script -import { setupPreload, setupPasskeysPreload } from '@clerk/electron/preload'; +import { setupPreload } from '@clerk/electron/preload'; + +setupPreload({ passkeys: true }); +``` -setupPreload(); -setupPasskeysPreload(); +```tsx +// renderer process (React) +import { ClerkProvider } from '@clerk/electron/react'; +import { passkeys } from '@clerk/electron/passkeys'; + + + {/* ... */} +; ``` +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()`: + ```ts -// renderer process +// renderer process (vanilla) import { Clerk } from '@clerk/clerk-js'; import { createPasskeyProvider } from '@clerk/electron/passkeys'; const clerk = new Clerk(publishableKey); -createPasskeyProvider(clerk); // optionally { mode: 'auto' | 'renderer' | 'native' } +createPasskeyProvider(clerk); await clerk.load(); ``` diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index b27a3627806..cbba18b8e07 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,3 +1,2 @@ export { setupMain } from './main/setup-main'; -export { setupPasskeysMain } from './main/passkey-handlers'; -export type { TokenStorage } from './shared/types'; +export type { SetupMainOptions, SetupPreloadOptions, TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/passkey-handlers.test.ts b/packages/electron/src/main/__tests__/passkey-handlers.test.ts index ee0ea3da6d0..a4664c766f1 100644 --- a/packages/electron/src/main/__tests__/passkey-handlers.test.ts +++ b/packages/electron/src/main/__tests__/passkey-handlers.test.ts @@ -35,7 +35,8 @@ const getHandler = (channel: string): Handler => { }; const windowHandle = Buffer.from([1, 2, 3, 4]); -const event = { sender: {} } as IpcMainInvokeEvent; +const mainFrame = {}; +const event = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as IpcMainInvokeEvent; const creationOptions = { challenge: 'abc', rp: { id: 'example.com', name: 'Example' } }; const registrationJSON = { id: 'cred', rawId: 'cred', type: 'public-key', response: {} }; @@ -94,6 +95,16 @@ describe('setupPasskeysMain', () => { expect(native.createCredential).not.toHaveBeenCalled(); }); + it('rejects requests that do not originate from the main frame', async () => { + setupPasskeysMain(); + + const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as IpcMainInvokeEvent; + const result = await getHandler(PASSKEY_CHANNELS.create)(subframeEvent, creationOptions); + + expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } }); + expect(native.createCredential).not.toHaveBeenCalled(); + }); + it('returns an error when the request does not originate from a window', async () => { vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(null); setupPasskeysMain(); diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts index 32701055806..33766c23da3 100644 --- a/packages/electron/src/main/__tests__/setup-main.test.ts +++ b/packages/electron/src/main/__tests__/setup-main.test.ts @@ -1,10 +1,14 @@ import { ipcMain, protocol } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PASSKEY_CHANNELS } from '../../shared/ipc'; import type { TokenStorage } from '../../shared/types'; import { setupMain } from '../setup-main'; vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: vi.fn(), + }, ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), @@ -14,6 +18,15 @@ vi.mock('electron', () => ({ }, })); +vi.mock('@clerk/electron-passkeys', () => ({ + default: { + isAvailable: () => true, + capabilities: () => ({ platformAuthenticator: true, securityKeys: true }), + createCredential: vi.fn(), + getCredential: vi.fn(), + }, +})); + describe('setupMain', () => { const missingStorage = {} as Parameters[0]; const storage: TokenStorage = { @@ -90,4 +103,30 @@ describe('setupMain', () => { expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); }); + + it('does not register passkey IPC handlers by default', () => { + setupMain({ storage }); + + const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel); + expect(channels).not.toContain(PASSKEY_CHANNELS.create); + expect(channels).not.toContain(PASSKEY_CHANNELS.get); + expect(channels).not.toContain(PASSKEY_CHANNELS.capabilities); + }); + + it('registers passkey IPC handlers when passkeys is enabled', () => { + setupMain({ storage, passkeys: true }); + + const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel); + expect(channels).toContain(PASSKEY_CHANNELS.create); + expect(channels).toContain(PASSKEY_CHANNELS.get); + expect(channels).toContain(PASSKEY_CHANNELS.capabilities); + }); + + it('cleans up passkey handlers together with the token handlers', () => { + const clerk = setupMain({ storage, passkeys: true }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(6); + }); }); diff --git a/packages/electron/src/main/passkey-handlers.ts b/packages/electron/src/main/passkey-handlers.ts index b8922a67067..c90db867da8 100644 --- a/packages/electron/src/main/passkey-handlers.ts +++ b/packages/electron/src/main/passkey-handlers.ts @@ -33,7 +33,7 @@ function loadNativeModule(): Promise { error => { nativeModulePromise = undefined; throw new Error( - 'Clerk: setupPasskeysMain requires the optional @clerk/electron-passkeys package. Install it with your package manager to enable native passkey support.', + 'Clerk: setupMain({ passkeys: true }) requires the optional @clerk/electron-passkeys package. Install it with your package manager to enable native passkey support.', { cause: error }, ); }, @@ -75,6 +75,15 @@ async function invokeNative( }; } + // Subframes and webviews can host third-party content that must not be able + // to run credential ceremonies for the app's RP ID. + if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) { + return { + ok: false, + error: { code: 'unknown', message: "The passkey request did not originate from a window's main frame." }, + }; + } + const window = BrowserWindow.fromWebContents(event.sender); if (!window) { return { diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts index aef67fda011..d540304c7ee 100644 --- a/packages/electron/src/main/setup-main.ts +++ b/packages/electron/src/main/setup-main.ts @@ -2,6 +2,7 @@ import { protocol } from 'electron'; import type { SetupMainOptions, SetupMainReturn } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; +import { setupPasskeysMain } from './passkey-handlers'; function assertValidRendererOriginConfig(renderer: NonNullable): void { if (renderer.scheme.includes(':') || renderer.scheme.includes('/')) { @@ -25,6 +26,7 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { } const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + const passkeys = options.passkeys ? setupPasskeysMain() : null; if (options.renderer) { assertValidRendererOriginConfig(options.renderer); @@ -46,6 +48,7 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { return { cleanup() { cleanupTokenPersistence(); + passkeys?.cleanup(); }, }; } diff --git a/packages/electron/src/passkeys/index.ts b/packages/electron/src/passkeys/index.ts index 7d1523f955e..d52f8ad2185 100644 --- a/packages/electron/src/passkeys/index.ts +++ b/packages/electron/src/passkeys/index.ts @@ -62,7 +62,7 @@ const unsupportedReturn = (): CredentialReturn => ({ publicKeyCredential: null, error: new ClerkWebAuthnError( - 'Clerk: Passkeys are not supported in this window. Serve the page from an https origin matching the RP ID, or set up the native passkey module (setupPasskeysMain/setupPasskeysPreload).', + 'Clerk: Passkeys are not supported in this window. Serve the page from an https origin matching the RP ID, or enable the native passkey module with `passkeys: true` in setupMain() and setupPreload().', { code: 'passkey_not_supported' }, ), }) as CredentialReturn; @@ -145,6 +145,20 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport return { create, get, isSupported, isAutoFillSupported, isPlatformAuthenticatorSupported }; } +/** + * Ready-to-use passkey implementation for the `ClerkProvider` `passkeys` prop. + * Chooses renderer or native WebAuthn automatically per request. + * + * @example + * ```tsx + * import { ClerkProvider } from '@clerk/electron/react'; + * import { passkeys } from '@clerk/electron/passkeys'; + * + * + * ``` + */ +export const passkeys: PasskeySupport = createPasskeys(); + /** * Wires passkey support into a Clerk instance. Call before `clerk.load()`. * diff --git a/packages/electron/src/passkeys/renderer/native-bridge.ts b/packages/electron/src/passkeys/renderer/native-bridge.ts index e0e2cfc4d7b..f214fb183ca 100644 --- a/packages/electron/src/passkeys/renderer/native-bridge.ts +++ b/packages/electron/src/passkeys/renderer/native-bridge.ts @@ -22,7 +22,7 @@ export function getPasskeyBridge(): PasskeyBridge | undefined { const bridgeMissingError = () => new ClerkWebAuthnError( - 'Clerk: The native passkey bridge is not available. Call setupPasskeysPreload() in your preload script and setupPasskeysMain() in the main process.', + 'Clerk: The native passkey bridge is not available. Pass `passkeys: true` to setupPreload() in your preload script and setupMain() in the main process.', { code: 'passkey_not_supported' }, ); diff --git a/packages/electron/src/passkeys/shared/serialization.ts b/packages/electron/src/passkeys/shared/serialization.ts index bfcd723a6e1..903be9bc401 100644 --- a/packages/electron/src/passkeys/shared/serialization.ts +++ b/packages/electron/src/passkeys/shared/serialization.ts @@ -79,7 +79,7 @@ export function deserializeCreationResponse( getTransports: () => credential.response.transports ?? [], }, toJSON: () => credential, - }; + } as PublicKeyCredentialWithAuthenticatorAttestationResponse & { toJSON: () => RegistrationResponseJSON }; } export function deserializeRequestResponse( diff --git a/packages/electron/src/preload/__tests__/index.test.ts b/packages/electron/src/preload/__tests__/index.test.ts index 28c329ed7b4..9325bf67da3 100644 --- a/packages/electron/src/preload/__tests__/index.test.ts +++ b/packages/electron/src/preload/__tests__/index.test.ts @@ -54,6 +54,26 @@ describe('setupPreload', () => { }); }); + it('does not expose the passkey bridge by default', () => { + setupPreload(); + + const exposedKeys = vi.mocked(contextBridge.exposeInMainWorld).mock.calls.map(([key]) => key); + expect(exposedKeys).not.toContain('__clerk_internal_electron_passkeys'); + }); + + it('exposes the passkey bridge when passkeys is enabled', () => { + setupPreload({ passkeys: true }); + + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith( + '__clerk_internal_electron_passkeys', + expect.objectContaining({ + create: expect.any(Function), + get: expect.any(Function), + capabilities: expect.any(Function), + }), + ); + }); + it('forwards token cache calls over IPC', async () => { setupPreload(); diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index d0530139a3a..de44ccab249 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -1,11 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; +import { setupPasskeysPreload } from '../passkeys/preload'; import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; -import type { TokenCache } from '../shared/types'; +import type { SetupPreloadOptions, TokenCache } from '../shared/types'; -export { setupPasskeysPreload } from '../passkeys/preload'; - -export function setupPreload(): void { +export function setupPreload(options?: SetupPreloadOptions): void { const tokenCache: TokenCache = { getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value), @@ -17,4 +16,8 @@ export function setupPreload(): void { } else { window.__clerk_internal_electron = { tokenCache }; } + + if (options?.passkeys) { + setupPasskeysPreload(); + } } diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx index 1aa7cdb90ce..8cfc71d6924 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -78,6 +78,66 @@ describe('Electron ClerkProvider', () => { expect(capturedProviderProps?.__internal_nativeOAuthHandler).toBeUndefined(); }); + it('does not wire passkeys unless they are provided', () => { + renderToStaticMarkup(App); + + const clerk = capturedProviderProps?.Clerk as Record; + expect(clerk.__internal_createPublicCredentials).toBeUndefined(); + expect(clerk.__internal_getPublicCredentials).toBeUndefined(); + expect(clerk.__internal_isWebAuthnSupported).toBeUndefined(); + }); + + it('wires the provided passkey implementation into the Clerk instance', () => { + const passkeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + }; + + renderToStaticMarkup( + + App + , + ); + + const clerk = capturedProviderProps?.Clerk as Record; + expect(clerk.__internal_createPublicCredentials).toBe(passkeys.create); + expect(clerk.__internal_getPublicCredentials).toBe(passkeys.get); + expect(clerk.__internal_isWebAuthnSupported).toBe(passkeys.isSupported); + expect(clerk.__internal_isWebAuthnAutofillSupported).toBe(passkeys.isAutoFillSupported); + expect(clerk.__internal_isWebAuthnPlatformAuthenticatorSupported).toBe(passkeys.isPlatformAuthenticatorSupported); + }); + + it('wires passkeys onto an instance that was cached without them', () => { + renderToStaticMarkup(App); + const cachedClerk = capturedProviderProps?.Clerk; + + const passkeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + }; + renderToStaticMarkup( + + App + , + ); + + const clerk = capturedProviderProps?.Clerk as Record; + expect(clerk).toBe(cachedClerk); + expect(clerk.__internal_createPublicCredentials).toBe(passkeys.create); + }); + it('adds native request params and Authorization from the Electron token cache', async () => { tokenCache.getToken.mockResolvedValue('client-jwt'); renderToStaticMarkup(App); diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts index a56a9f6d850..45494f8a4e9 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -1,18 +1,37 @@ import { Clerk } from '@clerk/clerk-js'; +// Type-only import: the passkeys module is only bundled when the app imports +// `@clerk/electron/passkeys` itself. +import type { PasskeySupport } from '../passkeys'; + const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; type ClerkInstance = InstanceType; let cached: { instance: ClerkInstance; publishableKey: string } | null = null; -export function createClerkInstance(publishableKey: string): ClerkInstance { +function attachPasskeys(clerk: ClerkInstance, passkeys: PasskeySupport): void { + clerk.__internal_createPublicCredentials = passkeys.create; + clerk.__internal_getPublicCredentials = passkeys.get; + clerk.__internal_isWebAuthnSupported = passkeys.isSupported; + clerk.__internal_isWebAuthnAutofillSupported = passkeys.isAutoFillSupported; + clerk.__internal_isWebAuthnPlatformAuthenticatorSupported = passkeys.isPlatformAuthenticatorSupported; +} + +export function createClerkInstance(publishableKey: string, passkeys?: PasskeySupport): ClerkInstance { if (cached?.publishableKey === publishableKey) { + if (passkeys) { + attachPasskeys(cached.instance, passkeys); + } return cached.instance; } const clerk = new Clerk(publishableKey); + if (passkeys) { + attachPasskeys(clerk, passkeys); + } + clerk.__internal_onBeforeRequest(async request => { request.credentials = 'omit'; request.url?.searchParams.append('_is_native', '1'); diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx index ddfa6de5607..272c97cd4f9 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -3,6 +3,7 @@ import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/intern import { ui } from '@clerk/ui'; import type { ReactNode } from 'react'; +import type { PasskeySupport } from '../passkeys'; import { createClerkInstance } from './create-clerk-instance'; export type ClerkProviderProps = Omit< @@ -14,10 +15,22 @@ export type ClerkProviderProps = Omit< * Your Clerk publishable key, available in the Clerk Dashboard. */ publishableKey: string; + /** + * Enables passkey support. Pass the `passkeys` export from `@clerk/electron/passkeys`; + * when omitted, no passkey code is bundled or initialized. + * + * @example + * ```tsx + * import { passkeys } from '@clerk/electron/passkeys'; + * + * + * ``` + */ + passkeys?: PasskeySupport; }; -export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element { - const clerk = createClerkInstance(publishableKey); +export function ClerkProvider({ children, publishableKey, passkeys, ...props }: ClerkProviderProps): JSX.Element { + const clerk = createClerkInstance(publishableKey, passkeys); return ( Date: Fri, 12 Jun 2026 01:54:02 +0400 Subject: [PATCH 15/19] chore(repo): restore mosaic architecture docs --- references/mosaic-architecture.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 2d098aa1750..d7983c693d3 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -45,8 +45,7 @@ theme.mix('primary', 'primaryForeground', 50); // "color-mix(in oklab, // text(key) — typography scale with fontSize + lineHeight theme.text('sm'); // { fontSize: '0.875rem', lineHeight: '...' } ``` - -```` +``` ## Theme delivery @@ -58,7 +57,7 @@ Single provider that handles cascade and theme delivery: import { MosaicProvider } from '../mosaic/MosaicProvider'; {children}; -```` +``` ### useMosaicTheme From 288e7b385cf5a23e5e4ccfa1de473b9608201495 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 12 Jun 2026 20:58:46 +0400 Subject: [PATCH 16/19] feat(electron): Report SDK version to FAPI via query param (#8847) --- packages/electron/src/global.d.ts | 8 ++++---- .../src/react/__tests__/ClerkProvider.test.tsx | 15 +++++++++++++++ .../electron/src/react/create-clerk-instance.ts | 1 + packages/electron/vitest.config.mts | 7 +++++++ packages/electron/vitest.setup.mts | 3 +++ 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 packages/electron/vitest.config.mts create mode 100644 packages/electron/vitest.setup.mts diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts index 6c762af29a5..8afb39ea766 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,10 +1,10 @@ import type { TokenCache } from './shared/types'; -declare const PACKAGE_NAME: string; -declare const PACKAGE_VERSION: string; -declare const __DEV__: boolean; - declare global { + const PACKAGE_NAME: string; + const PACKAGE_VERSION: string; + const __DEV__: boolean; + interface Window { __clerk_internal_electron?: { tokenCache: TokenCache; diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx index 1aa7cdb90ce..ea6e380049c 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -90,10 +90,25 @@ describe('Electron ClerkProvider', () => { expect(request.credentials).toBe('omit'); expect(request.url.searchParams.get('_is_native')).toBe('1'); + expect(request.url.searchParams.get('_electron_sdk_version')).toBe('0.0.0-test'); expect(request.headers.get('Authorization')).toBe('Bearer client-jwt'); expect(tokenCache.getToken).toHaveBeenCalledWith('__clerk_client_jwt'); }); + it('adds the Electron SDK version query param when there is no cached token', async () => { + tokenCache.getToken.mockResolvedValue(null); + renderToStaticMarkup(App); + + const request = { + headers: new Headers(), + url: new URL('https://api.clerk.test/v1/client'), + }; + await beforeRequest?.(request); + + expect(request.url.searchParams.get('_electron_sdk_version')).toBe('0.0.0-test'); + expect(request.headers.has('Authorization')).toBe(false); + }); + it('stores Authorization response headers in the Electron token cache', async () => { renderToStaticMarkup(App); diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts index a56a9f6d850..92f705a6d90 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -16,6 +16,7 @@ export function createClerkInstance(publishableKey: string): ClerkInstance { clerk.__internal_onBeforeRequest(async request => { request.credentials = 'omit'; request.url?.searchParams.append('_is_native', '1'); + request.url?.searchParams.append('_electron_sdk_version', PACKAGE_VERSION); const token = await window.__clerk_internal_electron?.tokenCache.getToken(CLERK_CLIENT_JWT_KEY); if (token) { diff --git a/packages/electron/vitest.config.mts b/packages/electron/vitest.config.mts new file mode 100644 index 00000000000..e3d1477ad3b --- /dev/null +++ b/packages/electron/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/electron/vitest.setup.mts b/packages/electron/vitest.setup.mts new file mode 100644 index 00000000000..4bd677df222 --- /dev/null +++ b/packages/electron/vitest.setup.mts @@ -0,0 +1,3 @@ +globalThis.PACKAGE_NAME = '@clerk/electron'; +globalThis.PACKAGE_VERSION = '0.0.0-test'; +globalThis.__DEV__ = false; From 7688b66982b126e633de756220fa8f785b1a4bca Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 12 Jun 2026 22:09:02 +0400 Subject: [PATCH 17/19] chore(electron): update lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca41033a66b..9c0d261d941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,9 @@ importers: '@clerk/react': specifier: workspace:^ version: link:../react + '@clerk/shared': + specifier: workspace:^ + version: link:../shared '@clerk/ui': specifier: workspace:^ version: link:../ui From e1c121cb5d795be3b2264990aee0faba98703b7a Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Sat, 13 Jun 2026 04:57:17 +0400 Subject: [PATCH 18/19] fix(electron): support dev loopback passkeys --- .../src/passkeys/__tests__/index.test.ts | 65 +++++++++++++++++++ .../src/passkeys/__tests__/strategy.test.ts | 48 +++++++++++++- packages/electron/src/passkeys/index.ts | 34 ++++++++-- .../src/passkeys/renderer/strategy.ts | 10 ++- .../react/__tests__/ClerkProvider.test.tsx | 60 +++++++++++++++++ .../src/react/create-clerk-instance.ts | 45 +++++++++---- packages/electron/src/react/index.tsx | 16 ++++- 7 files changed, 253 insertions(+), 25 deletions(-) diff --git a/packages/electron/src/passkeys/__tests__/index.test.ts b/packages/electron/src/passkeys/__tests__/index.test.ts index a093b73f510..b8623565fe1 100644 --- a/packages/electron/src/passkeys/__tests__/index.test.ts +++ b/packages/electron/src/passkeys/__tests__/index.test.ts @@ -21,6 +21,8 @@ vi.mock('@clerk/shared/webauthn', () => ({ })); const HELLO_B64URL = 'aGVsbG8'; +const DEV_PUBLISHABLE_KEY = 'pk_test_electron'; +const LIVE_PUBLISHABLE_KEY = 'pk_live_electron'; const creationOptions = () => ({ @@ -112,6 +114,28 @@ describe('createPasskeys', () => { expect(bridge.create).not.toHaveBeenCalled(); }); + it('uses the renderer path on a localhost origin for development publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys({ publishableKey: DEV_PUBLISHABLE_KEY }).create(creationOptions()); + + expect(result).toBe(rendererResult); + expect(bridge.create).not.toHaveBeenCalled(); + }); + + it('uses the native path on a localhost origin for production publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + + await createPasskeys({ publishableKey: LIVE_PUBLISHABLE_KEY }).create(creationOptions()); + + expect(bridge.create).toHaveBeenCalled(); + expect(webAuthnCreateCredential).not.toHaveBeenCalled(); + }); + it('uses the native path for local bundles', async () => { const bridge = makeBridge(); stubEnvironment({ protocol: 'file:', hostname: '', bridge }); @@ -228,6 +252,24 @@ describe('createPasskeys', () => { expect(result.error).toBeNull(); expect(result.publicKeyCredential?.toJSON()).toEqual(authenticationJSON); }); + + it('uses the renderer path on a loopback origin for development publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: '127.0.0.1', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnGetCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys({ publishableKey: DEV_PUBLISHABLE_KEY }).get({ + publicKeyOptions: requestOptions(), + }); + + expect(webAuthnGetCredential).toHaveBeenCalledWith({ + publicKeyOptions: expect.anything(), + conditionalUI: false, + }); + expect(result).toBe(rendererResult); + expect(bridge.get).not.toHaveBeenCalled(); + }); }); describe('capability checks', () => { @@ -280,4 +322,27 @@ describe('createPasskeyProvider', () => { expect(clerk.__internal_isWebAuthnPlatformAuthenticatorSupported).toBe(passkeys.isPlatformAuthenticatorSupported); vi.unstubAllGlobals(); }); + + it('uses the Clerk publishable key when options do not include one', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(rendererResult); + const clerk: ClerkPasskeyHost = { + publishableKey: DEV_PUBLISHABLE_KEY, + __internal_createPublicCredentials: undefined, + __internal_getPublicCredentials: undefined, + __internal_isWebAuthnSupported: undefined, + __internal_isWebAuthnAutofillSupported: undefined, + __internal_isWebAuthnPlatformAuthenticatorSupported: undefined, + }; + + createPasskeyProvider(clerk); + const result = await clerk.__internal_createPublicCredentials?.(creationOptions()); + + expect(result).toBe(rendererResult); + expect(webAuthnCreateCredential).toHaveBeenCalled(); + expect(bridge.create).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); }); diff --git a/packages/electron/src/passkeys/__tests__/strategy.test.ts b/packages/electron/src/passkeys/__tests__/strategy.test.ts index d01f793ba79..0f9fd596a10 100644 --- a/packages/electron/src/passkeys/__tests__/strategy.test.ts +++ b/packages/electron/src/passkeys/__tests__/strategy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { StrategyEnv } from '../renderer/strategy'; -import { decidePath, originSatisfiesRpId } from '../renderer/strategy'; +import { decidePath, isLoopbackHost, originSatisfiesRpId } from '../renderer/strategy'; const RP_ID = 'example.com'; @@ -12,6 +12,7 @@ const env = (overrides: Partial = {}): StrategyEnv => ({ nativeAvailable: true, platform: 'darwin', electronMajor: 42, + isDevelopmentInstance: false, ...overrides, }); @@ -31,6 +32,20 @@ describe('originSatisfiesRpId', () => { }); }); +describe('isLoopbackHost', () => { + it.each([ + ['localhost', true], + ['127.0.0.1', true], + ['::1', true], + ['[::1]', true], + ['app.localhost', false], + ['0.0.0.0', false], + ['example.com', false], + ])('%s -> %s', (hostname, expected) => { + expect(isLoopbackHost(hostname)).toBe(expected); + }); +}); + describe('decidePath', () => { describe('forced modes', () => { it('renderer mode uses the renderer whenever WebAuthn exists, regardless of origin', () => { @@ -92,5 +107,36 @@ describe('decidePath', () => { it('uses the renderer for security keys on Linux remote origins', () => { expect(decidePath(RP_ID, 'auto', env({ platform: 'linux', nativeAvailable: false }))).toBe('renderer'); }); + + it.each(['localhost', '127.0.0.1', '::1', '[::1]'])( + 'uses the renderer for development instances on loopback host %s', + hostname => { + expect( + decidePath( + RP_ID, + 'auto', + env({ + protocol: 'http:', + hostname, + isDevelopmentInstance: true, + }), + ), + ).toBe('renderer'); + }, + ); + + it('falls back to native for production instances on loopback hosts', () => { + expect(decidePath(RP_ID, 'auto', env({ protocol: 'http:', hostname: 'localhost' }))).toBe('native'); + }); + + it('falls back to native for development instances on non-loopback http hosts', () => { + expect( + decidePath( + RP_ID, + 'auto', + env({ protocol: 'http:', hostname: 'preview.example.test', isDevelopmentInstance: true }), + ), + ).toBe('native'); + }); }); }); diff --git a/packages/electron/src/passkeys/index.ts b/packages/electron/src/passkeys/index.ts index d52f8ad2185..38b6da4119c 100644 --- a/packages/electron/src/passkeys/index.ts +++ b/packages/electron/src/passkeys/index.ts @@ -1,5 +1,6 @@ import { ClerkWebAuthnError } from '@clerk/shared/error'; import { webAuthnCreateCredential, webAuthnGetCredential } from '@clerk/shared/internal/clerk-js/passkeys'; +import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys'; import type { CredentialReturn, PublicKeyCredentialCreationOptionsWithoutExtensions, @@ -21,6 +22,10 @@ export type CreatePasskeysOptions = { * `auto` chooses renderer WebAuthn for valid HTTPS origins and native WebAuthn otherwise. */ mode?: PasskeyMode; + /** + * Clerk publishable key used to enable development-instance behavior. + */ + publishableKey?: string; }; export type PasskeySupport = { @@ -33,9 +38,11 @@ export type PasskeySupport = { isSupported: () => boolean; isAutoFillSupported: () => Promise; isPlatformAuthenticatorSupported: () => Promise; + __internal_withPublishableKey?: (publishableKey: string) => PasskeySupport; }; export type ClerkPasskeyHost = { + publishableKey?: string; __internal_createPublicCredentials: PasskeySupport['create'] | undefined; __internal_getPublicCredentials: PasskeySupport['get'] | undefined; __internal_isWebAuthnSupported: PasskeySupport['isSupported'] | undefined; @@ -45,7 +52,7 @@ export type ClerkPasskeyHost = { const NATIVE_PLATFORMS = ['darwin', 'win32']; -function getEnv(): StrategyEnv { +function getEnv(publishableKey?: string): StrategyEnv { const bridge = getPasskeyBridge(); const hasLocation = typeof location !== 'undefined'; return { @@ -55,6 +62,7 @@ function getEnv(): StrategyEnv { nativeAvailable: !!bridge && NATIVE_PLATFORMS.includes(bridge.platform), platform: bridge?.platform ?? '', electronMajor: bridge?.electronMajor ?? 0, + isDevelopmentInstance: publishableKey ? isDevelopmentFromPublishableKey(publishableKey) : false, }; } @@ -73,9 +81,10 @@ const isRpIdMismatchError = (error: unknown): boolean => /** Creates an Electron passkey provider for clerk-js. */ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport { const mode: PasskeyMode = options?.mode ?? 'auto'; + const publishableKey = options?.publishableKey; const create: PasskeySupport['create'] = async publicKey => { - const env = getEnv(); + const env = getEnv(publishableKey); const path = decidePath(publicKey.rp.id ?? '', mode, env); if (path === 'unsupported') { @@ -94,7 +103,7 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport }; const get: PasskeySupport['get'] = async ({ publicKeyOptions }) => { - const env = getEnv(); + const env = getEnv(publishableKey); const path = decidePath(publicKeyOptions.rpId ?? '', mode, env); if (path === 'unsupported') { @@ -112,7 +121,7 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport }; const isSupported: PasskeySupport['isSupported'] = () => { - const env = getEnv(); + const env = getEnv(publishableKey); if (mode === 'renderer') { return env.hasWebAuthn; } @@ -127,7 +136,7 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport }; const isPlatformAuthenticatorSupported: PasskeySupport['isPlatformAuthenticatorSupported'] = async () => { - const env = getEnv(); + const env = getEnv(publishableKey); if (env.nativeAvailable && mode !== 'renderer') { const bridge = getPasskeyBridge(); try { @@ -142,7 +151,18 @@ export function createPasskeys(options?: CreatePasskeysOptions): PasskeySupport return isWebAuthnPlatformAuthenticatorSupported(); }; - return { create, get, isSupported, isAutoFillSupported, isPlatformAuthenticatorSupported }; + const withPublishableKey: NonNullable = publishableKey => { + return createPasskeys({ ...options, publishableKey }); + }; + + return { + create, + get, + isSupported, + isAutoFillSupported, + isPlatformAuthenticatorSupported, + __internal_withPublishableKey: withPublishableKey, + }; } /** @@ -173,7 +193,7 @@ export const passkeys: PasskeySupport = createPasskeys(); * ``` */ export function createPasskeyProvider(clerk: ClerkPasskeyHost, options?: CreatePasskeysOptions): PasskeySupport { - const passkeys = createPasskeys(options); + const passkeys = createPasskeys({ ...options, publishableKey: options?.publishableKey ?? clerk.publishableKey }); clerk.__internal_createPublicCredentials = passkeys.create; clerk.__internal_getPublicCredentials = passkeys.get; diff --git a/packages/electron/src/passkeys/renderer/strategy.ts b/packages/electron/src/passkeys/renderer/strategy.ts index 8e259fe8ae2..7844519e7ec 100644 --- a/packages/electron/src/passkeys/renderer/strategy.ts +++ b/packages/electron/src/passkeys/renderer/strategy.ts @@ -8,6 +8,7 @@ export type StrategyEnv = { nativeAvailable: boolean; platform: string; electronMajor: number; + isDevelopmentInstance: boolean; }; export function originSatisfiesRpId(env: Pick, rpId: string): boolean { @@ -17,6 +18,10 @@ export function originSatisfiesRpId(env: Pick 0 && env.electronMajor < 42 && env.nativeAvailable) { return 'native'; } diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx index 7ac3961fd75..928d0397c69 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -138,6 +138,43 @@ describe('Electron ClerkProvider', () => { expect(clerk.__internal_createPublicCredentials).toBe(passkeys.create); }); + it('passes the publishable key to contextual passkey implementations', () => { + const contextualPasskeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + }; + const passkeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + __internal_withPublishableKey: vi.fn(() => contextualPasskeys), + }; + + renderToStaticMarkup( + + App + , + ); + + const clerk = capturedProviderProps?.Clerk as Record; + expect(passkeys.__internal_withPublishableKey).toHaveBeenCalledWith('pk_test_contextual'); + expect(clerk.__internal_createPublicCredentials).toBe(contextualPasskeys.create); + expect(clerk.__internal_getPublicCredentials).toBe(contextualPasskeys.get); + expect(clerk.__internal_isWebAuthnSupported).toBe(contextualPasskeys.isSupported); + expect(clerk.__internal_isWebAuthnAutofillSupported).toBe(contextualPasskeys.isAutoFillSupported); + expect(clerk.__internal_isWebAuthnPlatformAuthenticatorSupported).toBe( + contextualPasskeys.isPlatformAuthenticatorSupported, + ); + }); + it('adds native request params and Authorization from the Electron token cache', async () => { tokenCache.getToken.mockResolvedValue('client-jwt'); renderToStaticMarkup(App); @@ -169,6 +206,29 @@ describe('Electron ClerkProvider', () => { expect(request.headers.has('Authorization')).toBe(false); }); + it('can disable the Electron SDK version query param', async () => { + tokenCache.getToken.mockResolvedValue(null); + renderToStaticMarkup( + + App + , + ); + + const request = { + headers: new Headers(), + url: new URL('https://api.clerk.test/v1/client'), + }; + await beforeRequest?.(request); + + expect(request.url.searchParams.get('_is_native')).toBe('1'); + expect(request.url.searchParams.has('_electron_sdk_version')).toBe(false); + expect(request.headers.has('Authorization')).toBe(false); + expect(capturedProviderProps?.__internal_disableElectronSdkVersionParam).toBeUndefined(); + }); + it('stores Authorization response headers in the Electron token cache', async () => { renderToStaticMarkup(App); diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts index 81317d464d6..3881e409dea 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -8,20 +8,35 @@ const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; type ClerkInstance = InstanceType; -let cached: { instance: ClerkInstance; publishableKey: string } | null = null; - -function attachPasskeys(clerk: ClerkInstance, passkeys: PasskeySupport): void { - clerk.__internal_createPublicCredentials = passkeys.create; - clerk.__internal_getPublicCredentials = passkeys.get; - clerk.__internal_isWebAuthnSupported = passkeys.isSupported; - clerk.__internal_isWebAuthnAutofillSupported = passkeys.isAutoFillSupported; - clerk.__internal_isWebAuthnPlatformAuthenticatorSupported = passkeys.isPlatformAuthenticatorSupported; +type CreateClerkInstanceOptions = { + disableElectronSdkVersionParam?: boolean; +}; + +let cached: ({ instance: ClerkInstance; publishableKey: string } & CreateClerkInstanceOptions) | null = null; + +function attachPasskeys(clerk: ClerkInstance, passkeys: PasskeySupport, publishableKey: string): void { + const contextualPasskeys = passkeys.__internal_withPublishableKey?.(publishableKey) ?? passkeys; + + clerk.__internal_createPublicCredentials = contextualPasskeys.create; + clerk.__internal_getPublicCredentials = contextualPasskeys.get; + clerk.__internal_isWebAuthnSupported = contextualPasskeys.isSupported; + clerk.__internal_isWebAuthnAutofillSupported = contextualPasskeys.isAutoFillSupported; + clerk.__internal_isWebAuthnPlatformAuthenticatorSupported = contextualPasskeys.isPlatformAuthenticatorSupported; } -export function createClerkInstance(publishableKey: string, passkeys?: PasskeySupport): ClerkInstance { - if (cached?.publishableKey === publishableKey) { +export function createClerkInstance( + publishableKey: string, + passkeys?: PasskeySupport, + options: CreateClerkInstanceOptions = {}, +): ClerkInstance { + const disableElectronSdkVersionParam = options.disableElectronSdkVersionParam ?? false; + + if ( + cached?.publishableKey === publishableKey && + cached.disableElectronSdkVersionParam === disableElectronSdkVersionParam + ) { if (passkeys) { - attachPasskeys(cached.instance, passkeys); + attachPasskeys(cached.instance, passkeys, publishableKey); } return cached.instance; } @@ -29,13 +44,15 @@ export function createClerkInstance(publishableKey: string, passkeys?: PasskeySu const clerk = new Clerk(publishableKey); if (passkeys) { - attachPasskeys(clerk, passkeys); + attachPasskeys(clerk, passkeys, publishableKey); } clerk.__internal_onBeforeRequest(async request => { request.credentials = 'omit'; request.url?.searchParams.append('_is_native', '1'); - request.url?.searchParams.append('_electron_sdk_version', PACKAGE_VERSION); + if (!disableElectronSdkVersionParam) { + request.url?.searchParams.append('_electron_sdk_version', PACKAGE_VERSION); + } const token = await window.__clerk_internal_electron?.tokenCache.getToken(CLERK_CLIENT_JWT_KEY); if (token) { @@ -55,6 +72,6 @@ export function createClerkInstance(publishableKey: string, passkeys?: PasskeySu await window.__clerk_internal_electron?.tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); }); - cached = { instance: clerk, publishableKey }; + cached = { instance: clerk, publishableKey, disableElectronSdkVersionParam }; return clerk; } diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx index 272c97cd4f9..5cf9e5a4e1e 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -15,6 +15,10 @@ export type ClerkProviderProps = Omit< * Your Clerk publishable key, available in the Clerk Dashboard. */ publishableKey: string; + /** + * Temporary internal escape hatch to disable the `_electron_sdk_version` request query parameter. + */ + __internal_disableElectronSdkVersionParam?: boolean; /** * Enables passkey support. Pass the `passkeys` export from `@clerk/electron/passkeys`; * when omitted, no passkey code is bundled or initialized. @@ -29,8 +33,16 @@ export type ClerkProviderProps = Omit< passkeys?: PasskeySupport; }; -export function ClerkProvider({ children, publishableKey, passkeys, ...props }: ClerkProviderProps): JSX.Element { - const clerk = createClerkInstance(publishableKey, passkeys); +export function ClerkProvider({ + children, + publishableKey, + passkeys, + __internal_disableElectronSdkVersionParam, + ...props +}: ClerkProviderProps): JSX.Element { + const clerk = createClerkInstance(publishableKey, passkeys, { + disableElectronSdkVersionParam: __internal_disableElectronSdkVersionParam, + }); return ( Date: Sat, 13 Jun 2026 12:47:10 +0400 Subject: [PATCH 19/19] chore(electron): note temporary sdk version opt-out --- packages/electron/src/react/create-clerk-instance.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts index 3881e409dea..ced6918a713 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -9,6 +9,8 @@ const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; type ClerkInstance = InstanceType; type CreateClerkInstanceOptions = { + // temporary until FAPI supports this, otherwise you get a CORS error + // TODO: Remove after FAPI properly handles this query param. disableElectronSdkVersionParam?: boolean; };