ソースを参照

feat: use Web Crypto API for JWT verification on WASM targets

Replace `jsonwebtoken` (which depends on `ring`, a C/ASM crypto lib that
doesn't compile to WASM) with the browser's Web Crypto API on wasm32 targets.
Native targets continue using `jsonwebtoken`.

- Define own JWK/JwkSet types replacing `jsonwebtoken::jwk::*`
- Add cross-platform `decode_jwt_header` (no crypto dependency)
- cfg-gate `verify_jwt_signature`: native uses `jsonwebtoken::decode`, WASM
  uses `SubtleCrypto.importKey` + `SubtleCrypto.verify`
- Build JWK JS objects manually via `Reflect::set` to avoid
  `serde_wasm_bindgen` producing Maps from `#[serde(flatten)]`
- Add WASM browser tests (wasm-pack + Firefox headless) for RSA/EC signature
  verification, expiration, issuer, and error cases
- Add `wasm-tests` CI job with dedicated Nix `wasm` devShell
- Fix pre-existing `refresh_mint_quote_status` rename in cdk-wasm
Cesar Rodas 3 日 前
コミット
3e6df7e2be

+ 17 - 0
.github/workflows/ci.yml

@@ -308,6 +308,23 @@ jobs:
       - name: Build WASM
         run: nix build -L .#checks.x86_64-linux.${{ matrix.check }}
 
+  wasm-tests:
+    name: "WASM browser tests"
+    runs-on: self-hosted
+    timeout-minutes: 30
+    needs: [pre-commit-checks, check-wasm]
+    steps:
+      - name: checkout
+        uses: actions/checkout@v4
+      - uses: cachix/cachix-action@v16
+        with:
+          name: cashudevkit
+          authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
+          useDaemon: false
+        continue-on-error: true
+      - name: Run WASM browser tests
+        run: nix develop -i -L .#wasm --command wasm-pack test --firefox --headless crates/cdk-wasm
+
   fake-mint-auth-itest:
     name: "Integration fake mint auth tests"
     runs-on: self-hosted

+ 6 - 1
Cargo.lock

@@ -1205,6 +1205,7 @@ dependencies = [
  "hickory-resolver",
  "http 0.2.12",
  "hyper 0.14.32",
+ "js-sys",
  "jsonwebtoken",
  "lightning 0.2.2",
  "lightning-invoice 0.34.0",
@@ -1212,6 +1213,7 @@ dependencies = [
  "rand 0.9.2",
  "rustls 0.23.36",
  "serde",
+ "serde-wasm-bindgen",
  "serde_json",
  "serde_with",
  "thiserror 2.0.18",
@@ -1227,6 +1229,9 @@ dependencies = [
  "url",
  "utoipa",
  "uuid",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
  "web-time",
  "zeroize",
 ]
@@ -1754,7 +1759,7 @@ dependencies = [
 
 [[package]]
 name = "cdk-wasm"
-version = "0.15.0-rc.1"
+version = "0.15.0"
 dependencies = [
  "async-trait",
  "bip39",

+ 1 - 0
crates/cdk-wasm/Cargo.toml

@@ -32,6 +32,7 @@ getrandom = { version = "0.2", features = ["js"] }
 
 [dev-dependencies]
 wasm-bindgen-test = "0.3"
+web-sys = { version = "0.3", features = ["Crypto", "SubtleCrypto", "CryptoKey"] }
 
 [lints]
 workspace = true

+ 1 - 1
crates/cdk-wasm/src/wallet.rs

@@ -359,7 +359,7 @@ impl Wallet {
         &self,
         quote_id: String,
     ) -> Result<JsValue, WasmError> {
-        let quote = self.inner.refresh_mint_quote_status(&quote_id).await?;
+        let quote = self.inner.check_mint_quote_status(&quote_id).await?;
         let wasm_quote: MintQuote = quote.into();
         serde_wasm_bindgen::to_value(&wasm_quote).map_err(WasmError::internal)
     }

+ 290 - 0
crates/cdk-wasm/tests/oidc_test.rs

@@ -0,0 +1,290 @@
+use cdk::oidc_client::{verify_jwt_signature, Jwk};
+use js_sys::{Object, Reflect, Uint8Array};
+use wasm_bindgen::JsValue;
+use wasm_bindgen_futures::JsFuture;
+use wasm_bindgen_test::*;
+
+wasm_bindgen_test_configure!(run_in_browser);
+
+const TEST_ISSUER: &str = "https://test.issuer.example.com";
+const TEST_CLIENT_ID: &str = "test-client-123";
+
+// Pre-generated RSA key components (base64url, no padding)
+const RSA_N: &str = "viZMoE4GaFGF9cualbi9SMKYfL8_A1z8819SM_S38423mlGOyPZoR8gKUMMShQabPanuHAEpSooY2KXM68lAdG0UiRt__MupQWFRP5MaPg4wAkJxAZFWpVSkxuks_P8E3icce48RDjvInhNIB6ZSYQQ9VzXYLhCPOBVIo5CRuD8fXQPJU9TIAhGPUFYXaIcRnL8chECmGBXovHU16x3LxY5wZXA_CJ0HfsZ2Tu97lwyFmNb2enYBtB9NgrxHx3tFsE0uIketOCrKx7exrjWA_m6XHYxl-HB9ixI791cHjA_guSasnWp3HSVXdq8xvmbOZUtpJ0wu4LS9PwOAvvJaIw";
+const RSA_E: &str = "AQAB";
+const RSA_D: &str = "TYmIjYXDjx5PJd-UdaETbmwLijLiGxj7_LHN72nG6QXM7Jx9QO1ZsIudyTkCgEQlYYu9kKXYlJCjeRSC71LteYxRZ2dTVV4m8oYgf3AYr11RrloxgpYlYt2VI5dJxRCoh34jWy8HoWo3cF4kbRohVXZJHRrTwFT4UcI8EJaPFTXTnFWcFUS4I5wvtWeCpWwLB0IhvIvOfPSmsrEEyC0AlCm71X9WLGhln0YpPk7rEmdYp2pHTSed4pL822lUsuXQjkB3yjN7IJDq6BpJzMnsnO9QUsZhATI6ElPajeez2iYDzqSdrENAyNY7g9fiB1lJAF_eBchmg_zjmtp6MSb5IQ";
+const RSA_P: &str = "8A9Rg-CwLHbXvAjxhEUYqVZMWlHeIU7tCf7GiWxyr3u1eusUGWmu6eyWYZNuvOPONHA4e-f2o3ePBmSfuSG0B0Lp7MFS0BDiJTe1f7n1f0DcpcM5tnLXYdH_z5Nn27fnexgZHkNKkjIPwPwZUdoQTLVhZ2lDWv2NZK-Sy0NJKp8";
+const RSA_Q: &str = "ysaTpsmKokjbTWHDS6D469OFyvv1g6rSAeJufzEigD96xjOOk3rZQFQJ6WQFsZ8G4WXmLvBPNknjxv-0aKwxC0XLulw2TDKMeVWQESm-etXJmzHfPJyE9dJOCYOZ750xRJfQeYmDvWl3Gk9Uo9sIhuHpBqGUYKmbOOmU6Led5f0";
+const RSA_DP: &str = "xoHURToaVFpdoMbAeEDu2LBc6N8D0QVD69z67Y5483VXp3IWp8EFe7hAziUtEBNMY35cptE02Q23fnDcxykAhnSlnTprsVQUvPPpKNpsEDNhgc0Cv0UNp30QjOR2oHDdgKN3udepJWUyM8IDafTpP5VJG0snAGnkbtrkhyJ3sT8";
+const RSA_DQ: &str = "P22mOgHJD8Jidu4hvMJ5mqrrqvbtcWY5ksVVcwvXku5IZT8zVgaTdn_TKeJTtZ_c8xyAyCX7YSvzyAesUyGppbELbRvzEBqvvjR5gCTipGHDUnxjK_55yLskFe3IdR9ijeY_HAVb5B_dVamC_E5DeI2p6p0YYLQtDbxjC_iDt7E";
+const RSA_QI: &str = "hEd-TpKvFEi4PCm4845FR6D9O_Y6o8nVyODLbC6ACacA_f4rKm7BLQLphsG8aVaKteUMmJS6eQg2abM2Phmy9NrGzg3qGEGl_VDFQ6laDbfrtVgSAZBMzH-ftzoybZZO3MAFdE_x3HaafYkcZ_lcV_OMo1QGwD2cg3AGBZ3bAcM";
+
+// Pre-generated EC P-256 key components (base64url, no padding)
+const EC_X: &str = "_GpXxzqwAcWFrFINB4ehLPSWAbXEEt7TTgElIakGOhk";
+const EC_Y: &str = "r-CLBX5hepUn2PJ9tHivvDwvL_nZfwl1KrajlRQwzIc";
+const EC_D: &str = "RmG7VzFSrog-8-W7Yt1bbQuMxHY0ggnL9-oGaaLPWPM";
+
+// ---------------------------------------------------------------------------
+// Base64url encoding (no-pad) — avoids depending on bitcoin crate
+// ---------------------------------------------------------------------------
+
+const B64URL_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+fn base64url_encode(data: &[u8]) -> String {
+    let mut out = String::with_capacity((data.len() * 4 + 2) / 3);
+    for chunk in data.chunks(3) {
+        let b0 = chunk[0] as usize;
+        let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
+        let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
+        let n = (b0 << 16) | (b1 << 8) | b2;
+        out.push(B64URL_CHARS[(n >> 18) & 0x3f] as char);
+        out.push(B64URL_CHARS[(n >> 12) & 0x3f] as char);
+        if chunk.len() > 1 {
+            out.push(B64URL_CHARS[(n >> 6) & 0x3f] as char);
+        }
+        if chunk.len() > 2 {
+            out.push(B64URL_CHARS[n & 0x3f] as char);
+        }
+    }
+    out
+}
+
+// ---------------------------------------------------------------------------
+// JS Object helpers
+// ---------------------------------------------------------------------------
+
+fn js_set(obj: &Object, key: &str, val: &JsValue) {
+    Reflect::set(obj, &JsValue::from_str(key), val).unwrap();
+}
+
+fn js_str(s: &str) -> JsValue {
+    JsValue::from_str(s)
+}
+
+fn hash_obj(name: &str) -> Object {
+    let o = Object::new();
+    js_set(&o, "name", &js_str(name));
+    o
+}
+
+fn get_subtle() -> web_sys::SubtleCrypto {
+    let global = js_sys::global();
+    let crypto = Reflect::get(&global, &js_str("crypto")).unwrap();
+    let subtle = Reflect::get(&crypto, &js_str("subtle")).unwrap();
+    subtle.into()
+}
+
+// ---------------------------------------------------------------------------
+// JWK builders — construct JS Objects directly for Web Crypto importKey
+// ---------------------------------------------------------------------------
+
+fn build_rsa_private_jwk_js() -> Object {
+    let o = Object::new();
+    js_set(&o, "kty", &js_str("RSA"));
+    js_set(&o, "n", &js_str(RSA_N));
+    js_set(&o, "e", &js_str(RSA_E));
+    js_set(&o, "d", &js_str(RSA_D));
+    js_set(&o, "p", &js_str(RSA_P));
+    js_set(&o, "q", &js_str(RSA_Q));
+    js_set(&o, "dp", &js_str(RSA_DP));
+    js_set(&o, "dq", &js_str(RSA_DQ));
+    js_set(&o, "qi", &js_str(RSA_QI));
+    o
+}
+
+fn build_ec_private_jwk_js() -> Object {
+    let o = Object::new();
+    js_set(&o, "kty", &js_str("EC"));
+    js_set(&o, "crv", &js_str("P-256"));
+    js_set(&o, "x", &js_str(EC_X));
+    js_set(&o, "y", &js_str(EC_Y));
+    js_set(&o, "d", &js_str(EC_D));
+    o
+}
+
+fn rsa_public_jwk() -> Jwk {
+    Jwk {
+        kty: "RSA".into(),
+        kid: Some("test-key-1".into()),
+        alg: Some("RS256".into()),
+        n: Some(RSA_N.into()),
+        e: Some(RSA_E.into()),
+        crv: None,
+        x: None,
+        y: None,
+        extra: Default::default(),
+    }
+}
+
+fn ec_public_jwk() -> Jwk {
+    Jwk {
+        kty: "EC".into(),
+        kid: Some("test-key-1".into()),
+        alg: Some("ES256".into()),
+        n: None,
+        e: None,
+        crv: Some("P-256".into()),
+        x: Some(EC_X.into()),
+        y: Some(EC_Y.into()),
+        extra: Default::default(),
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Algorithm builders
+// ---------------------------------------------------------------------------
+
+fn rsa_algo() -> Object {
+    let algo = Object::new();
+    js_set(&algo, "name", &js_str("RSASSA-PKCS1-v1_5"));
+    js_set(&algo, "hash", &hash_obj("SHA-256").into());
+    algo
+}
+
+fn ec_import_algo() -> Object {
+    let algo = Object::new();
+    js_set(&algo, "name", &js_str("ECDSA"));
+    js_set(&algo, "namedCurve", &js_str("P-256"));
+    algo
+}
+
+fn ec_sign_algo() -> Object {
+    let algo = Object::new();
+    js_set(&algo, "name", &js_str("ECDSA"));
+    js_set(&algo, "hash", &hash_obj("SHA-256").into());
+    algo
+}
+
+// ---------------------------------------------------------------------------
+// JWT signing helper using Web Crypto
+// ---------------------------------------------------------------------------
+
+async fn sign_jwt_web_crypto(
+    claims_json: &str,
+    private_jwk: &Object,
+    alg_name: &str,
+    kid: &str,
+    import_algo: &Object,
+    sign_algo: &Object,
+) -> String {
+    let subtle = get_subtle();
+
+    let header = format!(r#"{{"alg":"{}","typ":"JWT","kid":"{}"}}"#, alg_name, kid);
+    let header_b64 = base64url_encode(header.as_bytes());
+    let payload_b64 = base64url_encode(claims_json.as_bytes());
+    let message = format!("{}.{}", header_b64, payload_b64);
+
+    // importKey("jwk", private_jwk, algo, false, ["sign"])
+    let usages = js_sys::Array::new();
+    usages.push(&js_str("sign"));
+    let key_promise = subtle
+        .import_key_with_object("jwk", private_jwk, import_algo, false, &usages)
+        .unwrap();
+    let key: web_sys::CryptoKey = JsFuture::from(key_promise).await.unwrap().into();
+
+    // sign(algo, key, data)
+    let msg_array = Uint8Array::from(message.as_bytes() as &[u8]);
+    let sign_promise = subtle
+        .sign_with_object_and_buffer_source(sign_algo, &key, &msg_array)
+        .unwrap();
+    let sig_buffer = JsFuture::from(sign_promise).await.unwrap();
+    let sig_array = Uint8Array::new(&sig_buffer);
+    let mut sig_bytes = vec![0u8; sig_array.length() as usize];
+    sig_array.copy_to(&mut sig_bytes);
+
+    let sig_b64 = base64url_encode(&sig_bytes);
+    format!("{}.{}.{}", header_b64, payload_b64, sig_b64)
+}
+
+fn claims_str(issuer: &str, client_id: &str, expired: bool) -> String {
+    let exp = if expired { 1_000_000_000u64 } else { 4_102_444_800u64 };
+    format!(
+        r#"{{"iss":"{}","client_id":"{}","exp":{},"sub":"test-user"}}"#,
+        issuer, client_id, exp
+    )
+}
+
+// ---------------------------------------------------------------------------
+// Helpers: sign a JWT for reuse across multiple tests
+// ---------------------------------------------------------------------------
+
+async fn make_rsa_jwt(issuer: &str, client_id: &str, expired: bool) -> String {
+    let algo = rsa_algo();
+    sign_jwt_web_crypto(
+        &claims_str(issuer, client_id, expired),
+        &build_rsa_private_jwk_js(),
+        "RS256",
+        "test-key-1",
+        &algo,
+        &algo,
+    )
+    .await
+}
+
+async fn make_ec_jwt(issuer: &str, client_id: &str, expired: bool) -> String {
+    sign_jwt_web_crypto(
+        &claims_str(issuer, client_id, expired),
+        &build_ec_private_jwk_js(),
+        "ES256",
+        "test-key-1",
+        &ec_import_algo(),
+        &ec_sign_algo(),
+    )
+    .await
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_rsa_rs256() {
+    let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
+    let result = verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", TEST_ISSUER).await;
+    let claims = result.unwrap();
+    assert_eq!(claims["iss"], TEST_ISSUER);
+    assert_eq!(claims["client_id"], TEST_CLIENT_ID);
+}
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_ec_es256() {
+    let jwt = make_ec_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
+    let result = verify_jwt_signature(&jwt, &ec_public_jwk(), "ES256", TEST_ISSUER).await;
+    let claims = result.unwrap();
+    assert_eq!(claims["iss"], TEST_ISSUER);
+    assert_eq!(claims["client_id"], TEST_CLIENT_ID);
+}
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_expired() {
+    let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, true).await;
+    let result = verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", TEST_ISSUER).await;
+    assert!(result.is_err(), "expected error for expired token");
+}
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_wrong_issuer() {
+    let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
+    let result =
+        verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", "https://wrong.issuer.com").await;
+    assert!(result.is_err(), "expected error for wrong issuer");
+}
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_bad_signature() {
+    let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
+    // Tamper with the signature
+    let parts: Vec<&str> = jwt.splitn(3, '.').collect();
+    let tampered = format!("{}.{}.{}AAAA", parts[0], parts[1], parts[2]);
+    let result = verify_jwt_signature(&tampered, &rsa_public_jwk(), "RS256", TEST_ISSUER).await;
+    assert!(result.is_err(), "expected error for tampered signature");
+}
+
+#[wasm_bindgen_test]
+async fn test_verify_jwt_unsupported_kty() {
+    let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
+    let mut jwk = rsa_public_jwk();
+    jwk.kty = "OKP".into();
+    let result = verify_jwt_signature(&jwt, &jwk, "RS256", TEST_ISSUER).await;
+    assert!(result.is_err(), "expected error for unsupported key type");
+}

+ 67 - 0
crates/cdk-wasm/wasm_usage.md

@@ -0,0 +1,67 @@
+# WASM Binary Size Analysis
+
+Analysis of `cdk-wasm` release build (`cargo build --target wasm32-unknown-unknown --release`).
+
+Binary size: **~6.6 MB** raw (before gzip/brotli compression).
+
+Tooling used: [twiggy](https://rustwasm.github.io/twiggy/) for symbol-level profiling.
+
+## Per-component breakdown
+
+| Component | Size (KB) | % | Notes |
+|---|---:|---:|---|
+| **[data/rodata]** | 1554.8 | 23.1% | String literals, lookup tables, constants |
+| **cdk** (wallet core) | 944.1 | 14.0% | Wallet sagas, swap, receive, restore, OIDC |
+| **core/alloc/std** | 841.9 | 12.5% | Rust stdlib (formatting, collections, etc.) |
+| **[function names]** | 793.3 | 11.8% | Debug symbol names — stripped by wasm-pack |
+| **[other]** | 597.9 | 8.9% | Mostly secp256k1 C symbols + serde monomorphs |
+| **serde (all)** | 425.9 | 6.3% | JSON/CBOR deserialization monomorphization |
+| **tracing** | 251.7 | 3.7% | Logging framework |
+| **cashu** | 223.9 | 3.3% | Protocol types + their serde impls |
+| **secp256k1** | 221.8 | 3.3% | Elliptic curve crypto (C code compiled to WASM) |
+| **wasm-bindgen** | 189.6 | 2.8% | JS glue layer |
+| **cdk_wasm + cdk_common** | 191.7 | 2.9% | FFI wrappers + shared types |
+| **url/idna** | 66.3 | 1.0% | URL parsing + international domain names |
+| **lightning** | 50.5 | 0.8% | Invoice parsing |
+| **bitcoin_hashes** | 31.2 | 0.5% | SHA256, SHA512, RIPEMD160 |
+| Everything else | ~110 | 1.6% | bip39, cbor, encoding, tokio, etc. |
+
+## Key observations
+
+1. **Biggest wins would come from**: reducing `cdk` code size (14%), trimming `serde` monomorphization (6.3%), and evaluating if `tracing` (3.7%) can be compiled out for WASM.
+
+2. **`.rodata` at 23%** is large — this is string literals (error messages, format strings, tracing span names) and lookup tables (unicode normalization, secp256k1 precomputed tables). Hard to reduce without removing features.
+
+3. **`[function names]` (11.8%)** disappears when wasm-pack uses `--release` with name stripping, so the real production binary is closer to **~5.9 MB** raw (before gzip).
+
+4. **`secp256k1` + `bitcoin_hashes`** (3.8% combined) is the cost of doing cryptography in WASM — this is the C secp256k1 library compiled to WASM. Can't avoid it.
+
+5. **`serde` monomorphization** (6.3%) is the classic Rust WASM bloat problem — every `Deserialize` impl for each type generates a separate copy of the JSON parser. `miniserde` or hand-written parsing would shrink this but at massive effort.
+
+6. **`jsonwebtoken`/`ring` is completely gone** from the WASM build — zero cost, replaced by the browser's built-in Web Crypto API via `SubtleCrypto`.
+
+## Potential size reduction strategies
+
+- **Strip tracing on WASM**: feature-gate `tracing` instrumentation behind `cfg(not(target_arch = "wasm32"))` or use `console.log` directly. Saves ~250 KB.
+- **wasm-opt**: run `wasm-opt -Oz` on the output. Typically 10-20% reduction.
+- **gzip/brotli**: the final served `.wasm` file compresses well (~60-70% reduction).
+- **Reduce serde monomorphization**: use `#[serde(deserialize_with)]` to share deserializer instances across similar types, or use `serde_json::Value` as an intermediate.
+
+## Reproducing this analysis
+
+```bash
+# Install twiggy
+cargo install twiggy
+
+# Build release WASM
+cargo build --target wasm32-unknown-unknown -p cdk-wasm --release
+
+# Analyze
+twiggy top target/wasm32-unknown-unknown/release/cdk_wasm.wasm -n 50
+
+# Dominator tree (what retains what)
+twiggy dominators target/wasm32-unknown-unknown/release/cdk_wasm.wasm -d 2 | head -80
+
+# CSV export for scripting
+twiggy top target/wasm32-unknown-unknown/release/cdk_wasm.wasm -n 30000 --format csv > twiggy.csv
+```

+ 8 - 1
crates/cdk/Cargo.toml

@@ -55,7 +55,8 @@ futures = { workspace = true, optional = true, features = ["alloc"] }
 url.workspace = true
 utoipa = { workspace = true, optional = true }
 uuid.workspace = true
-jsonwebtoken.workspace = true
+# jsonwebtoken is only used on non-WASM (it depends on ring which doesn't compile to WASM)
+# On WASM, we use the browser's Web Crypto API instead
 nostr-sdk = { workspace = true, optional = true }
 cdk-npubcash = { workspace = true, optional = true }
 cdk-prometheus = {workspace = true, optional = true}
@@ -64,6 +65,7 @@ zeroize = "1"
 tokio-util.workspace = true
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+jsonwebtoken.workspace = true
 hickory-resolver = { version = "0.25.2", optional = true, features = ["dnssec-ring"] }
 tokio = { workspace = true, features = [
     "rt-multi-thread",
@@ -91,6 +93,11 @@ tls-api-native-tls = { version = "0.9", optional = true }
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
 getrandom = { version = "0.2", features = ["js"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+serde-wasm-bindgen = "0.6"
+web-sys = { version = "0.3", features = ["Crypto", "SubtleCrypto", "CryptoKey"] }
 
 uuid = { workspace = true, features = ["js"] }
 gloo-timers = { version = "0.3", features = ["futures"] }

+ 1 - 1
crates/cdk/src/lib.rs

@@ -34,7 +34,7 @@ mod bip353;
 mod lightning_address;
 
 #[cfg(any(feature = "wallet", feature = "mint"))]
-mod oidc_client;
+pub mod oidc_client;
 
 #[cfg(feature = "mint")]
 #[doc(hidden)]

+ 736 - 57
crates/cdk/src/oidc_client.rs

@@ -4,25 +4,97 @@ use std::collections::HashMap;
 use std::ops::Deref;
 use std::sync::Arc;
 
+use bitcoin::base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use bitcoin::base64::Engine;
 use cdk_common::HttpClient;
-use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
-use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
-use serde::Deserialize;
-#[cfg(feature = "wallet")]
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use tokio::sync::RwLock;
 use tracing::instrument;
 
+// ---------------------------------------------------------------------------
+// Own JWK types (replaces jsonwebtoken::jwk::*)
+// ---------------------------------------------------------------------------
+
+/// JSON Web Key Set
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JwkSet {
+    /// The keys in the set
+    pub keys: Vec<Jwk>,
+}
+
+impl JwkSet {
+    /// Find a key by its `kid`
+    pub fn find(&self, kid: &str) -> Option<&Jwk> {
+        self.keys.iter().find(|k| k.kid.as_deref() == Some(kid))
+    }
+}
+
+/// JSON Web Key
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Jwk {
+    /// Key type (e.g. "RSA", "EC")
+    pub kty: String,
+    /// Key ID
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub kid: Option<String>,
+    /// Algorithm
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub alg: Option<String>,
+    /// RSA modulus
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub n: Option<String>,
+    /// RSA exponent
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub e: Option<String>,
+    /// EC curve name
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub crv: Option<String>,
+    /// EC x coordinate
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub x: Option<String>,
+    /// EC y coordinate
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub y: Option<String>,
+    /// Additional fields (preserved for Web Crypto importKey)
+    #[serde(flatten)]
+    pub extra: serde_json::Map<String, serde_json::Value>,
+}
+
+// ---------------------------------------------------------------------------
+// JWT header parsing (cross-platform, no crypto)
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Deserialize)]
+struct JwtHeader {
+    alg: String,
+    kid: Option<String>,
+}
+
+fn decode_jwt_header(token: &str) -> Result<JwtHeader, Error> {
+    let header_b64 = token.split('.').next().ok_or(Error::InvalidJwtFormat)?;
+    let header_bytes = URL_SAFE_NO_PAD
+        .decode(header_b64)
+        .map_err(|_| Error::InvalidJwtFormat)?;
+    serde_json::from_slice(&header_bytes).map_err(|_| Error::InvalidJwtFormat)
+}
+
+// ---------------------------------------------------------------------------
+// Error
+// ---------------------------------------------------------------------------
+
 /// OIDC Error
 #[derive(Debug, Error)]
 pub enum Error {
     /// From HTTP error
     #[error(transparent)]
     Http(#[from] cdk_common::HttpError),
-    /// From JWT error
-    #[error(transparent)]
-    Jwt(#[from] jsonwebtoken::errors::Error),
+    /// JWT verification failed
+    #[error("JWT verification failed: {0}")]
+    JwtVerification(String),
+    /// Invalid JWT format
+    #[error("Invalid JWT format")]
+    InvalidJwtFormat,
     /// Missing kid header
     #[error("Missing kid header")]
     MissingKidHeader,
@@ -47,9 +119,13 @@ impl From<Error> for cdk_common::error::Error {
 /// Open Id Config
 #[derive(Debug, Clone, Deserialize)]
 pub struct OidcConfig {
+    /// JWKS URI
     pub jwks_uri: String,
+    /// Issuer
     pub issuer: String,
+    /// Token endpoint
     pub token_endpoint: String,
+    /// Device authorization endpoint
     pub device_authorization_endpoint: String,
 }
 
@@ -63,27 +139,38 @@ pub struct OidcClient {
     jwks_set: Arc<RwLock<Option<JwkSet>>>,
 }
 
+/// OAuth2 grant type
 #[cfg(feature = "wallet")]
 #[derive(Debug, Clone, Copy, Serialize)]
 #[serde(rename_all = "snake_case")]
 pub enum GrantType {
+    /// Refresh token grant
     RefreshToken,
 }
 
+/// OAuth2 refresh token request
 #[cfg(feature = "wallet")]
 #[derive(Debug, Clone, Serialize)]
 pub struct RefreshTokenRequest {
+    /// Grant type
     pub grant_type: GrantType,
+    /// Client ID
     pub client_id: String,
+    /// Refresh token
     pub refresh_token: String,
 }
 
+/// OAuth2 token response
 #[cfg(feature = "wallet")]
 #[derive(Debug, Clone, Deserialize)]
 pub struct TokenResponse {
+    /// Access token
     pub access_token: String,
+    /// Refresh token
     pub refresh_token: Option<String>,
+    /// Expires in seconds
     pub expires_in: Option<i64>,
+    /// Token type
     pub token_type: String,
 }
 
@@ -129,9 +216,9 @@ impl OidcClient {
     #[instrument(skip_all)]
     pub async fn verify_cat(&self, cat_jwt: &str) -> Result<(), Error> {
         tracing::debug!("Verifying cat");
-        let header = decode_header(cat_jwt)?;
+        let header = decode_jwt_header(cat_jwt)?;
 
-        let kid = header.kid.ok_or(Error::MissingKidHeader)?;
+        let kid = header.kid.as_deref().ok_or(Error::MissingKidHeader)?;
 
         let oidc_config = {
             let locked = self.oidc_config.read().await;
@@ -155,66 +242,46 @@ impl OidcClient {
             }
         };
 
-        let jwk = match jwks.find(&kid) {
+        let jwk = match jwks.find(kid) {
             Some(jwk) => jwk.clone(),
             None => {
                 let refreshed_jwks = self.get_jwkset(&oidc_config.jwks_uri).await?;
                 refreshed_jwks
-                    .find(&kid)
+                    .find(kid)
                     .ok_or(Error::MissingKidHeader)?
                     .clone()
             }
         };
 
-        let decoding_key = match &jwk.algorithm {
-            AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?,
-            AlgorithmParameters::EllipticCurve(ecdsa) => {
-                DecodingKey::from_ec_components(&ecdsa.x, &ecdsa.y)?
-            }
-            _ => return Err(Error::UnsupportedSigningAlgo),
-        };
+        let claims =
+            verify_jwt_signature(cat_jwt, &jwk, &header.alg, &oidc_config.issuer).await?;
 
-        let validation = {
-            let mut validation = Validation::new(header.alg);
-            validation.validate_exp = true;
-            validation.validate_aud = false;
-            validation.set_issuer(&[oidc_config.issuer]);
-            validation
-        };
+        tracing::debug!("Successfully verified cat");
+        tracing::debug!("Claims: {:?}", claims);
 
-        match decode::<HashMap<String, serde_json::Value>>(cat_jwt, &decoding_key, &validation) {
-            Ok(claims) => {
-                tracing::debug!("Successfully verified cat");
-                tracing::debug!("Claims: {:?}", claims.claims);
-                if let Some(client_id) = &self.client_id {
-                    if let Some(token_client_id) = claims.claims.get("client_id") {
-                        if let Some(token_client_id_value) = token_client_id.as_str() {
-                            if token_client_id_value != client_id {
-                                tracing::warn!(
-                                    "Client ID mismatch: expected {}, got {}",
-                                    client_id,
-                                    token_client_id_value
-                                );
-                                return Err(Error::InvalidClientId);
-                            }
-                        }
-                    } else if let Some(azp) = claims.claims.get("azp") {
-                        if let Some(azp_value) = azp.as_str() {
-                            if azp_value != client_id {
-                                tracing::warn!(
-                                    "Client ID (azp) mismatch: expected {}, got {}",
-                                    client_id,
-                                    azp_value
-                                );
-                                return Err(Error::InvalidClientId);
-                            }
-                        }
+        if let Some(client_id) = &self.client_id {
+            if let Some(token_client_id) = claims.get("client_id") {
+                if let Some(token_client_id_value) = token_client_id.as_str() {
+                    if token_client_id_value != client_id {
+                        tracing::warn!(
+                            "Client ID mismatch: expected {}, got {}",
+                            client_id,
+                            token_client_id_value
+                        );
+                        return Err(Error::InvalidClientId);
+                    }
+                }
+            } else if let Some(azp) = claims.get("azp") {
+                if let Some(azp_value) = azp.as_str() {
+                    if azp_value != client_id {
+                        tracing::warn!(
+                            "Client ID (azp) mismatch: expected {}, got {}",
+                            client_id,
+                            azp_value
+                        );
+                        return Err(Error::InvalidClientId);
                     }
                 }
-            }
-            Err(err) => {
-                tracing::debug!("Could not verify cat: {}", err);
-                return Err(err.into());
             }
         }
 
@@ -241,3 +308,615 @@ impl OidcClient {
         Ok(response)
     }
 }
+
+// ===========================================================================
+// Native JWT verification (using jsonwebtoken crate)
+// ===========================================================================
+
+/// Verify a JWT signature and return the claims.
+#[cfg(not(target_arch = "wasm32"))]
+pub async fn verify_jwt_signature(
+    token: &str,
+    jwk: &Jwk,
+    alg: &str,
+    expected_issuer: &str,
+) -> Result<HashMap<String, serde_json::Value>, Error> {
+    let decoding_key = match jwk.kty.as_str() {
+        "RSA" => {
+            let n = jwk.n.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
+            let e = jwk.e.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
+            jsonwebtoken::DecodingKey::from_rsa_components(n, e)
+                .map_err(|e| Error::JwtVerification(e.to_string()))?
+        }
+        "EC" => {
+            let x = jwk.x.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
+            let y = jwk.y.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
+            jsonwebtoken::DecodingKey::from_ec_components(x, y)
+                .map_err(|e| Error::JwtVerification(e.to_string()))?
+        }
+        _ => return Err(Error::UnsupportedSigningAlgo),
+    };
+
+    let algorithm: jsonwebtoken::Algorithm = alg
+        .parse()
+        .map_err(|_| Error::JwtVerification(format!("unsupported algorithm: {alg}")))?;
+
+    let validation = {
+        let mut v = jsonwebtoken::Validation::new(algorithm);
+        v.validate_exp = true;
+        v.validate_aud = false;
+        v.set_issuer(&[expected_issuer]);
+        v
+    };
+
+    let token_data =
+        jsonwebtoken::decode::<HashMap<String, serde_json::Value>>(token, &decoding_key, &validation)
+            .map_err(|e| Error::JwtVerification(e.to_string()))?;
+
+    Ok(token_data.claims)
+}
+
+// ===========================================================================
+// WASM JWT verification (using Web Crypto API)
+// ===========================================================================
+
+/// Verify a JWT signature and return the claims.
+#[cfg(target_arch = "wasm32")]
+pub async fn verify_jwt_signature(
+    token: &str,
+    jwk: &Jwk,
+    alg: &str,
+    expected_issuer: &str,
+) -> Result<HashMap<String, serde_json::Value>, Error> {
+    use js_sys::{Reflect, Uint8Array};
+    use wasm_bindgen::JsValue;
+    use wasm_bindgen_futures::JsFuture;
+
+    let parts: Vec<&str> = token.splitn(3, '.').collect();
+    if parts.len() != 3 {
+        return Err(Error::InvalidJwtFormat);
+    }
+    let (header_b64, payload_b64, sig_b64) = (parts[0], parts[1], parts[2]);
+
+    // Decode signature
+    let sig_bytes = URL_SAFE_NO_PAD
+        .decode(sig_b64)
+        .map_err(|_| Error::InvalidJwtFormat)?;
+
+    // The message that was signed is "header.payload" (the raw base64url text)
+    let message = format!("{}.{}", header_b64, payload_b64);
+
+    // Get SubtleCrypto (works in both Window and Worker contexts)
+    let global = js_sys::global();
+    let crypto = Reflect::get(&global, &JsValue::from_str("crypto"))
+        .map_err(|_| Error::JwtVerification("crypto not available".into()))?;
+    let subtle = Reflect::get(&crypto, &JsValue::from_str("subtle"))
+        .map_err(|_| Error::JwtVerification("subtle crypto not available".into()))?;
+    let subtle: web_sys::SubtleCrypto = subtle.into();
+
+    // Build algorithm objects for importKey and verify
+    let (import_algo, verify_algo) = web_crypto_algorithms(alg, &jwk.kty)?;
+
+    // Build JWK as a plain JS Object for Web Crypto importKey.
+    // We cannot use serde_wasm_bindgen::to_value() because the #[serde(flatten)]
+    // on the `extra` field produces a JS Map instead of a plain Object, which
+    // Web Crypto's importKey rejects with DataError.
+    let jwk_js = jwk_to_js_object(jwk)?;
+
+    // importKey("jwk", jwk_js, algo, false, ["verify"])
+    let usages = js_sys::Array::new();
+    usages.push(&JsValue::from_str("verify"));
+    let key_promise = subtle
+        .import_key_with_object("jwk", &jwk_js, &import_algo, false, &usages)
+        .map_err(|e| Error::JwtVerification(format!("importKey setup failed: {e:?}")))?;
+    let key: web_sys::CryptoKey = JsFuture::from(key_promise)
+        .await
+        .map_err(|e| Error::JwtVerification(format!("importKey failed: {e:?}")))?
+        .into();
+
+    // verify(algo, key, signature, data)
+    let sig_array = Uint8Array::from(sig_bytes.as_slice());
+    let msg_array = Uint8Array::from(message.as_bytes() as &[u8]);
+    let verify_promise = subtle
+        .verify_with_object_and_buffer_source_and_buffer_source(
+            &verify_algo,
+            &key,
+            &sig_array,
+            &msg_array,
+        )
+        .map_err(|e| Error::JwtVerification(format!("verify setup failed: {e:?}")))?;
+    let result = JsFuture::from(verify_promise)
+        .await
+        .map_err(|e| Error::JwtVerification(format!("verify failed: {e:?}")))?;
+
+    let valid = result
+        .as_bool()
+        .ok_or_else(|| Error::JwtVerification("verify returned non-boolean".into()))?;
+    if !valid {
+        return Err(Error::JwtVerification("signature verification failed".into()));
+    }
+
+    // Decode payload and validate claims
+    let payload_bytes = URL_SAFE_NO_PAD
+        .decode(payload_b64)
+        .map_err(|_| Error::InvalidJwtFormat)?;
+    let claims: HashMap<String, serde_json::Value> =
+        serde_json::from_slice(&payload_bytes).map_err(|_| Error::InvalidJwtFormat)?;
+
+    // Validate expiration
+    if let Some(exp) = claims.get("exp").and_then(|v| v.as_u64()) {
+        let now = web_time::SystemTime::now()
+            .duration_since(web_time::SystemTime::UNIX_EPOCH)
+            .map_err(|_| Error::JwtVerification("system time error".into()))?
+            .as_secs();
+        if now > exp {
+            return Err(Error::JwtVerification("token expired".into()));
+        }
+    }
+
+    // Validate issuer
+    if let Some(iss) = claims.get("iss").and_then(|v| v.as_str()) {
+        if iss != expected_issuer {
+            return Err(Error::JwtVerification(format!(
+                "issuer mismatch: expected {expected_issuer}, got {iss}"
+            )));
+        }
+    }
+
+    Ok(claims)
+}
+
+/// Build Web Crypto algorithm objects for importKey and verify.
+/// Returns (import_algorithm, verify_algorithm) - they differ for ECDSA.
+#[cfg(target_arch = "wasm32")]
+fn web_crypto_algorithms(
+    alg: &str,
+    kty: &str,
+) -> Result<(js_sys::Object, js_sys::Object), Error> {
+    use js_sys::Object;
+    use wasm_bindgen::JsValue;
+
+    let set = |obj: &Object, key: &str, val: &JsValue| {
+        js_sys::Reflect::set(obj, &JsValue::from_str(key), val).ok();
+    };
+
+    let hash_obj = |name: &str| -> Object {
+        let o = Object::new();
+        set(&o, "name", &JsValue::from_str(name));
+        o
+    };
+
+    match kty {
+        "RSA" => {
+            let hash_name = match alg {
+                "RS256" => "SHA-256",
+                "RS384" => "SHA-384",
+                "RS512" => "SHA-512",
+                _ => return Err(Error::UnsupportedSigningAlgo),
+            };
+            let algo = Object::new();
+            set(&algo, "name", &JsValue::from_str("RSASSA-PKCS1-v1_5"));
+            set(&algo, "hash", &hash_obj(hash_name).into());
+            // For RSA, import and verify use the same algorithm object
+            Ok((algo.clone(), algo))
+        }
+        "EC" => {
+            let (curve, hash_name) = match alg {
+                "ES256" => ("P-256", "SHA-256"),
+                "ES384" => ("P-384", "SHA-384"),
+                _ => return Err(Error::UnsupportedSigningAlgo),
+            };
+            let import_algo = Object::new();
+            set(&import_algo, "name", &JsValue::from_str("ECDSA"));
+            set(&import_algo, "namedCurve", &JsValue::from_str(curve));
+
+            let verify_algo = Object::new();
+            set(&verify_algo, "name", &JsValue::from_str("ECDSA"));
+            set(&verify_algo, "hash", &hash_obj(hash_name).into());
+
+            Ok((import_algo, verify_algo))
+        }
+        _ => Err(Error::UnsupportedSigningAlgo),
+    }
+}
+
+/// Convert a [`Jwk`] to a plain JS Object suitable for Web Crypto `importKey`.
+///
+/// We build the object manually with `Reflect::set` rather than using
+/// `serde_wasm_bindgen::to_value` because the `#[serde(flatten)]` on the
+/// `extra` field can produce a JS `Map` instead of a plain `Object`, which
+/// Web Crypto rejects with `DataError`.
+#[cfg(target_arch = "wasm32")]
+fn jwk_to_js_object(jwk: &Jwk) -> Result<js_sys::Object, Error> {
+    use js_sys::Object;
+    use wasm_bindgen::JsValue;
+
+    let obj = Object::new();
+    let set = |key: &str, val: &JsValue| {
+        js_sys::Reflect::set(&obj, &JsValue::from_str(key), val).ok();
+    };
+
+    set("kty", &JsValue::from_str(&jwk.kty));
+
+    if let Some(ref kid) = jwk.kid {
+        set("kid", &JsValue::from_str(kid));
+    }
+    if let Some(ref alg) = jwk.alg {
+        set("alg", &JsValue::from_str(alg));
+    }
+    if let Some(ref n) = jwk.n {
+        set("n", &JsValue::from_str(n));
+    }
+    if let Some(ref e) = jwk.e {
+        set("e", &JsValue::from_str(e));
+    }
+    if let Some(ref crv) = jwk.crv {
+        set("crv", &JsValue::from_str(crv));
+    }
+    if let Some(ref x) = jwk.x {
+        set("x", &JsValue::from_str(x));
+    }
+    if let Some(ref y) = jwk.y {
+        set("y", &JsValue::from_str(y));
+    }
+
+    // Copy any extra fields (e.g. "use", "key_ops")
+    for (key, value) in &jwk.extra {
+        let js_val = serde_wasm_bindgen::to_value(value)
+            .map_err(|e| Error::JwtVerification(format!("failed to serialize JWK extra field '{key}': {e}")))?;
+        set(key, &js_val);
+    }
+
+    Ok(obj)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const TEST_ISSUER: &str = "https://test.issuer.example.com";
+    const TEST_CLIENT_ID: &str = "test-client-123";
+    const TEST_KID: &str = "test-key-1";
+
+    const RSA_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+JkygTgZoUYX1
+y5qVuL1Iwph8vz8DXPzzX1Iz9LfzjbeaUY7I9mhHyApQwxKFBps9qe4cASlKihjY
+pczryUB0bRSJG3/8y6lBYVE/kxo+DjACQnEBkValVKTG6Sz8/wTeJxx7jxEOO8ie
+E0gHplJhBD1XNdguEI84FUijkJG4Px9dA8lT1MgCEY9QVhdohxGcvxyEQKYYFei8
+dTXrHcvFjnBlcD8InQd+xnZO73uXDIWY1vZ6dgG0H02CvEfHe0WwTS4iR604KsrH
+t7GuNYD+bpcdjGX4cH2LEjv3VweMD+C5JqydancdJVd2rzG+Zs5lS2knTC7gtL0/
+A4C+8lojAgMBAAECggEATYmIjYXDjx5PJd+UdaETbmwLijLiGxj7/LHN72nG6QXM
+7Jx9QO1ZsIudyTkCgEQlYYu9kKXYlJCjeRSC71LteYxRZ2dTVV4m8oYgf3AYr11R
+rloxgpYlYt2VI5dJxRCoh34jWy8HoWo3cF4kbRohVXZJHRrTwFT4UcI8EJaPFTXT
+nFWcFUS4I5wvtWeCpWwLB0IhvIvOfPSmsrEEyC0AlCm71X9WLGhln0YpPk7rEmdY
+p2pHTSed4pL822lUsuXQjkB3yjN7IJDq6BpJzMnsnO9QUsZhATI6ElPajeez2iYD
+zqSdrENAyNY7g9fiB1lJAF/eBchmg/zjmtp6MSb5IQKBgQDwD1GD4LAsdte8CPGE
+RRipVkxaUd4hTu0J/saJbHKve7V66xQZaa7p7JZhk2684840cDh75/ajd48GZJ+5
+IbQHQunswVLQEOIlN7V/ufV/QNylwzm2ctdh0f/Pk2fbt+d7GBkeQ0qSMg/A/BlR
+2hBMtWFnaUNa/Y1kr5LLQ0kqnwKBgQDKxpOmyYqiSNtNYcNLoPjr04XK+/WDqtIB
+4m5/MSKAP3rGM46TetlAVAnpZAWxnwbhZeYu8E82SePG/7RorDELRcu6XDZMMox5
+VZARKb561cmbMd88nIT10k4Jg5nvnTFEl9B5iYO9aXcaT1Sj2wiG4ekGoZRgqZs4
+6ZTot53l/QKBgQDGgdRFOhpUWl2gxsB4QO7YsFzo3wPRBUPr3PrtjnjzdVenchan
+wQV7uEDOJS0QE0xjflym0TTZDbd+cNzHKQCGdKWdOmuxVBS88+ko2mwQM2GBzQK/
+RQ2nfRCM5HagcN2Ao3e516klZTIzwgNp9Ok/lUkbSycAaeRu2uSHInexPwKBgD9t
+pjoByQ/CYnbuIbzCeZqq66r27XFmOZLFVXML15LuSGU/M1YGk3Z/0yniU7Wf3PMc
+gMgl+2Er88gHrFMhqaWxC20b8xAar740eYAk4qRhw1J8Yyv+eci7JBXtyHUfYo3m
+PxwFW+Qf3VWpgvxOQ3iNqeqdGGC0LQ28Ywv4g7exAoGBAIRHfk6SrxRIuDwpuPOO
+RUeg/Tv2OqPJ1cjgy2wugAmnAP3+KypuwS0C6YbBvGlWirXlDJiUunkINmmzNj4Z
+svTaxs4N6hhBpf1QxUOpWg2367VYEgGQTMx/n7c6Mm2WTtzABXRP8dx2mn2JHGf5
+XFfzjKNUBsA9nINwBgWd2wHD
+-----END PRIVATE KEY-----"#;
+
+    const RSA_N: &str = "viZMoE4GaFGF9cualbi9SMKYfL8_A1z8819SM_S38423mlGOyPZoR8gKUMMShQabPanuHAEpSooY2KXM68lAdG0UiRt__MupQWFRP5MaPg4wAkJxAZFWpVSkxuks_P8E3icce48RDjvInhNIB6ZSYQQ9VzXYLhCPOBVIo5CRuD8fXQPJU9TIAhGPUFYXaIcRnL8chECmGBXovHU16x3LxY5wZXA_CJ0HfsZ2Tu97lwyFmNb2enYBtB9NgrxHx3tFsE0uIketOCrKx7exrjWA_m6XHYxl-HB9ixI791cHjA_guSasnWp3HSVXdq8xvmbOZUtpJ0wu4LS9PwOAvvJaIw";
+    const RSA_E: &str = "AQAB";
+
+    const EC_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRmG7VzFSrog+8+W7
+Yt1bbQuMxHY0ggnL9+oGaaLPWPOhRANCAAT8alfHOrABxYWsUg0Hh6Es9JYBtcQS
+3tNOASUhqQY6Ga/giwV+YXqVJ9jyfbR4r7w8Ly/52X8JdSq2o5UUMMyH
+-----END PRIVATE KEY-----"#;
+
+    const EC_X: &str = "_GpXxzqwAcWFrFINB4ehLPSWAbXEEt7TTgElIakGOhk";
+    const EC_Y: &str = "r-CLBX5hepUn2PJ9tHivvDwvL_nZfwl1KrajlRQwzIc";
+
+    fn rsa_jwk() -> Jwk {
+        Jwk {
+            kty: "RSA".to_string(),
+            kid: Some(TEST_KID.to_string()),
+            alg: Some("RS256".to_string()),
+            n: Some(RSA_N.to_string()),
+            e: Some(RSA_E.to_string()),
+            crv: None,
+            x: None,
+            y: None,
+            extra: Default::default(),
+        }
+    }
+
+    fn ec_jwk() -> Jwk {
+        Jwk {
+            kty: "EC".to_string(),
+            kid: Some(TEST_KID.to_string()),
+            alg: Some("ES256".to_string()),
+            n: None,
+            e: None,
+            crv: Some("P-256".to_string()),
+            x: Some(EC_X.to_string()),
+            y: Some(EC_Y.to_string()),
+            extra: Default::default(),
+        }
+    }
+
+    fn test_claims(issuer: &str, client_id: &str, expired: bool) -> HashMap<String, serde_json::Value> {
+        let exp = if expired { 1_000_000_000u64 } else { 4_102_444_800u64 };
+        let mut claims = HashMap::new();
+        claims.insert("iss".into(), serde_json::json!(issuer));
+        claims.insert("client_id".into(), serde_json::json!(client_id));
+        claims.insert("exp".into(), serde_json::json!(exp));
+        claims.insert("sub".into(), serde_json::json!("test-user"));
+        claims
+    }
+
+    fn sign_jwt(
+        claims: &HashMap<String, serde_json::Value>,
+        alg: jsonwebtoken::Algorithm,
+        kid: &str,
+        pem: &[u8],
+    ) -> String {
+        let mut header = jsonwebtoken::Header::new(alg);
+        header.kid = Some(kid.to_string());
+        let key = match alg {
+            jsonwebtoken::Algorithm::RS256 => {
+                jsonwebtoken::EncodingKey::from_rsa_pem(pem).unwrap()
+            }
+            jsonwebtoken::Algorithm::ES256 => {
+                jsonwebtoken::EncodingKey::from_ec_pem(pem).unwrap()
+            }
+            _ => panic!("unsupported alg in test"),
+        };
+        jsonwebtoken::encode(&header, claims, &key).unwrap()
+    }
+
+    fn make_test_client(
+        client_id: Option<String>,
+        oidc_config: OidcConfig,
+        jwks: JwkSet,
+    ) -> OidcClient {
+        OidcClient {
+            client: HttpClient::new(),
+            openid_discovery: "https://unused.example.com".into(),
+            client_id,
+            oidc_config: Arc::new(RwLock::new(Some(oidc_config))),
+            jwks_set: Arc::new(RwLock::new(Some(jwks))),
+        }
+    }
+
+    fn test_oidc_config() -> OidcConfig {
+        OidcConfig {
+            jwks_uri: "https://unused.example.com/jwks".into(),
+            issuer: TEST_ISSUER.into(),
+            token_endpoint: "https://unused.example.com/token".into(),
+            device_authorization_endpoint: "https://unused.example.com/device".into(),
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // decode_jwt_header tests (cross-platform, no crypto)
+    // -----------------------------------------------------------------------
+
+    #[test]
+    fn test_decode_jwt_header_rs256() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let header = decode_jwt_header(&jwt).unwrap();
+        assert_eq!(header.alg, "RS256");
+        assert_eq!(header.kid.as_deref(), Some(TEST_KID));
+    }
+
+    #[test]
+    fn test_decode_jwt_header_es256() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::ES256, TEST_KID, EC_PEM);
+        let header = decode_jwt_header(&jwt).unwrap();
+        assert_eq!(header.alg, "ES256");
+        assert_eq!(header.kid.as_deref(), Some(TEST_KID));
+    }
+
+    #[test]
+    fn test_decode_jwt_header_no_dots() {
+        assert!(matches!(
+            decode_jwt_header("nodots"),
+            Err(Error::InvalidJwtFormat)
+        ));
+    }
+
+    #[test]
+    fn test_decode_jwt_header_invalid_base64() {
+        assert!(matches!(
+            decode_jwt_header("!!!.payload.sig"),
+            Err(Error::InvalidJwtFormat)
+        ));
+    }
+
+    #[test]
+    fn test_decode_jwt_header_invalid_json() {
+        let not_json = URL_SAFE_NO_PAD.encode(b"not json");
+        let token = format!("{}.payload.sig", not_json);
+        assert!(matches!(
+            decode_jwt_header(&token),
+            Err(Error::InvalidJwtFormat)
+        ));
+    }
+
+    // -----------------------------------------------------------------------
+    // JwkSet tests
+    // -----------------------------------------------------------------------
+
+    #[test]
+    fn test_jwkset_find() {
+        let set = JwkSet {
+            keys: vec![rsa_jwk(), ec_jwk()],
+        };
+        assert!(set.find(TEST_KID).is_some());
+        assert!(set.find("nonexistent").is_none());
+    }
+
+    // -----------------------------------------------------------------------
+    // verify_jwt_signature tests (native)
+    // -----------------------------------------------------------------------
+
+    #[tokio::test]
+    async fn test_verify_jwt_rsa_rs256() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let result = verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", TEST_ISSUER).await;
+        let result_claims = result.unwrap();
+        assert_eq!(result_claims["iss"], TEST_ISSUER);
+        assert_eq!(result_claims["client_id"], TEST_CLIENT_ID);
+    }
+
+    #[tokio::test]
+    async fn test_verify_jwt_ec_es256() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::ES256, TEST_KID, EC_PEM);
+        let result = verify_jwt_signature(&jwt, &ec_jwk(), "ES256", TEST_ISSUER).await;
+        let result_claims = result.unwrap();
+        assert_eq!(result_claims["iss"], TEST_ISSUER);
+        assert_eq!(result_claims["client_id"], TEST_CLIENT_ID);
+    }
+
+    #[tokio::test]
+    async fn test_verify_jwt_expired() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, true);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let result = verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", TEST_ISSUER).await;
+        assert!(result.is_err());
+        let err = result.unwrap_err();
+        assert!(
+            matches!(err, Error::JwtVerification(ref msg) if msg.contains("expired") || msg.contains("Expired")),
+            "expected expiration error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_verify_jwt_wrong_issuer() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let result =
+            verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", "https://wrong.issuer.com").await;
+        assert!(result.is_err());
+        let err = result.unwrap_err();
+        assert!(
+            matches!(err, Error::JwtVerification(ref msg) if msg.contains("issuer") || msg.contains("Issuer")),
+            "expected issuer error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_verify_jwt_bad_signature() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        // Tamper with the signature
+        let parts: Vec<&str> = jwt.splitn(3, '.').collect();
+        let tampered = format!("{}.{}.{}AAAA", parts[0], parts[1], parts[2]);
+        let result = verify_jwt_signature(&tampered, &rsa_jwk(), "RS256", TEST_ISSUER).await;
+        assert!(result.is_err());
+    }
+
+    #[tokio::test]
+    async fn test_verify_jwt_unsupported_kty() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let mut jwk = rsa_jwk();
+        jwk.kty = "OKP".into();
+        let result = verify_jwt_signature(&jwt, &jwk, "RS256", TEST_ISSUER).await;
+        assert!(matches!(result, Err(Error::UnsupportedSigningAlgo)));
+    }
+
+    // -----------------------------------------------------------------------
+    // verify_cat tests (native, needs pre-populated OidcClient)
+    // -----------------------------------------------------------------------
+
+    #[tokio::test]
+    async fn test_verify_cat_valid() {
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let jwks = JwkSet {
+            keys: vec![rsa_jwk()],
+        };
+        let client = make_test_client(
+            Some(TEST_CLIENT_ID.to_string()),
+            test_oidc_config(),
+            jwks,
+        );
+        client.verify_cat(&jwt).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_verify_cat_wrong_client_id() {
+        let claims = test_claims(TEST_ISSUER, "wrong-client", false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let jwks = JwkSet {
+            keys: vec![rsa_jwk()],
+        };
+        let client = make_test_client(
+            Some(TEST_CLIENT_ID.to_string()),
+            test_oidc_config(),
+            jwks,
+        );
+        let result = client.verify_cat(&jwt).await;
+        assert!(matches!(result, Err(Error::InvalidClientId)));
+    }
+
+    #[tokio::test]
+    async fn test_verify_cat_azp_fallback() {
+        // Token has azp instead of client_id
+        let mut claims = HashMap::new();
+        claims.insert("iss".into(), serde_json::json!(TEST_ISSUER));
+        claims.insert("azp".into(), serde_json::json!(TEST_CLIENT_ID));
+        claims.insert("exp".into(), serde_json::json!(4_102_444_800u64));
+        claims.insert("sub".into(), serde_json::json!("test-user"));
+
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let jwks = JwkSet {
+            keys: vec![rsa_jwk()],
+        };
+        let client = make_test_client(
+            Some(TEST_CLIENT_ID.to_string()),
+            test_oidc_config(),
+            jwks,
+        );
+        client.verify_cat(&jwt).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_verify_cat_no_client_id_check() {
+        // OidcClient without client_id configured - should accept any token
+        let claims = test_claims(TEST_ISSUER, "any-client", false);
+        let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
+        let jwks = JwkSet {
+            keys: vec![rsa_jwk()],
+        };
+        let client = make_test_client(None, test_oidc_config(), jwks);
+        client.verify_cat(&jwt).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_verify_cat_missing_kid() {
+        // Create a JWT without kid in header
+        let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
+        let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
+        let key = jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM).unwrap();
+        let jwt = jsonwebtoken::encode(&header, &claims, &key).unwrap();
+        let jwks = JwkSet {
+            keys: vec![rsa_jwk()],
+        };
+        let client = make_test_client(
+            Some(TEST_CLIENT_ID.to_string()),
+            test_oidc_config(),
+            jwks,
+        );
+        let result = client.verify_cat(&jwt).await;
+        assert!(matches!(result, Err(Error::MissingKidHeader)));
+    }
+}

+ 19 - 0
flake.nix

@@ -686,6 +686,24 @@
               // envVars
             );
 
+            # Shell for WASM browser tests (wasm-pack + Firefox)
+            wasm = pkgs.mkShell (
+              {
+                shellHook = ''
+                  echo "WASM test shell"
+                  echo "  wasm-pack test --firefox --headless crates/cdk-wasm"
+                '';
+                buildInputs = buildInputs ++ [
+                  stable_toolchain
+                  pkgs.wasm-pack
+                  pkgs.firefox
+                  pkgs.geckodriver
+                ];
+                inherit nativeBuildInputs;
+              }
+              // envVars
+            );
+
           in
           {
             inherit
@@ -694,6 +712,7 @@
               nightly
               integration
               ffi
+              wasm
               ;
             default = stable;
           };