diff --git a/.github/workflows/electron-passkeys.yml.template b/.github/workflows/electron-passkeys.yml.template new file mode 100644 index 00000000000..67d0d0bb1fc --- /dev/null +++ b/.github/workflows/electron-passkeys.yml.template @@ -0,0 +1,105 @@ +name: Electron Passkeys Native Build + +on: + workflow_dispatch: + pull_request: + paths: + - 'packages/electron-passkeys/**' + - '.github/workflows/electron-passkeys.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.host }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + settings: + - host: macos-14 + target: aarch64-apple-darwin + - host: macos-14 + target: x86_64-apple-darwin + - host: windows-latest + target: x86_64-pc-windows-msvc + - host: windows-latest + target: aarch64-pc-windows-msvc + defaults: + run: + working-directory: packages/electron-passkeys + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: pnpm/action-setup@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.settings.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/electron-passkeys + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + working-directory: . + + - name: Build native module + run: pnpm build --target ${{ matrix.settings.target }} + + - name: Smoke test (host-native targets only) + if: matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' + run: node -e "const m = require('./index.js'); console.log('isAvailable:', m.isAvailable(), 'capabilities:', JSON.stringify(m.capabilities())); 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 --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..23210838918 --- /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 `setupMain({ 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/package.json b/packages/electron-passkeys/npm/darwin-arm64/package.json new file mode 100644 index 00000000000..5668b5293b5 --- /dev/null +++ b/packages/electron-passkeys/npm/darwin-arm64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-darwin-arm64", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (macOS arm64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.darwin-arm64.node", + "files": [ + "electron-passkeys.darwin-arm64.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/darwin-x64/package.json b/packages/electron-passkeys/npm/darwin-x64/package.json new file mode 100644 index 00000000000..4848e7e3de3 --- /dev/null +++ b/packages/electron-passkeys/npm/darwin-x64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-darwin-x64", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (macOS x64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.darwin-x64.node", + "files": [ + "electron-passkeys.darwin-x64.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-arm64-msvc/package.json b/packages/electron-passkeys/npm/win32-arm64-msvc/package.json new file mode 100644 index 00000000000..8ae61b2924f --- /dev/null +++ b/packages/electron-passkeys/npm/win32-arm64-msvc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-win32-arm64-msvc", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (Windows arm64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.win32-arm64-msvc.node", + "files": [ + "electron-passkeys.win32-arm64-msvc.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/npm/win32-x64-msvc/package.json b/packages/electron-passkeys/npm/win32-x64-msvc/package.json new file mode 100644 index 00000000000..83c3eebad0b --- /dev/null +++ b/packages/electron-passkeys/npm/win32-x64-msvc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/electron-passkeys-win32-x64-msvc", + "version": "0.0.0", + "description": "Native passkey support for Clerk's Electron SDK (Windows x64)", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "electron-passkeys.win32-x64-msvc.node", + "files": [ + "electron-passkeys.win32-x64-msvc.node" + ], + "engines": { + "node": ">=20.9.0" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron-passkeys/package.json b/packages/electron-passkeys/package.json new file mode 100644 index 00000000000..cb16ff17313 --- /dev/null +++ b/packages/electron-passkeys/package.json @@ -0,0 +1,60 @@ +{ + "name": "@clerk/electron-passkeys", + "version": "0.0.0", + "description": "Native passkey (WebAuthn) support for Clerk's Electron SDK", + "keywords": [ + "clerk", + "electron", + "passkeys", + "webauthn", + "auth", + "authentication" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/electron-passkeys" + }, + "license": "MIT", + "author": "Clerk", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts" + ], + "scripts": { + "artifacts": "napi artifacts --output-dir artifacts --npm-dir npm", + "build": "napi build --platform --release --no-js --dts native.d.ts", + "build:debug": "napi build --platform --no-js --dts native.d.ts", + "test": "node --test test/loader.test.cjs" + }, + "devDependencies": { + "@napi-rs/cli": "^3.0.0" + }, + "optionalDependencies": { + "@clerk/electron-passkeys-darwin-arm64": "workspace:*", + "@clerk/electron-passkeys-darwin-x64": "workspace:*", + "@clerk/electron-passkeys-win32-arm64-msvc": "workspace:*", + "@clerk/electron-passkeys-win32-x64-msvc": "workspace:*" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + }, + "napi": { + "binaryName": "electron-passkeys", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + } +} diff --git a/packages/electron-passkeys/rustfmt.toml b/packages/electron-passkeys/rustfmt.toml new file mode 100644 index 00000000000..a54c7795ebc --- /dev/null +++ b/packages/electron-passkeys/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2021" +max_width = 110 diff --git a/packages/electron-passkeys/src/lib.rs b/packages/electron-passkeys/src/lib.rs new file mode 100644 index 00000000000..bcfb8a525db --- /dev/null +++ b/packages/electron-passkeys/src/lib.rs @@ -0,0 +1,258 @@ +//! Native passkey (WebAuthn) support for Electron, exposed through napi-rs. +//! +//! Ceremony failures resolve to a JSON result envelope instead of rejecting, so +//! JS callers can handle user-facing WebAuthn failures without try/catch. +//! Invalid input uses the same error envelope. + +#![deny(clippy::all)] + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use serde::Deserialize; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +// Binary fields are base64url without padding. Some fields are platform-specific, +// but the wire contract stays the same across targets. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RpEntity { + pub id: String, + #[allow(dead_code)] + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserEntity { + /// base64url-encoded user handle. + pub id: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PubKeyCredParam { + #[serde(rename = "type", default)] + pub _type: Option, + pub alg: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AuthenticatorSelection { + #[serde(default)] + pub authenticator_attachment: Option, + #[serde(default)] + pub require_resident_key: Option, + #[serde(default)] + pub resident_key: Option, + #[serde(default)] + pub user_verification: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CredentialDescriptor { + #[serde(rename = "type", default)] + pub _type: Option, + /// base64url-encoded credential id. + pub id: String, + #[allow(dead_code)] + #[serde(default)] + pub transports: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreationOptions { + pub rp: RpEntity, + pub user: UserEntity, + /// base64url-encoded challenge. + pub challenge: String, + #[serde(default)] + pub pub_key_cred_params: Option>, + #[allow(dead_code)] + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub authenticator_selection: Option, + #[serde(default)] + pub attestation: Option, + #[serde(default)] + pub exclude_credentials: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RequestOptions { + /// base64url-encoded challenge. + pub challenge: String, + pub rp_id: String, + #[allow(dead_code)] + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub user_verification: Option, + #[serde(default)] + pub allow_credentials: Option>, +} + +pub(crate) fn ok_envelope(credential: serde_json::Value) -> String { + serde_json::json!({ "ok": true, "credential": credential }).to_string() +} + +pub(crate) fn err_envelope(code: &str, message: &str) -> String { + serde_json::json!({ + "ok": false, + "error": { "code": code, "message": message }, + }) + .to_string() +} + +pub(crate) fn b64url_encode(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +pub(crate) fn b64url_decode(input: &str) -> Result, base64::DecodeError> { + URL_SAFE_NO_PAD.decode(input.trim_end_matches('=')) +} + +/// Reads the native pointer from Electron's `BrowserWindow#getNativeWindowHandle()`. +#[allow(dead_code)] +pub(crate) fn window_handle_from_bytes(bytes: &[u8]) -> Option { + const PTR_LEN: usize = std::mem::size_of::(); + if bytes.len() < PTR_LEN { + return None; + } + let mut raw = [0u8; PTR_LEN]; + raw.copy_from_slice(&bytes[..PTR_LEN]); + Some(usize::from_le_bytes(raw)) +} + +#[napi(object)] +pub struct Capabilities { + pub platform_authenticator: bool, + pub security_keys: bool, +} + +#[napi] +pub fn is_available() -> bool { + #[cfg(target_os = "macos")] + { + macos::is_available() + } + #[cfg(target_os = "windows")] + { + windows::is_available() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + false + } +} + +#[napi] +pub fn capabilities() -> Capabilities { + #[cfg(target_os = "macos")] + { + let (platform_authenticator, security_keys) = macos::capabilities(); + Capabilities { + platform_authenticator, + security_keys, + } + } + #[cfg(target_os = "windows")] + { + let (platform_authenticator, security_keys) = windows::capabilities(); + Capabilities { + platform_authenticator, + security_keys, + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Capabilities { + platform_authenticator: false, + security_keys: false, + } + } +} + +#[napi] +pub async fn create_credential(window_handle: Buffer, options_json: String) -> napi::Result { + // Do not hold JS-owned memory across await points or threads. + let handle_bytes = window_handle.to_vec(); + Ok(create_credential_impl(handle_bytes, options_json).await) +} + +#[napi] +pub async fn get_credential(window_handle: Buffer, options_json: String) -> napi::Result { + let handle_bytes = window_handle.to_vec(); + Ok(get_credential_impl(handle_bytes, options_json).await) +} + +#[allow(unused_variables)] +async fn create_credential_impl(handle_bytes: Vec, options_json: String) -> String { + let handle = match window_handle_from_bytes(&handle_bytes) { + Some(h) => h, + None => return err_envelope("unknown", "Invalid window handle buffer"), + }; + let options: CreationOptions = match serde_json::from_str(&options_json) { + Ok(o) => o, + Err(e) => return err_envelope("unknown", &format!("Failed to parse creation options: {e}")), + }; + + #[cfg(target_os = "macos")] + { + macos::create_credential(handle, options).await + } + #[cfg(target_os = "windows")] + { + windows::create_credential(handle, options).await + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + err_envelope( + "not_supported", + "Native passkeys are not supported on this platform", + ) + } +} + +#[allow(unused_variables)] +async fn get_credential_impl(handle_bytes: Vec, options_json: String) -> String { + let handle = match window_handle_from_bytes(&handle_bytes) { + Some(h) => h, + None => return err_envelope("unknown", "Invalid window handle buffer"), + }; + let options: RequestOptions = match serde_json::from_str(&options_json) { + Ok(o) => o, + Err(e) => return err_envelope("unknown", &format!("Failed to parse request options: {e}")), + }; + + #[cfg(target_os = "macos")] + { + macos::get_credential(handle, options).await + } + #[cfg(target_os = "windows")] + { + windows::get_credential(handle, options).await + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + err_envelope( + "not_supported", + "Native passkeys are not supported on this platform", + ) + } +} diff --git a/packages/electron-passkeys/src/macos.rs b/packages/electron-passkeys/src/macos.rs new file mode 100644 index 00000000000..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 08bbb31c2bd..06bcc75e938 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 @@ -81,6 +82,78 @@ import { ClerkProvider } from '@clerk/electron/react'; {/* ... */}; ``` +## Passkeys + +Passkey support works in two modes, selected automatically per request: + +- **Renderer mode** — when your window loads content over `https://` from an origin that matches your passkey RP ID, the renderer's built-in Chromium WebAuthn is used. Credentials are synced by the OS/browser ecosystem (Windows Hello works out of the box; Touch ID on macOS requires Electron ≥ 42 and [`app.configureWebAuthn`](https://www.electronjs.org/docs/latest/api/app#appconfigurewebauthnoptions-macos)). +- **Native mode** — when your window loads a local bundle (`file://` or a custom protocol), WebAuthn's origin checks reject the request, so the ceremony is routed over IPC to the main process and serviced by the OS WebAuthn APIs (AuthenticationServices on macOS, `webauthn.dll` on Windows) via the optional [`@clerk/electron-passkeys`](https://github.com/clerk/javascript/tree/main/packages/electron-passkeys) native module. + +### Setup + +Native mode requires the optional native module: + +```sh +pnpm add @clerk/electron-passkeys +``` + +```ts +// main process +import { setupMain } from '@clerk/electron'; +import { storage } from '@clerk/electron/storage'; + +setupMain({ storage: storage(), passkeys: true }); +``` + +```ts +// preload script +import { setupPreload } from '@clerk/electron/preload'; + +setupPreload({ 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 5347bcfed38..0d65dc38d48 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -54,6 +54,16 @@ "default": "./dist/cjs/storage/index.js" } }, + "./passkeys": { + "import": { + "types": "./dist/types/passkeys/index.d.ts", + "default": "./dist/esm/passkeys/index.js" + }, + "require": { + "types": "./dist/types/passkeys/index.d.ts", + "default": "./dist/cjs/passkeys/index.js" + } + }, "./react": { "import": { "types": "./dist/types/react/index.d.ts", @@ -90,21 +100,27 @@ "dependencies": { "@clerk/clerk-js": "workspace:^", "@clerk/react": "workspace:^", + "@clerk/shared": "workspace:^", "@clerk/ui": "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 f14b55c0736..e1e84ca2540 100644 --- a/packages/electron/src/global.d.ts +++ b/packages/electron/src/global.d.ts @@ -1,14 +1,15 @@ -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; }; + __clerk_internal_electron_passkeys?: PasskeyBridge; } } diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index ea95302fb59..cbba18b8e07 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1 +1,2 @@ export { setupMain } from './main/setup-main'; +export type { SetupMainOptions, SetupPreloadOptions, TokenStorage } from './shared/types'; diff --git a/packages/electron/src/main/__tests__/passkey-handlers.test.ts b/packages/electron/src/main/__tests__/passkey-handlers.test.ts 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/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts index e0f5da4cf51..b8b21ab0c5c 100644 --- a/packages/electron/src/main/__tests__/setup-main.test.ts +++ b/packages/electron/src/main/__tests__/setup-main.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 { setupMain } from '../setup-main'; @@ -11,6 +11,9 @@ vi.mock('electron', () => ({ removeListener: vi.fn(), setAsDefaultProtocolClient: vi.fn(), }, + BrowserWindow: { + fromWebContents: vi.fn(), + }, ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), @@ -23,6 +26,15 @@ vi.mock('electron', () => ({ }, })); +vi.mock('@clerk/electron-passkeys', () => ({ + default: { + isAvailable: () => true, + capabilities: () => ({ platformAuthenticator: true, securityKeys: true }), + createCredential: vi.fn(), + getCredential: vi.fn(), + }, +})); + describe('setupMain', () => { const missingStorage = {} as Parameters[0]; const storage: TokenStorage = { @@ -100,6 +112,32 @@ describe('setupMain', () => { expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); }); + it('does not register passkey IPC handlers by default', () => { + setupMain({ storage }); + + const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel); + expect(channels).not.toContain(PASSKEY_CHANNELS.create); + expect(channels).not.toContain(PASSKEY_CHANNELS.get); + expect(channels).not.toContain(PASSKEY_CHANNELS.capabilities); + }); + + it('registers passkey IPC handlers when passkeys is enabled', () => { + setupMain({ storage, passkeys: true }); + + const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel); + expect(channels).toContain(PASSKEY_CHANNELS.create); + expect(channels).toContain(PASSKEY_CHANNELS.get); + expect(channels).toContain(PASSKEY_CHANNELS.capabilities); + }); + + it('cleans up passkey handlers together with the token handlers', () => { + const clerk = setupMain({ storage, passkeys: true }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(6); + }); + it('cleans up OAuth transport handlers when renderer origin is configured', () => { const clerk = setupMain({ storage, diff --git a/packages/electron/src/main/passkey-handlers.ts b/packages/electron/src/main/passkey-handlers.ts new file mode 100644 index 00000000000..c90db867da8 --- /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: setupMain({ 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/main/setup-main.ts b/packages/electron/src/main/setup-main.ts index ff283ca922b..79f72539a7e 100644 --- a/packages/electron/src/main/setup-main.ts +++ b/packages/electron/src/main/setup-main.ts @@ -3,6 +3,7 @@ import { protocol } from 'electron'; import type { SetupMainOptions, SetupMainReturn } 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('/')) { @@ -26,6 +27,7 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { } const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage); + const passkeys = options.passkeys ? setupPasskeysMain() : null; let cleanupOAuthTransport: (() => void) | undefined; if (options.renderer) { @@ -53,6 +55,7 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn { cleanup() { cleanupTokenPersistence(); cleanupOAuthTransport?.(); + passkeys?.cleanup(); }, }; } 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..b8623565fe1 --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/index.test.ts @@ -0,0 +1,348 @@ +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 DEV_PUBLISHABLE_KEY = 'pk_test_electron'; +const LIVE_PUBLISHABLE_KEY = 'pk_live_electron'; + +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 renderer path on a localhost origin for development publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys({ publishableKey: DEV_PUBLISHABLE_KEY }).create(creationOptions()); + + expect(result).toBe(rendererResult); + expect(bridge.create).not.toHaveBeenCalled(); + }); + + it('uses the native path on a localhost origin for production publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + + await createPasskeys({ publishableKey: LIVE_PUBLISHABLE_KEY }).create(creationOptions()); + + expect(bridge.create).toHaveBeenCalled(); + expect(webAuthnCreateCredential).not.toHaveBeenCalled(); + }); + + it('uses the native path for local bundles', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'file:', hostname: '', bridge }); + + 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); + }); + + it('uses the renderer path on a loopback origin for development publishable keys', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: '127.0.0.1', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnGetCredential).mockResolvedValue(rendererResult); + + const result = await createPasskeys({ publishableKey: DEV_PUBLISHABLE_KEY }).get({ + publicKeyOptions: requestOptions(), + }); + + expect(webAuthnGetCredential).toHaveBeenCalledWith({ + publicKeyOptions: expect.anything(), + conditionalUI: false, + }); + expect(result).toBe(rendererResult); + expect(bridge.get).not.toHaveBeenCalled(); + }); + }); + + describe('capability checks', () => { + 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(); + }); + + it('uses the Clerk publishable key when options do not include one', async () => { + const bridge = makeBridge(); + stubEnvironment({ protocol: 'http:', hostname: 'localhost', bridge }); + const rendererResult = { publicKeyCredential: {} as never, error: null }; + vi.mocked(webAuthnCreateCredential).mockResolvedValue(rendererResult); + const clerk: ClerkPasskeyHost = { + publishableKey: DEV_PUBLISHABLE_KEY, + __internal_createPublicCredentials: undefined, + __internal_getPublicCredentials: undefined, + __internal_isWebAuthnSupported: undefined, + __internal_isWebAuthnAutofillSupported: undefined, + __internal_isWebAuthnPlatformAuthenticatorSupported: undefined, + }; + + createPasskeyProvider(clerk); + const result = await clerk.__internal_createPublicCredentials?.(creationOptions()); + + expect(result).toBe(rendererResult); + expect(webAuthnCreateCredential).toHaveBeenCalled(); + expect(bridge.create).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/electron/src/passkeys/__tests__/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..0f9fd596a10 --- /dev/null +++ b/packages/electron/src/passkeys/__tests__/strategy.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import type { StrategyEnv } from '../renderer/strategy'; +import { decidePath, isLoopbackHost, 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, + isDevelopmentInstance: false, + ...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('isLoopbackHost', () => { + it.each([ + ['localhost', true], + ['127.0.0.1', true], + ['::1', true], + ['[::1]', true], + ['app.localhost', false], + ['0.0.0.0', false], + ['example.com', false], + ])('%s -> %s', (hostname, expected) => { + expect(isLoopbackHost(hostname)).toBe(expected); + }); +}); + +describe('decidePath', () => { + describe('forced modes', () => { + it('renderer mode uses the renderer whenever WebAuthn exists, regardless of origin', () => { + 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'); + }); + + it.each(['localhost', '127.0.0.1', '::1', '[::1]'])( + 'uses the renderer for development instances on loopback host %s', + hostname => { + expect( + decidePath( + RP_ID, + 'auto', + env({ + protocol: 'http:', + hostname, + isDevelopmentInstance: true, + }), + ), + ).toBe('renderer'); + }, + ); + + it('falls back to native for production instances on loopback hosts', () => { + expect(decidePath(RP_ID, 'auto', env({ protocol: 'http:', hostname: 'localhost' }))).toBe('native'); + }); + + it('falls back to native for development instances on non-loopback http hosts', () => { + expect( + decidePath( + RP_ID, + 'auto', + env({ protocol: 'http:', hostname: 'preview.example.test', isDevelopmentInstance: true }), + ), + ).toBe('native'); + }); + }); +}); diff --git a/packages/electron/src/passkeys/index.ts b/packages/electron/src/passkeys/index.ts new file mode 100644 index 00000000000..38b6da4119c --- /dev/null +++ b/packages/electron/src/passkeys/index.ts @@ -0,0 +1,205 @@ +import { ClerkWebAuthnError } from '@clerk/shared/error'; +import { webAuthnCreateCredential, webAuthnGetCredential } from '@clerk/shared/internal/clerk-js/passkeys'; +import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys'; +import type { + CredentialReturn, + PublicKeyCredentialCreationOptionsWithoutExtensions, + 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; + /** + * Clerk publishable key used to enable development-instance behavior. + */ + publishableKey?: string; +}; + +export type PasskeySupport = { + create: ( + publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions, + ) => Promise>; + get: (args: { + publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions; + }) => Promise>; + isSupported: () => boolean; + isAutoFillSupported: () => Promise; + isPlatformAuthenticatorSupported: () => Promise; + __internal_withPublishableKey?: (publishableKey: string) => PasskeySupport; +}; + +export type ClerkPasskeyHost = { + publishableKey?: string; + __internal_createPublicCredentials: PasskeySupport['create'] | undefined; + __internal_getPublicCredentials: PasskeySupport['get'] | undefined; + __internal_isWebAuthnSupported: PasskeySupport['isSupported'] | undefined; + __internal_isWebAuthnAutofillSupported: PasskeySupport['isAutoFillSupported'] | undefined; + __internal_isWebAuthnPlatformAuthenticatorSupported: PasskeySupport['isPlatformAuthenticatorSupported'] | undefined; +}; + +const NATIVE_PLATFORMS = ['darwin', 'win32']; + +function getEnv(publishableKey?: string): 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, + isDevelopmentInstance: publishableKey ? isDevelopmentFromPublishableKey(publishableKey) : false, + }; +} + +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 setupMain() and setupPreload().', + { 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 publishableKey = options?.publishableKey; + + const create: PasskeySupport['create'] = async publicKey => { + const env = getEnv(publishableKey); + 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(publishableKey); + 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(publishableKey); + 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(publishableKey); + 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(); + }; + + const withPublishableKey: NonNullable = publishableKey => { + return createPasskeys({ ...options, publishableKey }); + }; + + return { + create, + get, + isSupported, + isAutoFillSupported, + isPlatformAuthenticatorSupported, + __internal_withPublishableKey: withPublishableKey, + }; +} + +/** + * 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, publishableKey: options?.publishableKey ?? clerk.publishableKey }); + + 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..f214fb183ca --- /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 setupPreload() in your preload script and setupMain() 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..7844519e7ec --- /dev/null +++ b/packages/electron/src/passkeys/renderer/strategy.ts @@ -0,0 +1,48 @@ +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; + isDevelopmentInstance: boolean; +}; + +export function originSatisfiesRpId(env: Pick, rpId: string): boolean { + if (env.protocol !== 'https:' || !rpId) { + return false; + } + return env.hostname === rpId || env.hostname.endsWith(`.${rpId}`); +} + +export function isLoopbackHost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; +} + +/** + * 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) || (env.isDevelopmentInstance && isLoopbackHost(env.hostname))) + ) { + 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 c90f2bb6b4c..d1a1e3f7f52 100644 --- a/packages/electron/src/preload/__tests__/index.test.ts +++ b/packages/electron/src/preload/__tests__/index.test.ts @@ -62,6 +62,26 @@ describe('setupPreload', () => { }); }); + it('does not expose the passkey bridge by default', () => { + setupPreload(); + + const exposedKeys = vi.mocked(contextBridge.exposeInMainWorld).mock.calls.map(([key]) => key); + expect(exposedKeys).not.toContain('__clerk_internal_electron_passkeys'); + }); + + it('exposes the passkey bridge when passkeys is enabled', () => { + setupPreload({ passkeys: true }); + + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith( + '__clerk_internal_electron_passkeys', + expect.objectContaining({ + create: expect.any(Function), + get: expect.any(Function), + capabilities: expect.any(Function), + }), + ); + }); + it('forwards token cache calls over IPC', async () => { setupPreload(); diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index ea219667b54..fb1f71765e5 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -1,9 +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 { OAuthTransport, SetupPreloadOptions, TokenCache } from '../shared/types'; -export function setupPreload(): void { +export function setupPreload(options?: SetupPreloadOptions): void { const tokenCache: TokenCache = { getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key), saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value), @@ -20,4 +21,8 @@ export function setupPreload(): 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 8bfab7bc695..5d24b89a575 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -104,6 +104,103 @@ 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('passes the publishable key to contextual passkey implementations', () => { + const contextualPasskeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + }; + const passkeys = { + create: vi.fn(), + get: vi.fn(), + isSupported: vi.fn(), + isAutoFillSupported: vi.fn(), + isPlatformAuthenticatorSupported: vi.fn(), + __internal_withPublishableKey: vi.fn(() => contextualPasskeys), + }; + + renderToStaticMarkup( + + App + , + ); + + const clerk = capturedProviderProps?.Clerk as Record; + expect(passkeys.__internal_withPublishableKey).toHaveBeenCalledWith('pk_test_contextual'); + expect(clerk.__internal_createPublicCredentials).toBe(contextualPasskeys.create); + expect(clerk.__internal_getPublicCredentials).toBe(contextualPasskeys.get); + expect(clerk.__internal_isWebAuthnSupported).toBe(contextualPasskeys.isSupported); + expect(clerk.__internal_isWebAuthnAutofillSupported).toBe(contextualPasskeys.isAutoFillSupported); + expect(clerk.__internal_isWebAuthnPlatformAuthenticatorSupported).toBe( + contextualPasskeys.isPlatformAuthenticatorSupported, + ); + }); + it('adds native request params and Authorization from the Electron token cache', async () => { tokenCache.getToken.mockResolvedValue('client-jwt'); renderToStaticMarkup(App); @@ -116,10 +213,48 @@ 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('can disable the Electron SDK version query param', async () => { + tokenCache.getToken.mockResolvedValue(null); + renderToStaticMarkup( + + App + , + ); + + const request = { + headers: new Headers(), + url: new URL('https://api.clerk.test/v1/client'), + }; + await beforeRequest?.(request); + + expect(request.url.searchParams.get('_is_native')).toBe('1'); + expect(request.url.searchParams.has('_electron_sdk_version')).toBe(false); + expect(request.headers.has('Authorization')).toBe(false); + expect(capturedProviderProps?.__internal_disableElectronSdkVersionParam).toBeUndefined(); + }); + it('stores Authorization response headers in the Electron token cache', async () => { renderToStaticMarkup(App); diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts index a56a9f6d850..ced6918a713 100644 --- a/packages/electron/src/react/create-clerk-instance.ts +++ b/packages/electron/src/react/create-clerk-instance.ts @@ -1,21 +1,60 @@ 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; +type CreateClerkInstanceOptions = { + // temporary until FAPI supports this, otherwise you get a CORS error + // TODO: Remove after FAPI properly handles this query param. + disableElectronSdkVersionParam?: boolean; +}; + +let cached: ({ instance: ClerkInstance; publishableKey: string } & CreateClerkInstanceOptions) | null = null; + +function attachPasskeys(clerk: ClerkInstance, passkeys: PasskeySupport, publishableKey: string): void { + const contextualPasskeys = passkeys.__internal_withPublishableKey?.(publishableKey) ?? passkeys; + + clerk.__internal_createPublicCredentials = contextualPasskeys.create; + clerk.__internal_getPublicCredentials = contextualPasskeys.get; + clerk.__internal_isWebAuthnSupported = contextualPasskeys.isSupported; + clerk.__internal_isWebAuthnAutofillSupported = contextualPasskeys.isAutoFillSupported; + clerk.__internal_isWebAuthnPlatformAuthenticatorSupported = contextualPasskeys.isPlatformAuthenticatorSupported; +} + +export function createClerkInstance( + publishableKey: string, + passkeys?: PasskeySupport, + options: CreateClerkInstanceOptions = {}, +): ClerkInstance { + const disableElectronSdkVersionParam = options.disableElectronSdkVersionParam ?? false; -export function createClerkInstance(publishableKey: string): ClerkInstance { - if (cached?.publishableKey === publishableKey) { + if ( + cached?.publishableKey === publishableKey && + cached.disableElectronSdkVersionParam === disableElectronSdkVersionParam + ) { + if (passkeys) { + attachPasskeys(cached.instance, passkeys, publishableKey); + } return cached.instance; } const clerk = new Clerk(publishableKey); + if (passkeys) { + attachPasskeys(clerk, passkeys, publishableKey); + } + clerk.__internal_onBeforeRequest(async request => { request.credentials = 'omit'; request.url?.searchParams.append('_is_native', '1'); + if (!disableElectronSdkVersionParam) { + request.url?.searchParams.append('_electron_sdk_version', PACKAGE_VERSION); + } const token = await window.__clerk_internal_electron?.tokenCache.getToken(CLERK_CLIENT_JWT_KEY); if (token) { @@ -35,6 +74,6 @@ export function createClerkInstance(publishableKey: string): ClerkInstance { await window.__clerk_internal_electron?.tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); }); - cached = { instance: clerk, publishableKey }; + cached = { instance: clerk, publishableKey, disableElectronSdkVersionParam }; return clerk; } diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx index 8f185655e92..545dd1e26aa 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -3,6 +3,7 @@ import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/intern import { ui } from '@clerk/ui'; import type { ReactNode } from 'react'; +import type { PasskeySupport } from '../passkeys'; import { createClerkInstance } from './create-clerk-instance'; type ClerkOAuthTransport = NonNullable; @@ -16,6 +17,22 @@ export type ClerkProviderProps = Omit< * Your Clerk publishable key, available in the Clerk Dashboard. */ publishableKey: string; + /** + * Temporary internal escape hatch to disable the `_electron_sdk_version` request query parameter. + */ + __internal_disableElectronSdkVersionParam?: boolean; + /** + * Enables passkey support. Pass the `passkeys` export from `@clerk/electron/passkeys`; + * when omitted, no passkey code is bundled or initialized. + * + * @example + * ```tsx + * import { passkeys } from '@clerk/electron/passkeys'; + * + * + * ``` + */ + passkeys?: PasskeySupport; }; function createOAuthTransport(): ClerkOAuthTransport | undefined { @@ -31,8 +48,16 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined { }; } -export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element { - const clerk = createClerkInstance(publishableKey); +export function ClerkProvider({ + children, + publishableKey, + passkeys, + __internal_disableElectronSdkVersionParam, + ...props +}: ClerkProviderProps): JSX.Element { + const clerk = createClerkInstance(publishableKey, passkeys, { + disableElectronSdkVersionParam: __internal_disableElectronSdkVersionParam, + }); const oauthTransport = createOAuthTransport(); return ( diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts index 03fe0349a95..f3e8948fa24 100644 --- a/packages/electron/src/shared/ipc.ts +++ b/packages/electron/src/shared/ipc.ts @@ -4,6 +4,12 @@ export const TOKEN_CACHE_CHANNELS = { clearToken: 'clerk:token-cache:clear', } as const; +export const PASSKEY_CHANNELS = { + create: 'clerk:passkeys:create', + get: 'clerk:passkeys:get', + capabilities: 'clerk:passkeys:capabilities', +} as const; + export const OAUTH_TRANSPORT_CHANNELS = { getRedirectUrl: 'clerk:oauth-transport:get-redirect-url', open: 'clerk:oauth-transport:open', diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts index 3c2385e7a0d..a5b6b11f23f 100644 --- a/packages/electron/src/shared/types.ts +++ b/packages/electron/src/shared/types.ts @@ -12,6 +12,18 @@ export type SetupMainOptions = { * 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 `setupPreload({ passkeys: true })`. + */ + passkeys?: boolean; +}; + +export type SetupPreloadOptions = { + /** + * Exposes the native passkey bridge to the renderer. Requires `setupMain({ passkeys: true })`. + */ + passkeys?: boolean; }; export type SetupMainReturn = { @@ -39,3 +51,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 8aff7afae3b..2409488edf8 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@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)) + version: 2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@4.1.4) packages/chrome-extension: dependencies: @@ -532,6 +532,9 @@ importers: '@clerk/react': specifier: workspace:^ version: link:../react + '@clerk/shared': + specifier: workspace:^ + version: link:../shared '@clerk/ui': specifier: workspace:^ version: link:../ui @@ -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.1(@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: {node: '>=0.10.0'} + engines: {'0': 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.1': + resolution: {integrity: sha512-7FkcxJ70SqpsYnyjaYwLVpkP3Xoyd8YkT5g7DevuEvSY7mzpLSva8oSQNENHiQ1T0GMLnae1X46KmiNIu5OXDw==} + 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.0: + resolution: {integrity: sha512-3+AyVLAzk2jr9FPnCT9fgU4/gGoHrB3MpYxqNG6JdmkWmFPpKAhArQgfK/WEiT9kExX2ecXVA6DoPceDShR47g==} + 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==} @@ -10521,6 +11072,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} @@ -10606,6 +11161,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 @@ -11953,6 +12511,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -14448,6 +15010,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 +15285,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'} @@ -18377,6 +18945,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 +18994,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 +19016,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 +19454,202 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/cli@3.7.1(@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.0(node-addon-api@7.1.1) + es-toolkit: 1.47.0 + js-yaml: 4.2.0 + obug: 2.1.2 + 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 +19664,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 +20070,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 +20082,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 +20108,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 +20179,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,6 +22953,20 @@ 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 @@ -21989,6 +22985,24 @@ 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 @@ -22034,23 +23048,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@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/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': 3.2.4 + '@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: 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@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))': 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: 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: 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/pretty-format@3.2.4': dependencies: @@ -23118,6 +24132,8 @@ snapshots: before-after-hook@2.2.3: {} + before-after-hook@4.0.0: {} + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -23629,6 +24645,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 @@ -23823,6 +24843,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + conventional-changelog-angular@8.3.1: dependencies: compare-func: 2.0.0 @@ -24511,6 +25533,10 @@ snapshots: transitivePeerDependencies: - supports-color + emnapi@1.11.0(node-addon-api@7.1.1): + optionalDependencies: + node-addon-api: 7.1.1 + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -27096,6 +28122,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + jsbn@0.1.1: {} jsc-safe-url@0.2.4: {} @@ -27235,6 +28265,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.8: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -29298,6 +30330,8 @@ snapshots: obug@2.1.1: {} + obug@2.1.2: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -32131,6 +33165,8 @@ snapshots: tweetnacl@0.14.5: {} + typanion@3.14.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -32415,6 +33451,8 @@ snapshots: universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@1.0.0: {} @@ -32664,27 +33702,6 @@ 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 @@ -32856,14 +33873,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@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)): + vitest-environment-miniflare@2.14.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@4.1.4): 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: 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) + 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 - utf-8-validate @@ -32913,55 +33930,43 @@ snapshots: - tsx - yaml - 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): + 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: - '@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) + '@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 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.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 - '@types/debug': 4.1.12 + '@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@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@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(@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)): 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/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/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -32978,13 +33983,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.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: 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) 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@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/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/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) 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: