diff --git a/.github/workflows/electron-passkeys.yml b/.github/workflows/electron-passkeys.yml new file mode 100644 index 00000000000..62b33c6a84a --- /dev/null +++ b/.github/workflows/electron-passkeys.yml @@ -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 --filter @clerk/electron-passkeys... --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())); if (!m.isAvailable()) throw new Error('the loader did not find the freshly built native binary');" + + - 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 --filter @clerk/electron-passkeys... --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-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..c93298879b6 --- /dev/null +++ b/packages/electron-passkeys/Cargo.toml @@ -0,0 +1,77 @@ +[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", + "ASAuthorizationError", + "ASAuthorizationController", + "ASAuthorizationCredential", + "ASAuthorizationRequest", + "ASAuthorizationPlatformPublicKeyCredentialProvider", + "ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPlatformPublicKeyCredentialAssertionRequest", + "ASAuthorizationSecurityKeyPublicKeyCredentialProvider", + "ASAuthorizationSecurityKeyPublicKeyCredentialRegistrationRequest", + "ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest", + "ASAuthorizationPublicKeyCredentialRegistrationRequest", + "ASAuthorizationPublicKeyCredentialAssertionRequest", + "ASAuthorizationPublicKeyCredentialConstants", + "ASAuthorizationPublicKeyCredentialDescriptor", + "ASAuthorizationPlatformPublicKeyCredentialDescriptor", + "ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor", + "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", + "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..8bedc891f9b --- /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 `createClerkBridge({ passkeys: true })` 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..8a5019efb28 --- /dev/null +++ b/packages/electron-passkeys/index.js @@ -0,0 +1,63 @@ +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; 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); + } + + 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/electron-passkeys.darwin-arm64.node b/packages/electron-passkeys/npm/darwin-arm64/electron-passkeys.darwin-arm64.node new file mode 100755 index 00000000000..dd04d714821 Binary files /dev/null and b/packages/electron-passkeys/npm/darwin-arm64/electron-passkeys.darwin-arm64.node differ 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..e8f2ddd0524 --- /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": ">=24.15.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/darwin-x64/electron-passkeys.darwin-x64.node b/packages/electron-passkeys/npm/darwin-x64/electron-passkeys.darwin-x64.node new file mode 100755 index 00000000000..9c43df95ec0 Binary files /dev/null and b/packages/electron-passkeys/npm/darwin-x64/electron-passkeys.darwin-x64.node differ 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..b19c53373f9 --- /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": ">=24.15.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-arm64-msvc/electron-passkeys.win32-arm64-msvc.node b/packages/electron-passkeys/npm/win32-arm64-msvc/electron-passkeys.win32-arm64-msvc.node new file mode 100644 index 00000000000..91ae46aca9c Binary files /dev/null and b/packages/electron-passkeys/npm/win32-arm64-msvc/electron-passkeys.win32-arm64-msvc.node differ 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..c98906848a3 --- /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": ">=24.15.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-x64-msvc/electron-passkeys.win32-x64-msvc.node b/packages/electron-passkeys/npm/win32-x64-msvc/electron-passkeys.win32-x64-msvc.node new file mode 100644 index 00000000000..5f54f81970b Binary files /dev/null and b/packages/electron-passkeys/npm/win32-x64-msvc/electron-passkeys.win32-x64-msvc.node differ 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..d6ae7a01a81 --- /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": ">=24.15.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..9553acf4db6 --- /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": ">=24.15.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..0ed221576e7 --- /dev/null +++ b/packages/electron-passkeys/src/macos.rs @@ -0,0 +1,665 @@ +//! 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, ASAuthorizationError, ASAuthorizationErrorDomain, + 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(); + 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 == unsafe { ASAuthorizationErrorDomain } { + match code { + ASAuthorizationError::Canceled => "cancelled", + // ASAuthorizationError.failed also covers RP ID / associated-domain + // mismatches, so use the localized description for classification. + ASAuthorizationError::Failed + 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 +} + +#[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-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/README.md b/packages/electron/README.md index e2d51ee052d..d3e1af2945f 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -37,6 +37,7 @@ This package exposes entrypoints for Electron's distinct runtime contexts: - `@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`. +- `@clerk/electron/passkeys` — passkey (WebAuthn) support for the **renderer** process. ```ts // main.ts @@ -52,6 +53,7 @@ createClerkBridge({ scheme: 'my-app', host: 'renderer', }, + passkeys: true, }); app.whenReady().then(() => { @@ -78,14 +80,20 @@ In `my-app://renderer/sign-in`, `my-app` is the scheme, `renderer` is the host, // preload.ts import { exposeClerkBridge } from '@clerk/electron/preload'; -exposeClerkBridge(); +exposeClerkBridge({ passkeys: true }); ``` ```tsx // renderer.tsx import { ClerkProvider } from '@clerk/electron/react'; - -{/* ... */}; +import { passkeys } from '@clerk/electron/passkeys'; + + + {/* ... */} +; ``` ## Content Security Policy @@ -151,6 +159,78 @@ app.whenReady().then(() => { > [!NOTE] > Loading the renderer from a dev server (such as Vite) requires looser rules for HMR: add `'unsafe-eval'` to `script-src` and your dev server's origin to `connect-src` (for example, `ws://localhost: http://localhost:`). Many apps skip CSP during development and apply it only to packaged builds. +## 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 { createClerkBridge } from '@clerk/electron'; +import { storage } from '@clerk/electron/storage'; + +createClerkBridge({ storage: storage(), passkeys: true }); +``` + +```ts +// preload script +import { exposeClerkBridge } from '@clerk/electron/preload'; + +exposeClerkBridge({ passkeys: true }); +``` + +```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 (vanilla) +import { Clerk } from '@clerk/clerk-js'; +import { createPasskeyProvider } from '@clerk/electron/passkeys'; + +const clerk = new Clerk(publishableKey); +createPasskeyProvider(clerk); +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 For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron). diff --git a/packages/electron/package.json b/packages/electron/package.json index 113888786a5..0d74a6c29b6 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -64,6 +64,16 @@ "default": "./dist/cjs/react/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", @@ -90,21 +100,27 @@ "dependencies": { "@clerk/clerk-js": "workspace:^", "@clerk/react": "workspace:^", + "@clerk/ui": "workspace:^", "@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", "react": "catalog:peer-react", "react-dom": "catalog:peer-react" }, "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 03252a51ccb..ad24b77311d 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,17 +1,18 @@ import type { ClerkUIConstructor } from '@clerk/shared/ui'; -import type { OAuthTransport, TokenCache } from './shared/types'; - -declare const PACKAGE_NAME: string; -declare const PACKAGE_VERSION: string; -declare const __DEV__: boolean; +import type { OAuthTransport, PasskeyBridge, TokenCache } from './shared/types'; declare global { + const PACKAGE_NAME: string; + const PACKAGE_VERSION: string; + const __DEV__: boolean; + interface Window { __clerk_internal_electron?: { tokenCache: TokenCache; oauthTransport: OAuthTransport; }; __internal_ClerkUICtor?: ClerkUIConstructor; + __clerk_internal_electron_passkeys?: PasskeyBridge; } } diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 1ac6aaf5413..3b109dbd849 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,2 +1,3 @@ export { createClerkBridge } from './main/create-clerk-bridge'; -export type { ClerkBridge, CreateClerkBridgeOptions } from './shared/types'; +export { setupPasskeysMain } from './main/passkey-handlers'; +export type { ClerkBridge, CreateClerkBridgeOptions, ExposeClerkBridgeOptions, TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts index ffe6ee21546..b5a4fda47a1 100644 --- a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts +++ b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts @@ -1,7 +1,7 @@ import { app, ipcMain, protocol, shell } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OAUTH_TRANSPORT_CHANNELS } from '../../shared/ipc'; +import { OAUTH_TRANSPORT_CHANNELS, PASSKEY_CHANNELS } from '../../shared/ipc'; import type { TokenStorage } from '../../shared/types'; import { createClerkBridge } from '../create-clerk-bridge'; @@ -23,6 +23,15 @@ vi.mock('electron', () => ({ }, })); +vi.mock('@clerk/electron-passkeys', () => ({ + default: { + isAvailable: () => true, + capabilities: () => ({ platformAuthenticator: true, securityKeys: true }), + createCredential: vi.fn(), + getCredential: vi.fn(), + }, +})); + describe('createClerkBridge', () => { const missingStorage = {} as Parameters[0]; const storage: TokenStorage = { @@ -100,6 +109,32 @@ describe('createClerkBridge', () => { expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); }); + it('does not register passkey IPC handlers by default', () => { + createClerkBridge({ 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 are enabled', () => { + createClerkBridge({ 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 = createClerkBridge({ storage, passkeys: true }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(6); + }); + it('cleans up OAuth transport handlers when renderer origin is configured', () => { const clerk = createClerkBridge({ storage, 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..a4664c766f1 --- /dev/null +++ b/packages/electron/src/main/__tests__/passkey-handlers.test.ts @@ -0,0 +1,153 @@ +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 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: {} }; + +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('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(); + + 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/create-clerk-bridge.ts b/packages/electron/src/main/create-clerk-bridge.ts index 2dcb822c9e6..5084ab07bd1 100644 --- a/packages/electron/src/main/create-clerk-bridge.ts +++ b/packages/electron/src/main/create-clerk-bridge.ts @@ -3,6 +3,7 @@ import { protocol } from 'electron'; import type { ClerkBridge, CreateClerkBridgeOptions } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; import { setupOAuthTransportIpcHandlers } from './oauth-transport'; +import { setupPasskeysMain } from './passkey-handlers'; function assertValidRendererOriginConfig(renderer: NonNullable): void { if (renderer.scheme.includes(':') || renderer.scheme.includes('/')) { @@ -34,6 +35,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); let cleanupOAuthTransport: (() => void) | undefined; + const passkeys = options.passkeys ? setupPasskeysMain() : null; if (options.renderer) { assertValidRendererOriginConfig(options.renderer); @@ -60,6 +62,7 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg cleanup() { cleanupTokenPersistence(); cleanupOAuthTransport?.(); + passkeys?.cleanup(); }, }; } diff --git a/packages/electron/src/main/passkey-handlers.ts b/packages/electron/src/main/passkey-handlers.ts new file mode 100644 index 00000000000..408dc50023e --- /dev/null +++ b/packages/electron/src/main/passkey-handlers.ts @@ -0,0 +1,147 @@ +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: createClerkBridge({ passkeys: true }) 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.' }, + }; + } + + // 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 { + 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..60c80d7ba43 --- /dev/null +++ b/packages/electron/src/passkeys/index.ts @@ -0,0 +1,185 @@ +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 enable the native passkey module with `passkeys: true` in createClerkBridge() and exposeClerkBridge().', + { 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 }; +} + +/** + * 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()`. + * + * @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..03a48692616 --- /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. Pass `passkeys: true` to exposeClerkBridge() in your preload script and createClerkBridge() 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..903be9bc401 --- /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, + } as PublicKeyCredentialWithAuthenticatorAttestationResponse & { toJSON: () => RegistrationResponseJSON }; +} + +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/__tests__/index.test.ts b/packages/electron/src/preload/__tests__/index.test.ts index b51a20554f5..9dbcece716e 100644 --- a/packages/electron/src/preload/__tests__/index.test.ts +++ b/packages/electron/src/preload/__tests__/index.test.ts @@ -62,6 +62,26 @@ describe('exposeClerkBridge', () => { }); }); + it('does not expose the passkey bridge by default', () => { + exposeClerkBridge(); + + 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', () => { + exposeClerkBridge({ 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 () => { exposeClerkBridge(); diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index 248ff5a9c6e..aabb8946253 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -1,7 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; +import { setupPasskeysPreload } from '../passkeys/preload'; import { OAUTH_TRANSPORT_CHANNELS, TOKEN_CACHE_CHANNELS } from '../shared/ipc'; -import type { OAuthTransport, TokenCache } from '../shared/types'; +import type { ExposeClerkBridgeOptions, OAuthTransport, TokenCache } from '../shared/types'; + +export { setupPasskeysPreload }; /** * Exposes Clerk's Electron bridge from the preload script to the renderer. @@ -9,7 +12,7 @@ import type { OAuthTransport, TokenCache } from '../shared/types'; * Call this from an Electron preload script. It publishes a narrow internal bridge used by * `@clerk/electron/react` for token storage and OAuth transport. */ -export function exposeClerkBridge(): void { +export function exposeClerkBridge(options?: ExposeClerkBridgeOptions): void { const tokenCache: TokenCache = { getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value), @@ -26,4 +29,8 @@ export function exposeClerkBridge(): void { } else { window.__clerk_internal_electron = bridge; } + + 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 0914feef2ed..096bb49097a 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -110,6 +110,66 @@ describe('Electron ClerkProvider', () => { expect(oauthTransport.open).toHaveBeenCalledWith('https://accounts.example.com/oauth'); }); + 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); @@ -122,10 +182,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..81317d464d6 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -1,21 +1,41 @@ 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'); + 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/src/react/index.tsx b/packages/electron/src/react/index.tsx index a4efb14ed61..4d8418d52f8 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -4,6 +4,7 @@ import { loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; import type { ClerkUIConstructor } from '@clerk/shared/ui'; import type { ReactNode } from 'react'; +import type { PasskeySupport } from '../passkeys'; import { createClerkInstance } from './create-clerk-instance'; type ClerkOAuthTransport = NonNullable; @@ -17,6 +18,18 @@ 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; }; let cachedClerkUI: { promise: Promise; publishableKey: string } | null = null; @@ -65,8 +78,8 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined { }; } -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); const oauthTransport = createOAuthTransport(); const clerkUI = loadClerkUI(publishableKey, props); diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts index 03fe0349a95..94251290191 100644 --- a/packages/electron/src/shared/ipc.ts +++ b/packages/electron/src/shared/ipc.ts @@ -8,3 +8,9 @@ export const OAUTH_TRANSPORT_CHANNELS = { getRedirectUrl: 'clerk:oauth-transport:get-redirect-url', open: 'clerk:oauth-transport:open', } 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 4486ad0b2d5..7db6158693e 100644 --- a/packages/electron/src/shared/types.ts +++ b/packages/electron/src/shared/types.ts @@ -15,6 +15,18 @@ export type CreateClerkBridgeOptions = { * Registers the custom scheme used to serve the Electron renderer from a stable origin. */ renderer?: RendererSchemeOptions; + /** + * Registers the IPC handlers for native passkey ceremonies. Native support also requires + * the optional `@clerk/electron-passkeys` package and `exposeClerkBridge({ passkeys: true })`. + */ + passkeys?: boolean; +}; + +export type ExposeClerkBridgeOptions = { + /** + * Exposes the native passkey bridge to the renderer. Requires `createClerkBridge({ passkeys: true })`. + */ + passkeys?: boolean; }; export type ClerkBridge = { @@ -45,3 +57,95 @@ export type OAuthTransport = { getRedirectUrl: () => Promise; open: (url: string) => Promise<{ callbackUrl: string }>; }; + +// 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 a8beef878b5..061eb09358a 100644 --- a/packages/electron/tsup.config.ts +++ b/packages/electron/tsup.config.ts @@ -9,8 +9,15 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/index.ts', './src/preload/index.ts', './src/react/index.tsx', './src/storage/index.ts'], + entry: [ + './src/index.ts', + './src/preload/index.ts', + './src/react/index.tsx', + './src/storage/index.ts', + './src/passkeys/index.ts', + ], bundle: true, + external: ['@clerk/electron-passkeys'], clean: true, minify: false, sourcemap: true, 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94f99883a57..164be5bfefb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,7 +388,7 @@ importers: version: 9.0.2 vitest-environment-miniflare: specifier: 2.14.4 - version: 2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@4.1.4) + version: 2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@25.6.0)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) packages/chrome-extension: dependencies: @@ -535,6 +535,9 @@ importers: '@clerk/shared': specifier: workspace:^ version: link:../shared + '@clerk/ui': + specifier: workspace:^ + version: link:../ui react: specifier: 18.3.1 version: 18.3.1 @@ -545,6 +548,9 @@ importers: 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 @@ -555,6 +561,33 @@ importers: specifier: ^8.2.0 version: 8.2.0 + packages/electron-passkeys: + devDependencies: + '@napi-rs/cli': + specifier: ^3.0.0 + version: 3.7.2(@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': @@ -2955,7 +2988,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==} @@ -3377,6 +3410,15 @@ packages: 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.1.1': resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -3395,6 +3437,24 @@ packages: '@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'} @@ -3404,10 +3464,82 @@ 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.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.7': resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -3611,6 +3743,268 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} + '@napi-rs/cli@3.7.2': + resolution: {integrity: sha512-shDW0Td/XZQpP04Yy+OsMt1ILMKGGkoLcy1zVAsSAK0fLfWm0Upgkmfs/NOV2ZhMQwkgpR3ZEdyHmTwgrUDQuA==} + 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==} @@ -3620,6 +4014,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==} @@ -3816,10 +4295,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'} @@ -3828,31 +4319,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'} @@ -3861,9 +4385,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==} @@ -7147,6 +7678,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'} @@ -7580,6 +8114,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==} @@ -7789,6 +8328,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'} @@ -8451,6 +8994,14 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + emnapi@1.11.1: + resolution: {integrity: sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA==} + peerDependencies: + node-addon-api: '>= 6.1.0' + peerDependenciesMeta: + node-addon-api: + optional: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -10517,8 +11068,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsbn@0.1.1: @@ -10606,6 +11157,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 @@ -11950,8 +12504,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -12895,7 +13450,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -14448,6 +15002,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'} @@ -14720,6 +15277,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'} @@ -15760,7 +16320,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - js-yaml: 4.1.1 + js-yaml: 4.2.0 picomatch: 4.0.4 retext-smartypants: 6.2.0 shiki: 4.1.0 @@ -17578,7 +18138,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -18189,7 +18749,7 @@ snapshots: '@babel/code-frame': 7.10.4 chalk: 4.1.2 find-up: 5.0.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 '@faker-js/faker@9.9.0': {} @@ -18377,6 +18937,15 @@ snapshots: '@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.1.1(@types/node@22.19.17)': dependencies: '@inquirer/core': 11.2.1(@types/node@22.19.17) @@ -18417,6 +18986,21 @@ snapshots: 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 @@ -18424,8 +19008,76 @@ 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.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.7(@types/node@22.19.17)': optionalDependencies: '@types/node': 22.19.17 @@ -18794,6 +19446,202 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/cli@3.7.2(@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.11.1(node-addon-api@7.1.1) + es-toolkit: 1.47.0 + js-yaml: 4.2.0 + obug: 2.1.3 + semver: 7.8.2 + 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 @@ -18808,6 +19656,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/mdx@15.5.19(@mdx-js/loader@3.1.1(webpack@5.102.1(esbuild@0.27.7)))(@mdx-js/react@3.1.1(@types/react@18.3.28)(react@18.3.1))': @@ -19151,6 +20062,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 @@ -19161,6 +20074,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 @@ -19172,28 +20100,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 @@ -19208,10 +20171,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': @@ -21971,20 +22945,6 @@ snapshots: - vite optional: true - '@vitest/browser-playwright@4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4)': - dependencies: - '@vitest/browser': 4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4) - '@vitest/mocker': 4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) - playwright: 1.59.1 - tinyrainbow: 3.1.0 - vitest: 4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser@4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4)': dependencies: '@blazediff/core': 1.9.1 @@ -22003,24 +22963,6 @@ snapshots: - vite optional: true - '@vitest/browser@4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4)': - dependencies: - '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) - '@vitest/utils': 4.1.4 - magic-string: 0.30.21 - pngjs: 7.0.0 - sirv: 3.0.2 - tinyrainbow: 3.1.0 - vitest: 4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 @@ -22066,23 +23008,23 @@ snapshots: msw: 2.14.2(@types/node@22.19.17)(typescript@6.0.3) vite: 6.4.2(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.4 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3) vite: 6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3) - vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite: 6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -22881,13 +23823,13 @@ snapshots: github-slugger: 2.0.0 html-escaper: 3.0.3 http-cache-semantics: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 jsonc-parser: 3.3.1 magic-string: 0.30.21 magicast: 0.5.3 mrmime: 2.0.1 neotraverse: 0.6.18 - obug: 2.1.1 + obug: 2.1.3 p-limit: 7.3.0 p-queue: 9.3.0 package-manager-detector: 1.6.0 @@ -23150,6 +24092,8 @@ snapshots: before-after-hook@2.2.3: {} + before-after-hook@4.0.0: {} + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -23661,6 +24605,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 @@ -23855,6 +24803,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + conventional-changelog-angular@8.3.1: dependencies: compare-func: 2.0.0 @@ -23943,7 +24893,7 @@ snapshots: cosmiconfig@8.3.6(typescript@6.0.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -23953,7 +24903,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -24543,6 +25493,10 @@ snapshots: transitivePeerDependencies: - supports-color + emnapi@1.11.1(node-addon-api@7.1.1): + optionalDependencies: + node-addon-api: 7.1.1 + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -27124,7 +28078,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -27267,6 +28221,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.8: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -29328,7 +30284,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obug@2.1.1: {} + obug@2.1.3: {} ofetch@1.5.1: dependencies: @@ -30861,7 +31817,7 @@ snapshots: birpc: 4.0.0 dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 - obug: 2.1.1 + obug: 2.1.3 rolldown: 1.1.0 optionalDependencies: typescript: 6.0.3 @@ -32085,7 +33041,7 @@ snapshots: empathic: 2.0.1 hookable: 6.1.1 import-without-cache: 0.4.0 - obug: 2.1.1 + obug: 2.1.3 picomatch: 4.0.4 rolldown: 1.1.0 rolldown-plugin-dts: 0.25.2(rolldown@1.1.0)(typescript@6.0.3)(vue-tsc@3.2.4(typescript@6.0.3)) @@ -32163,6 +33119,8 @@ snapshots: tweetnacl@0.14.5: {} + typanion@3.14.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -32447,6 +33405,8 @@ snapshots: universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@1.0.0: {} @@ -32465,7 +33425,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@vue/reactivity': 3.5.35 - obug: 2.1.1 + obug: 2.1.3 unplugin: 2.3.11 vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) vue: 3.5.33(typescript@6.0.3) @@ -32696,11 +33656,32 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@5.3.0(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0): dependencies: cac: 6.7.14 es-module-lexer: 2.1.0 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: @@ -32867,14 +33848,14 @@ snapshots: dependencies: '@types/chrome': 0.0.114 - vitest-environment-miniflare@2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@4.1.4): + vitest-environment-miniflare@2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@25.6.0)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10) undici: 5.28.4 - vitest: 4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@25.6.0)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -32924,43 +33905,55 @@ snapshots: - tsx - yaml - vitest@4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@25.6.0)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0): dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.1.0 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 4.1.0 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 0.3.2 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 vite: 6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 5.0.0 - '@opentelemetry/api': 1.9.0 + '@types/debug': 4.1.12 '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4) - '@vitest/coverage-v8': 3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) happy-dom: 18.0.1 jsdom: 27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: + - jiti + - less + - lightningcss - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml - vitest@4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@4.1.4(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)))(happy-dom@18.0.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -32969,7 +33962,7 @@ snapshots: es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 picomatch: 4.0.4 std-env: 4.1.0 @@ -32977,13 +33970,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + vite: 6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 5.0.0 '@opentelemetry/api': 1.9.0 '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4) + '@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.4) '@vitest/coverage-v8': 3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.14.2(@types/node@22.19.17)(typescript@6.0.3))(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) happy-dom: 18.0.1 jsdom: 27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -33330,7 +34323,7 @@ snapshots: '@oozcitak/dom': 2.0.2 '@oozcitak/infra': 2.0.2 '@oozcitak/util': 10.0.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 xmlbuilder@11.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a09bc6126d2..04cc9a5cb28 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - packages/electron-passkeys/npm/* catalogs: peer-react: