An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: fix external PDS OAuth and add client metadata endpoint

- Fix W3C DID Document parsing in discover_pds (was using PLC operation
format; plc.directory returns W3C format with different field names
and structure)
- Add protected resource metadata discovery (RFC 9728) so auth server
discovery works with Bluesky's entryway architecture
- Add DPoP nonce retry for PAR requests (bsky.social requires nonce
even for PAR)
- Add relay route GET /oauth/client-metadata.json serving AT Protocol
OAuth client metadata with dynamic client_id from public_url config
- Make wallet client_id dynamic (derived from configured relay URL)
instead of hardcoded, enabling external auth server compatibility
- Add tauri-plugin-log for iOS logging (tracing bridge via log feature)
- Add comprehensive tracing to entire claim flow (resolve, PDS auth,
verification, submission)
- Fix Secure Enclave key lookup in recovery.rs (use Reference::Key
pattern match instead of nonexistent as_sec_key method)

+956 -124
+281
Cargo.lock
··· 44 44 ] 45 45 46 46 [[package]] 47 + name = "ahash" 48 + version = "0.7.8" 49 + source = "registry+https://github.com/rust-lang/crates.io-index" 50 + checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" 51 + dependencies = [ 52 + "getrandom 0.2.17", 53 + "once_cell", 54 + "version_check", 55 + ] 56 + 57 + [[package]] 47 58 name = "aho-corasick" 48 59 version = "1.1.4" 49 60 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 72 83 version = "0.2.21" 73 84 source = "registry+https://github.com/rust-lang/crates.io-index" 74 85 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 86 + 87 + [[package]] 88 + name = "android_log-sys" 89 + version = "0.3.2" 90 + source = "registry+https://github.com/rust-lang/crates.io-index" 91 + checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" 92 + 93 + [[package]] 94 + name = "android_logger" 95 + version = "0.15.1" 96 + source = "registry+https://github.com/rust-lang/crates.io-index" 97 + checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" 98 + dependencies = [ 99 + "android_log-sys", 100 + "env_filter", 101 + "log", 102 + ] 75 103 76 104 [[package]] 77 105 name = "android_system_properties" ··· 149 177 "cpufeatures", 150 178 "password-hash", 151 179 ] 180 + 181 + [[package]] 182 + name = "arrayvec" 183 + version = "0.7.6" 184 + source = "registry+https://github.com/rust-lang/crates.io-index" 185 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 152 186 153 187 [[package]] 154 188 name = "ascii-canvas" ··· 591 625 ] 592 626 593 627 [[package]] 628 + name = "bitvec" 629 + version = "1.0.1" 630 + source = "registry+https://github.com/rust-lang/crates.io-index" 631 + checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" 632 + dependencies = [ 633 + "funty", 634 + "radium", 635 + "tap", 636 + "wyz", 637 + ] 638 + 639 + [[package]] 594 640 name = "blake2" 595 641 version = "0.10.6" 596 642 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 631 677 ] 632 678 633 679 [[package]] 680 + name = "borsh" 681 + version = "1.6.1" 682 + source = "registry+https://github.com/rust-lang/crates.io-index" 683 + checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" 684 + dependencies = [ 685 + "borsh-derive", 686 + "bytes", 687 + "cfg_aliases", 688 + ] 689 + 690 + [[package]] 691 + name = "borsh-derive" 692 + version = "1.6.1" 693 + source = "registry+https://github.com/rust-lang/crates.io-index" 694 + checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" 695 + dependencies = [ 696 + "once_cell", 697 + "proc-macro-crate 3.5.0", 698 + "proc-macro2", 699 + "quote", 700 + "syn 2.0.117", 701 + ] 702 + 703 + [[package]] 634 704 name = "brotli" 635 705 version = "8.0.2" 636 706 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 658 728 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 659 729 660 730 [[package]] 731 + name = "byte-unit" 732 + version = "5.2.0" 733 + source = "registry+https://github.com/rust-lang/crates.io-index" 734 + checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" 735 + dependencies = [ 736 + "rust_decimal", 737 + "schemars 1.2.1", 738 + "serde", 739 + "utf8-width", 740 + ] 741 + 742 + [[package]] 743 + name = "bytecheck" 744 + version = "0.6.12" 745 + source = "registry+https://github.com/rust-lang/crates.io-index" 746 + checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" 747 + dependencies = [ 748 + "bytecheck_derive", 749 + "ptr_meta", 750 + "simdutf8", 751 + ] 752 + 753 + [[package]] 754 + name = "bytecheck_derive" 755 + version = "0.6.12" 756 + source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" 758 + dependencies = [ 759 + "proc-macro2", 760 + "quote", 761 + "syn 1.0.109", 762 + ] 763 + 764 + [[package]] 661 765 name = "bytemuck" 662 766 version = "1.25.0" 663 767 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1619 1723 ] 1620 1724 1621 1725 [[package]] 1726 + name = "env_filter" 1727 + version = "0.1.4" 1728 + source = "registry+https://github.com/rust-lang/crates.io-index" 1729 + checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" 1730 + dependencies = [ 1731 + "log", 1732 + "regex", 1733 + ] 1734 + 1735 + [[package]] 1622 1736 name = "equivalent" 1623 1737 version = "1.0.2" 1624 1738 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1696 1810 checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 1697 1811 dependencies = [ 1698 1812 "simd-adler32", 1813 + ] 1814 + 1815 + [[package]] 1816 + name = "fern" 1817 + version = "0.7.1" 1818 + source = "registry+https://github.com/rust-lang/crates.io-index" 1819 + checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" 1820 + dependencies = [ 1821 + "log", 1699 1822 ] 1700 1823 1701 1824 [[package]] ··· 1821 1944 ] 1822 1945 1823 1946 [[package]] 1947 + name = "funty" 1948 + version = "2.0.0" 1949 + source = "registry+https://github.com/rust-lang/crates.io-index" 1950 + checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" 1951 + 1952 + [[package]] 1824 1953 name = "futf" 1825 1954 version = "0.1.5" 1826 1955 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2328 2457 version = "0.12.3" 2329 2458 source = "registry+https://github.com/rust-lang/crates.io-index" 2330 2459 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 2460 + dependencies = [ 2461 + "ahash", 2462 + ] 2331 2463 2332 2464 [[package]] 2333 2465 name = "hashbrown" ··· 2828 2960 "crypto", 2829 2961 "hickory-resolver", 2830 2962 "httpmock", 2963 + "log", 2831 2964 "multibase", 2832 2965 "p256", 2833 2966 "rand_core 0.6.4", ··· 2839 2972 "tauri", 2840 2973 "tauri-build", 2841 2974 "tauri-plugin-deep-link", 2975 + "tauri-plugin-log", 2842 2976 "tauri-plugin-opener", 2843 2977 "thiserror 2.0.18", 2844 2978 "tokio", ··· 3608 3742 ] 3609 3743 3610 3744 [[package]] 3745 + name = "num_threads" 3746 + version = "0.1.7" 3747 + source = "registry+https://github.com/rust-lang/crates.io-index" 3748 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 3749 + dependencies = [ 3750 + "libc", 3751 + ] 3752 + 3753 + [[package]] 3611 3754 name = "objc2" 3612 3755 version = "0.6.4" 3613 3756 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4501 4644 ] 4502 4645 4503 4646 [[package]] 4647 + name = "ptr_meta" 4648 + version = "0.1.4" 4649 + source = "registry+https://github.com/rust-lang/crates.io-index" 4650 + checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" 4651 + dependencies = [ 4652 + "ptr_meta_derive", 4653 + ] 4654 + 4655 + [[package]] 4656 + name = "ptr_meta_derive" 4657 + version = "0.1.4" 4658 + source = "registry+https://github.com/rust-lang/crates.io-index" 4659 + checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" 4660 + dependencies = [ 4661 + "proc-macro2", 4662 + "quote", 4663 + "syn 1.0.109", 4664 + ] 4665 + 4666 + [[package]] 4504 4667 name = "quick-xml" 4505 4668 version = "0.38.4" 4506 4669 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4584 4747 version = "6.0.0" 4585 4748 source = "registry+https://github.com/rust-lang/crates.io-index" 4586 4749 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 4750 + 4751 + [[package]] 4752 + name = "radium" 4753 + version = "0.7.0" 4754 + source = "registry+https://github.com/rust-lang/crates.io-index" 4755 + checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" 4587 4756 4588 4757 [[package]] 4589 4758 name = "rand" ··· 4829 4998 ] 4830 4999 4831 5000 [[package]] 5001 + name = "rend" 5002 + version = "0.4.2" 5003 + source = "registry+https://github.com/rust-lang/crates.io-index" 5004 + checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" 5005 + dependencies = [ 5006 + "bytecheck", 5007 + ] 5008 + 5009 + [[package]] 4832 5010 name = "repo-engine" 4833 5011 version = "0.1.0" 4834 5012 ··· 4943 5121 ] 4944 5122 4945 5123 [[package]] 5124 + name = "rkyv" 5125 + version = "0.7.46" 5126 + source = "registry+https://github.com/rust-lang/crates.io-index" 5127 + checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" 5128 + dependencies = [ 5129 + "bitvec", 5130 + "bytecheck", 5131 + "bytes", 5132 + "hashbrown 0.12.3", 5133 + "ptr_meta", 5134 + "rend", 5135 + "rkyv_derive", 5136 + "seahash", 5137 + "tinyvec", 5138 + "uuid", 5139 + ] 5140 + 5141 + [[package]] 5142 + name = "rkyv_derive" 5143 + version = "0.7.46" 5144 + source = "registry+https://github.com/rust-lang/crates.io-index" 5145 + checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" 5146 + dependencies = [ 5147 + "proc-macro2", 5148 + "quote", 5149 + "syn 1.0.109", 5150 + ] 5151 + 5152 + [[package]] 4946 5153 name = "rsa" 4947 5154 version = "0.9.10" 4948 5155 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4973 5180 ] 4974 5181 4975 5182 [[package]] 5183 + name = "rust_decimal" 5184 + version = "1.41.0" 5185 + source = "registry+https://github.com/rust-lang/crates.io-index" 5186 + checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" 5187 + dependencies = [ 5188 + "arrayvec", 5189 + "borsh", 5190 + "bytes", 5191 + "num-traits", 5192 + "rand 0.8.5", 5193 + "rkyv", 5194 + "serde", 5195 + "serde_json", 5196 + "wasm-bindgen", 5197 + ] 5198 + 5199 + [[package]] 4976 5200 name = "rustc-hash" 4977 5201 version = "2.1.1" 4978 5202 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5123 5347 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 5124 5348 5125 5349 [[package]] 5350 + name = "seahash" 5351 + version = "4.1.0" 5352 + source = "registry+https://github.com/rust-lang/crates.io-index" 5353 + checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" 5354 + 5355 + [[package]] 5126 5356 name = "sec1" 5127 5357 version = "0.7.3" 5128 5358 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5468 5698 version = "0.3.8" 5469 5699 source = "registry+https://github.com/rust-lang/crates.io-index" 5470 5700 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 5701 + 5702 + [[package]] 5703 + name = "simdutf8" 5704 + version = "0.1.5" 5705 + source = "registry+https://github.com/rust-lang/crates.io-index" 5706 + checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 5471 5707 5472 5708 [[package]] 5473 5709 name = "similar" ··· 6008 6244 ] 6009 6245 6010 6246 [[package]] 6247 + name = "tap" 6248 + version = "1.0.1" 6249 + source = "registry+https://github.com/rust-lang/crates.io-index" 6250 + checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 6251 + 6252 + [[package]] 6011 6253 name = "target-lexicon" 6012 6254 version = "0.12.16" 6013 6255 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6166 6408 ] 6167 6409 6168 6410 [[package]] 6411 + name = "tauri-plugin-log" 6412 + version = "2.8.0" 6413 + source = "registry+https://github.com/rust-lang/crates.io-index" 6414 + checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" 6415 + dependencies = [ 6416 + "android_logger", 6417 + "byte-unit", 6418 + "fern", 6419 + "log", 6420 + "objc2", 6421 + "objc2-foundation", 6422 + "serde", 6423 + "serde_json", 6424 + "serde_repr", 6425 + "swift-rs", 6426 + "tauri", 6427 + "tauri-plugin", 6428 + "thiserror 2.0.18", 6429 + "time", 6430 + ] 6431 + 6432 + [[package]] 6169 6433 name = "tauri-plugin-opener" 6170 6434 version = "2.5.3" 6171 6435 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6379 6643 dependencies = [ 6380 6644 "deranged", 6381 6645 "itoa", 6646 + "libc", 6382 6647 "num-conv", 6648 + "num_threads", 6383 6649 "powerfmt", 6384 6650 "serde_core", 6385 6651 "time-core", ··· 7007 7273 version = "0.7.6" 7008 7274 source = "registry+https://github.com/rust-lang/crates.io-index" 7009 7275 checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 7276 + 7277 + [[package]] 7278 + name = "utf8-width" 7279 + version = "0.1.8" 7280 + source = "registry+https://github.com/rust-lang/crates.io-index" 7281 + checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" 7010 7282 7011 7283 [[package]] 7012 7284 name = "utf8_iter" ··· 8099 8371 "windows-core 0.61.2", 8100 8372 "windows-version", 8101 8373 "x11-dl", 8374 + ] 8375 + 8376 + [[package]] 8377 + name = "wyz" 8378 + version = "0.5.1" 8379 + source = "registry+https://github.com/rust-lang/crates.io-index" 8380 + checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" 8381 + dependencies = [ 8382 + "tap", 8102 8383 ] 8103 8384 8104 8385 [[package]]
+1 -1
Cargo.toml
··· 44 44 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } 45 45 46 46 # Observability 47 - tracing = "0.1" 47 + tracing = { version = "0.1", features = ["log"] } 48 48 tracing-subscriber = { version = "0.3", features = ["env-filter"] } 49 49 tracing-opentelemetry = "0.29" 50 50 opentelemetry = { version = "0.28", features = ["trace"] }
+2
apps/identity-wallet/src-tauri/Cargo.toml
··· 17 17 tauri = { version = "2", features = [] } 18 18 tauri-plugin-deep-link = "2" 19 19 tauri-plugin-opener = "2" 20 + tauri-plugin-log = "2" 21 + log = "0.4" 20 22 url = "2" 21 23 # serde/serde_json are in workspace.dependencies (root Cargo.toml lines 30-32) 22 24 serde = { workspace = true }
+280 -75
apps/identity-wallet/src-tauri/src/claim.rs
··· 199 199 let (did, mut handle_for_fallback) = if is_did { 200 200 (handle_or_did.clone(), None) 201 201 } else { 202 - ( 203 - pds_client 204 - .resolve_handle(&handle_or_did) 205 - .await 206 - .map_err(map_pds_error_to_resolve)?, 207 - Some(handle_or_did.clone()), 208 - ) 202 + match pds_client.resolve_handle(&handle_or_did).await { 203 + Ok(did) => { 204 + tracing::info!(handle = %handle_or_did, did = %did, "handle resolved"); 205 + (did, Some(handle_or_did.clone())) 206 + } 207 + Err(e) => { 208 + tracing::error!(handle = %handle_or_did, error = %e, "handle resolution failed"); 209 + return Err(map_pds_error_to_resolve(e)); 210 + } 211 + } 209 212 }; 210 213 211 214 // Fetch DID document and PDS endpoint from plc.directory 212 - let (pds_url, did_doc) = pds_client 213 - .discover_pds(&did) 214 - .await 215 - .map_err(map_pds_error_to_resolve)?; 215 + let (pds_url, mut did_doc) = match pds_client.discover_pds(&did).await { 216 + Ok(result) => result, 217 + Err(e) => { 218 + tracing::error!(did = %did, error = %e, "PDS discovery failed"); 219 + return Err(map_pds_error_to_resolve(e)); 220 + } 221 + }; 222 + 223 + // The W3C DID Document doesn't include rotation keys — fetch them from the audit log. 224 + match pds_client.fetch_audit_log(&did).await { 225 + Ok(raw_log) => { 226 + did_doc.rotation_keys = crate::pds_client::rotation_keys_from_audit_log(&raw_log); 227 + tracing::debug!(did = %did, count = did_doc.rotation_keys.len(), "populated rotation keys from audit log"); 228 + } 229 + Err(e) => { 230 + tracing::warn!(did = %did, error = %e, "failed to fetch audit log for rotation keys"); 231 + } 232 + } 216 233 217 234 // Extract handle from also_known_as (format: at://handle) 218 235 let handle = extract_handle_from_also_known_as(&did_doc.also_known_as) ··· 351 368 let pds_client = state.pds_client(); 352 369 353 370 // 2. Discover auth server metadata from the PDS 371 + tracing::debug!(pds_url = %pds_url, "discovering auth server metadata"); 354 372 let metadata = pds_client 355 373 .discover_auth_server(&pds_url) 356 374 .await 357 - .map_err(|e| ClaimError::NetworkError { 358 - message: format!("failed to discover auth server: {}", e), 375 + .map_err(|e| { 376 + tracing::error!(pds_url = %pds_url, error = %e, "auth server discovery failed"); 377 + ClaimError::NetworkError { 378 + message: format!("failed to discover auth server: {}", e), 379 + } 359 380 })?; 381 + tracing::debug!(issuer = %metadata.issuer, "auth server metadata discovered"); 360 382 361 383 // 3. Generate PKCE and CSRF state 362 384 let (pkce_verifier, pkce_challenge) = crate::oauth::pkce::generate(); ··· 364 386 365 387 // 4. Get DPoP keypair and compute thumbprint 366 388 let dpop = 367 - crate::oauth::DPoPKeypair::get_or_create().map_err(|_| ClaimError::NetworkError { 368 - message: "failed to create DPoP keypair".to_string(), 389 + crate::oauth::DPoPKeypair::get_or_create().map_err(|e| { 390 + tracing::error!(error = %e, "DPoP keypair creation failed"); 391 + ClaimError::NetworkError { 392 + message: "failed to create DPoP keypair".to_string(), 393 + } 369 394 })?; 370 395 let dpop_jkt = dpop.public_jwk_thumbprint(); 371 396 372 - // 5. Build DPoP proof for PAR 397 + // 5-6. PAR with DPoP nonce retry 373 398 let par_htu = metadata 374 399 .pushed_authorization_request_endpoint 375 400 .as_ref() 376 401 .cloned() 377 402 .unwrap_or_else(|| format!("{}/oauth/par", metadata.issuer)); 378 403 379 - let par_proof = 380 - dpop.make_proof("POST", &par_htu, None, None) 381 - .map_err(|_| ClaimError::NetworkError { 382 - message: "failed to create DPoP proof for PAR".to_string(), 383 - })?; 404 + let relay_url = state.relay_client().base_url_str().to_string(); 405 + let client_id = crate::pds_client::client_id_for_relay(&relay_url); 384 406 385 - // 6. Call PDS PAR with the DID as login_hint 386 - let par_resp = pds_client 387 - .pds_par( 388 - &metadata, 389 - &pkce_challenge, 390 - &csrf_state, 391 - &par_proof, 392 - &dpop_jkt, 393 - Some(&did), 394 - ) 395 - .await 396 - .map_err(|e| ClaimError::NetworkError { 397 - message: format!("PAR failed: {}", e), 398 - })?; 407 + let par_resp = pds_par_with_retry(pds_client, &dpop, &metadata, &par_htu, &pkce_challenge, &csrf_state, &dpop_jkt, &did, &client_id).await?; 408 + tracing::debug!("PAR succeeded, opening browser"); 399 409 400 410 // 7. Set up oneshot channel and park pending_auth 401 411 let (tx, rx) = tokio::sync::oneshot::channel::< ··· 415 425 &metadata, 416 426 &par_resp.request_uri, 417 427 Some(&did), 428 + &client_id, 418 429 ); 419 430 420 431 app.opener() ··· 425 436 })?; 426 437 427 438 // 9. Await the deep-link callback 439 + tracing::debug!("waiting for deep-link callback"); 428 440 let callback = rx 429 441 .await 430 - .map_err(|_| ClaimError::Unauthorized)? 431 - .map_err(|_| ClaimError::Unauthorized)?; 442 + .map_err(|e| { 443 + tracing::error!(error = %e, "deep-link callback channel closed"); 444 + ClaimError::Unauthorized 445 + })? 446 + .map_err(|e| { 447 + tracing::error!(error = %e, "deep-link callback returned OAuth error"); 448 + ClaimError::Unauthorized 449 + })?; 450 + tracing::debug!("deep-link callback received, exchanging code"); 432 451 433 452 // 10. Token exchange with nonce retry 434 453 let (token_resp, initial_nonce) = 435 - pds_exchange_code_with_retry(pds_client, &dpop, &callback.code, &pkce_verifier, &metadata) 436 - .await?; 454 + pds_exchange_code_with_retry(pds_client, &dpop, &callback.code, &pkce_verifier, &metadata, &client_id) 455 + .await 456 + .map_err(|e| { 457 + tracing::error!(error = %e, "PDS token exchange failed"); 458 + e 459 + })?; 437 460 438 461 // 11. Create OAuthClient and store in ClaimState 439 462 let session = std::sync::Arc::new(std::sync::Mutex::new(crate::oauth::OAuthSession { ··· 474 497 Ok(()) 475 498 } 476 499 500 + /// Helper function for PAR with DPoP nonce retry. 501 + /// 502 + /// Some authorization servers (e.g. bsky.social) require a DPoP nonce even for 503 + /// PAR. On the first call, the server returns 400 `use_dpop_nonce` with a 504 + /// `DPoP-Nonce` header. We extract the nonce, rebuild the DPoP proof, and retry. 505 + #[allow(clippy::too_many_arguments)] 506 + async fn pds_par_with_retry( 507 + pds_client: &crate::pds_client::PdsClient, 508 + dpop: &crate::oauth::DPoPKeypair, 509 + metadata: &crate::pds_client::AuthServerMetadata, 510 + par_htu: &str, 511 + pkce_challenge: &str, 512 + csrf_state: &str, 513 + dpop_jkt: &str, 514 + did: &str, 515 + client_id: &str, 516 + ) -> Result<crate::pds_client::PdsParResponse, ClaimError> { 517 + let par_proof = dpop 518 + .make_proof("POST", par_htu, None, None) 519 + .map_err(|e| { 520 + tracing::error!(error = %e, "DPoP proof generation failed for PAR"); 521 + ClaimError::NetworkError { 522 + message: "failed to create DPoP proof for PAR".to_string(), 523 + } 524 + })?; 525 + 526 + tracing::debug!(par_endpoint = %par_htu, "sending PAR request"); 527 + match pds_client 528 + .pds_par(metadata, pkce_challenge, csrf_state, &par_proof, dpop_jkt, Some(did), client_id) 529 + .await 530 + { 531 + Ok(resp) => return Ok(resp), 532 + Err(crate::pds_client::PdsClientError::OauthFailed { message }) 533 + if message.contains("use_dpop_nonce") => 534 + { 535 + tracing::debug!("PAR requires DPoP nonce, retrying"); 536 + } 537 + Err(e) => { 538 + tracing::error!(error = %e, "PAR request failed"); 539 + return Err(ClaimError::NetworkError { 540 + message: format!("PAR failed: {}", e), 541 + }); 542 + } 543 + } 544 + 545 + // The nonce is in the error response body; we need to get it from the raw 546 + // response. Re-issue the PAR as a raw request to extract the nonce header. 547 + let raw_par_url = metadata 548 + .pushed_authorization_request_endpoint 549 + .clone() 550 + .unwrap_or_else(|| format!("{}/oauth/par", metadata.issuer)); 551 + 552 + let nonce_proof = dpop 553 + .make_proof("POST", par_htu, None, None) 554 + .map_err(|_| ClaimError::NetworkError { 555 + message: "failed to create DPoP proof for nonce discovery".to_string(), 556 + })?; 557 + 558 + let form_data = vec![ 559 + ("response_type", "code"), 560 + ("code_challenge_method", "S256"), 561 + ("code_challenge", pkce_challenge), 562 + ("state", csrf_state), 563 + ("client_id", client_id), 564 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 565 + ("scope", "atproto transition:generic"), 566 + ("dpop_jkt", dpop_jkt), 567 + ("login_hint", did), 568 + ]; 569 + 570 + let nonce_resp = pds_client 571 + .client() 572 + .post(&raw_par_url) 573 + .header("DPoP", &nonce_proof) 574 + .form(&form_data) 575 + .send() 576 + .await 577 + .map_err(|e| ClaimError::NetworkError { 578 + message: format!("PAR nonce discovery failed: {}", e), 579 + })?; 580 + 581 + let nonce = nonce_resp 582 + .headers() 583 + .get("DPoP-Nonce") 584 + .and_then(|v| v.to_str().ok()) 585 + .map(str::to_string); 586 + 587 + let Some(nonce_val) = nonce else { 588 + tracing::error!("PAR returned use_dpop_nonce but no DPoP-Nonce header"); 589 + return Err(ClaimError::NetworkError { 590 + message: "PAR requires nonce but server did not provide one".to_string(), 591 + }); 592 + }; 593 + 594 + tracing::debug!(nonce = %nonce_val, "retrying PAR with DPoP nonce"); 595 + let retry_proof = dpop 596 + .make_proof("POST", par_htu, Some(&nonce_val), None) 597 + .map_err(|e| { 598 + tracing::error!(error = %e, "DPoP proof with nonce failed"); 599 + ClaimError::NetworkError { 600 + message: "failed to create DPoP proof with nonce".to_string(), 601 + } 602 + })?; 603 + 604 + pds_client 605 + .pds_par(metadata, pkce_challenge, csrf_state, &retry_proof, dpop_jkt, Some(did), client_id) 606 + .await 607 + .map_err(|e| { 608 + tracing::error!(error = %e, "PAR retry with nonce failed"); 609 + ClaimError::NetworkError { 610 + message: format!("PAR retry failed: {}", e), 611 + } 612 + }) 613 + } 614 + 477 615 /// Helper function for token exchange with nonce retry (PDS version). 478 616 /// 479 617 /// Follows the same pattern as `exchange_code_with_retry` in oauth.rs. ··· 484 622 code: &str, 485 623 pkce_verifier: &str, 486 624 metadata: &crate::pds_client::AuthServerMetadata, 625 + client_id: &str, 487 626 ) -> Result<(crate::http::TokenResponse, Option<String>), ClaimError> { 488 627 let token_htu = &metadata.token_endpoint; 628 + tracing::debug!(token_endpoint = %token_htu, "starting PDS token exchange"); 489 629 let proof = dpop 490 630 .make_proof("POST", token_htu, None, None) 491 - .map_err(|_| ClaimError::NetworkError { 492 - message: "failed to create DPoP proof for token exchange".to_string(), 631 + .map_err(|e| { 632 + tracing::error!(error = %e, "DPoP proof for token exchange failed"); 633 + ClaimError::NetworkError { 634 + message: "failed to create DPoP proof for token exchange".to_string(), 635 + } 493 636 })?; 494 637 495 638 let resp = pds_client 496 - .pds_token_exchange(metadata, code, pkce_verifier, &proof) 639 + .pds_token_exchange(metadata, code, pkce_verifier, &proof, client_id) 497 640 .await 498 - .map_err(|e| ClaimError::NetworkError { 499 - message: format!("token exchange failed: {}", e), 641 + .map_err(|e| { 642 + tracing::error!(error = %e, "PDS token exchange request failed"); 643 + ClaimError::NetworkError { 644 + message: format!("token exchange failed: {}", e), 645 + } 500 646 })?; 501 647 648 + tracing::debug!(status = %resp.status(), "PDS token exchange response received"); 502 649 if resp.status().as_u16() == 200 { 503 650 let nonce = resp 504 651 .headers() ··· 522 669 .map(str::to_string); 523 670 524 671 let error_body = resp.text().await.unwrap_or_else(|_| "{}".to_string()); 672 + tracing::debug!(status = "non-200", body = %error_body, "token exchange needs retry or failed"); 525 673 526 674 // Detect nonce retry by checking error JSON for "use_dpop_nonce" error code. 527 675 // This is fragile string matching based on PDS/OAuth server error responses. ··· 537 685 })?; 538 686 539 687 let retry_resp = pds_client 540 - .pds_token_exchange(metadata, code, pkce_verifier, &proof_with_nonce) 688 + .pds_token_exchange(metadata, code, pkce_verifier, &proof_with_nonce, client_id) 541 689 .await 542 690 .map_err(|e| ClaimError::NetworkError { 543 691 message: format!("token exchange retry failed: {}", e), ··· 557 705 })?; 558 706 return Ok((token, retry_nonce)); 559 707 } else { 560 - // Retry response was non-200, extract status and body for error message 561 708 let status = retry_resp.status(); 562 709 let body = retry_resp 563 710 .text() 564 711 .await 565 712 .unwrap_or_else(|_| "(unable to read response body)".to_string()); 713 + tracing::error!(status = %status, body = %body, "token exchange retry failed"); 566 714 return Err(ClaimError::NetworkError { 567 715 message: format!("token exchange retry returned {}: {}", status, body), 568 716 }); ··· 571 719 } 572 720 } 573 721 722 + tracing::error!(body = %error_body, "token exchange failed with non-retryable error"); 574 723 Err(ClaimError::NetworkError { 575 724 message: format!( 576 725 "token exchange returned non-success response: {}", ··· 621 770 claim_state: &ClaimState, 622 771 ) -> Result<(), ClaimError> { 623 772 let Some(ref oauth_client) = claim_state.pds_oauth_client else { 773 + tracing::error!("request_claim_verification: no pds_oauth_client in ClaimState"); 624 774 return Err(ClaimError::Unauthorized); 625 775 }; 626 776 777 + tracing::debug!("calling requestPlcOperationSignature XRPC"); 627 778 crate::pds_client::request_plc_operation_signature(oauth_client) 628 779 .await 629 - .map_err(|e| ClaimError::NetworkError { 630 - message: format!("request_plc_operation_signature failed: {}", e), 631 - }) 780 + .map_err(|e| { 781 + tracing::error!(error = %e, "requestPlcOperationSignature failed"); 782 + ClaimError::NetworkError { 783 + message: format!("request_plc_operation_signature failed: {}", e), 784 + } 785 + })?; 786 + tracing::info!("email verification requested successfully"); 787 + Ok(()) 632 788 } 633 789 634 790 /// Sign and verify a PLC operation. ··· 723 879 }; 724 880 725 881 // Step 1: Get recommended credentials from old PDS 882 + tracing::debug!(did = %did, "fetching recommended DID credentials from PDS"); 726 883 let recommended = get_recommended_did_credentials(pds_oauth_client) 727 884 .await 728 - .map_err(|e| ClaimError::NetworkError { 729 - message: format!("get_recommended_did_credentials failed: {}", e), 885 + .map_err(|e| { 886 + tracing::error!(did = %did, error = %e, "getRecommendedDidCredentials failed"); 887 + ClaimError::NetworkError { 888 + message: format!("get_recommended_did_credentials failed: {}", e), 889 + } 730 890 })?; 891 + tracing::debug!(did = %did, "recommended credentials received"); 731 892 732 893 // Step 2: Build the sign request with device key at position [0] 733 894 let mut rotation_keys = vec![device_key_id.to_string()]; ··· 744 905 }; 745 906 746 907 // Step 3: Call signPlcOperation on old PDS 908 + tracing::debug!(did = %did, "calling signPlcOperation on PDS"); 747 909 let response = sign_plc_operation(pds_oauth_client, &request) 748 910 .await 749 911 .map_err(|e| { 750 - // Check if this is an invalid token error 751 - // OAuthClient intercepts 400 responses with {"error": "InvalidToken"} and returns 752 - // OAuthError::NotAuthenticated, which becomes NetworkError("sign_plc_operation failed: Not authenticated") 912 + tracing::error!(did = %did, error = %e, "signPlcOperation failed"); 753 913 if let crate::pds_client::PdsClientError::NetworkError { message } = &e { 754 914 let lower_msg = message.to_lowercase(); 755 915 if lower_msg.contains("invalidtoken") ··· 763 923 message: format!("sign_plc_operation failed: {}", e), 764 924 } 765 925 })?; 926 + tracing::debug!(did = %did, "signPlcOperation succeeded"); 766 927 767 928 // Step 4: Keep operation as JSON value (no need to serialize/deserialize) 768 929 let op_value = response.operation.clone(); 769 930 770 931 // Step 5: Fetch current audit log and get expected prev CID 932 + tracing::debug!(did = %did, "fetching audit log for verification"); 771 933 let log_json = pds_client 772 934 .fetch_audit_log(did) 773 935 .await 774 - .map_err(|e| ClaimError::NetworkError { 775 - message: format!("fetch_audit_log failed: {}", e), 936 + .map_err(|e| { 937 + tracing::error!(did = %did, error = %e, "fetch_audit_log failed"); 938 + ClaimError::NetworkError { 939 + message: format!("fetch_audit_log failed: {}", e), 940 + } 776 941 })?; 777 942 778 - let audit_log = crypto::parse_audit_log(&log_json).map_err(|e| ClaimError::NetworkError { 779 - message: format!("parse_audit_log failed: {}", e), 943 + let audit_log = crypto::parse_audit_log(&log_json).map_err(|e| { 944 + tracing::error!(did = %did, error = %e, "parse_audit_log failed"); 945 + ClaimError::NetworkError { 946 + message: format!("parse_audit_log failed: {}", e), 947 + } 780 948 })?; 949 + tracing::debug!(did = %did, entries = audit_log.len(), "audit log fetched"); 781 950 782 951 let expected_prev = audit_log.last().map(|entry| entry.cid.clone()); 783 952 ··· 792 961 .map(|k| DidKeyUri(k.clone())) 793 962 .collect(); 794 963 964 + tracing::debug!(did = %did, authorized_keys = authorized_keys.len(), "verifying PLC operation signature"); 795 965 let verified_op = 796 966 crypto::verify_plc_operation(&op_json_str, &authorized_keys).map_err(|e| { 967 + tracing::error!(did = %did, error = %e, "PLC operation signature verification failed"); 797 968 ClaimError::VerificationFailed { 798 969 message: format!("signature verification failed: {}", e), 799 970 } 800 971 })?; 972 + tracing::debug!(did = %did, "signature verified, running local checks"); 801 973 802 974 // Step 7: Local verification checks 803 975 804 976 // Check 1: rotationKeys[0] is our device key 805 977 if verified_op.rotation_keys.first() != Some(&device_key_id.to_string()) { 978 + tracing::error!( 979 + did = %did, 980 + expected = %device_key_id, 981 + actual = ?verified_op.rotation_keys.first(), 982 + "device key not at rotationKeys[0]" 983 + ); 806 984 return Err(ClaimError::VerificationFailed { 807 985 message: format!( 808 986 "expected device key at rotationKeys[0], found: {:?}", ··· 815 993 match (&verified_op.prev, expected_prev.as_deref()) { 816 994 (Some(op_prev), Some(expected)) if op_prev == expected => { /* OK */ } 817 995 (prev, expected) => { 996 + tracing::error!(did = %did, op_prev = ?prev, expected = ?expected, "prev CID mismatch"); 818 997 return Err(ClaimError::VerificationFailed { 819 998 message: format!( 820 999 "prev mismatch: operation has {:?}, expected {:?}", ··· 951 1130 ) -> Result<ClaimResult, ClaimError> { 952 1131 // Step 1: Read verified_signed_op from ClaimState 953 1132 let Some(ref operation) = claim_state.verified_signed_op else { 1133 + tracing::error!(did = %claim_state.did, "submit_claim: no verified_signed_op in ClaimState"); 954 1134 return Err(ClaimError::Unauthorized); 955 1135 }; 956 1136 957 1137 // Step 2: POST the signed operation to plc.directory 1138 + tracing::info!(did = %claim_state.did, "submitting signed PLC operation to plc.directory"); 958 1139 pds_client 959 1140 .post_plc_operation(&claim_state.did, operation) 960 1141 .await 961 - .map_err(|e| match e { 962 - crate::pds_client::PdsClientError::InvalidResponse { message } => { 963 - ClaimError::PlcDirectoryError { message } 1142 + .map_err(|e| { 1143 + tracing::error!(did = %claim_state.did, error = %e, "post_plc_operation failed"); 1144 + match e { 1145 + crate::pds_client::PdsClientError::InvalidResponse { message } => { 1146 + ClaimError::PlcDirectoryError { message } 1147 + } 1148 + other => ClaimError::NetworkError { 1149 + message: format!("post_plc_operation failed: {}", other), 1150 + }, 964 1151 } 965 - other => ClaimError::NetworkError { 966 - message: format!("post_plc_operation failed: {}", other), 967 - }, 968 1152 })?; 1153 + tracing::info!(did = %claim_state.did, "PLC operation accepted by plc.directory"); 969 1154 970 1155 // Step 3: Persist the claimed identity to IdentityStore 971 1156 let store = IdentityStore; 972 1157 973 1158 // 3a: Register DID in managed-dids index (may already exist from prior attempts) 1159 + tracing::debug!(did = %claim_state.did, "registering identity in store"); 974 1160 if let Err(e) = store.add_identity(&claim_state.did) { 975 - // IdentityAlreadyExists is fine — user may have a partially completed prior claim 976 1161 if !matches!( 977 1162 e, 978 1163 crate::identity_store::IdentityStoreError::IdentityAlreadyExists 979 1164 ) { 1165 + tracing::error!(did = %claim_state.did, error = %e, "failed to add identity to store"); 980 1166 return Err(ClaimError::NetworkError { 981 1167 message: format!("failed to add identity: {}", e), 982 1168 }); 983 1169 } 1170 + tracing::debug!(did = %claim_state.did, "identity already exists in store (prior partial claim)"); 984 1171 } 985 1172 986 1173 // 3b: Ensure device key exists for the DID 987 1174 store 988 1175 .get_or_create_device_key(&claim_state.did) 989 - .map_err(|e| ClaimError::NetworkError { 990 - message: format!("failed to get or create device key: {}", e), 1176 + .map_err(|e| { 1177 + tracing::error!(did = %claim_state.did, error = %e, "device key creation failed"); 1178 + ClaimError::NetworkError { 1179 + message: format!("failed to get or create device key: {}", e), 1180 + } 991 1181 })?; 992 1182 993 1183 // 3c: Re-fetch the DID document from plc.directory 1184 + tracing::debug!(did = %claim_state.did, "re-fetching DID document after claim"); 994 1185 let (_, updated_did_doc) = pds_client 995 1186 .discover_pds(&claim_state.did) 996 1187 .await 997 - .map_err(|e| ClaimError::NetworkError { 998 - message: format!("failed to re-fetch DID document: {}", e), 1188 + .map_err(|e| { 1189 + tracing::error!(did = %claim_state.did, error = %e, "failed to re-fetch DID document"); 1190 + ClaimError::NetworkError { 1191 + message: format!("failed to re-fetch DID document: {}", e), 1192 + } 999 1193 })?; 1000 1194 1001 1195 // Store the updated DID document as JSON string ··· 1022 1216 1023 1217 store 1024 1218 .store_did_doc(&claim_state.did, &did_doc_json) 1025 - .map_err(|e| ClaimError::NetworkError { 1026 - message: format!("failed to store DID document: {}", e), 1219 + .map_err(|e| { 1220 + tracing::error!(did = %claim_state.did, error = %e, "failed to store DID document"); 1221 + ClaimError::NetworkError { 1222 + message: format!("failed to store DID document: {}", e), 1223 + } 1027 1224 })?; 1028 1225 1029 1226 // 3d: Fetch and store the PLC audit log 1227 + tracing::debug!(did = %claim_state.did, "fetching audit log for persistence"); 1030 1228 let log_json = pds_client 1031 1229 .fetch_audit_log(&claim_state.did) 1032 1230 .await 1033 - .map_err(|e| ClaimError::NetworkError { 1034 - message: format!("failed to fetch audit log: {}", e), 1231 + .map_err(|e| { 1232 + tracing::error!(did = %claim_state.did, error = %e, "failed to fetch audit log for persistence"); 1233 + ClaimError::NetworkError { 1234 + message: format!("failed to fetch audit log: {}", e), 1235 + } 1035 1236 })?; 1036 1237 1037 1238 store 1038 1239 .store_plc_log(&claim_state.did, &log_json) 1039 - .map_err(|e| ClaimError::NetworkError { 1040 - message: format!("failed to store PLC log: {}", e), 1240 + .map_err(|e| { 1241 + tracing::error!(did = %claim_state.did, error = %e, "failed to store PLC log"); 1242 + ClaimError::NetworkError { 1243 + message: format!("failed to store PLC log: {}", e), 1244 + } 1041 1245 })?; 1246 + tracing::info!(did = %claim_state.did, "identity claim persisted successfully"); 1042 1247 1043 1248 // Step 4: Clear ClaimState (handled by the Tauri command caller after this function succeeds) 1044 1249 // Step 5: Return the updated DID document
+3
apps/identity-wallet/src-tauri/src/lib.rs
··· 757 757 pub fn run() { 758 758 tauri::Builder::default() 759 759 .manage(oauth::AppState::new()) 760 + .plugin(tauri_plugin_log::Builder::new() 761 + .level(log::LevelFilter::Debug) 762 + .build()) 760 763 .plugin(tauri_plugin_deep_link::init()) 761 764 .plugin(tauri_plugin_opener::init()) 762 765 .setup(|app| {
+256 -43
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 10 10 use reqwest::Client; 11 11 use serde::{Deserialize, Serialize}; 12 12 13 - /// OAuth client ID for the identity wallet application 14 - const CLIENT_ID: &str = "dev.malpercio.identitywallet"; 13 + /// OAuth client metadata path, appended to the relay's public URL to form the `client_id`. 14 + /// 15 + /// External auth servers (e.g. bsky.social) GET `{relay_url}/oauth/client-metadata.json` 16 + /// to discover redirect_uris, grant_types, etc. 17 + const CLIENT_METADATA_PATH: &str = "/oauth/client-metadata.json"; 15 18 16 - /// OAuth redirect URI for the identity wallet application 19 + /// OAuth redirect URI for external PDS authentication. 17 20 const REDIRECT_URI: &str = "dev.malpercio.identitywallet:/oauth/callback"; 21 + 22 + /// Build the OAuth client_id URL from a relay base URL. 23 + /// 24 + /// The client_id is the relay's public URL + `/oauth/client-metadata.json`. 25 + /// This must match what the relay serves at that path. 26 + pub fn client_id_for_relay(relay_url: &str) -> String { 27 + format!("{}{}", relay_url.trim_end_matches('/'), CLIENT_METADATA_PATH) 28 + } 18 29 19 30 /// Error type for PDS client operations. 20 31 /// ··· 53 64 OauthFailed { message: String }, 54 65 } 55 66 56 - /// PLC directory DID document response. 67 + /// PLC operation data for a DID. 57 68 /// 58 - /// Returned from `GET {plc_directory_url}/{did}`. 59 - /// Field names use camelCase per the API. 69 + /// Combines fields from the W3C DID Document (`GET /{did}`) and the PLC audit log 70 + /// (`GET /{did}/log/audit`). `rotation_keys` only exist in the audit log — they are 71 + /// NOT part of the W3C DID Document and must be populated separately. 60 72 #[derive(Debug, Clone, Deserialize)] 61 73 #[serde(rename_all = "camelCase")] 62 74 pub struct PlcDidDocument { 63 75 pub did: String, 64 76 pub also_known_as: Vec<String>, 77 + /// Rotation keys from the latest PLC operation. Empty if only populated from 78 + /// the W3C DID Document (which doesn't include rotation keys). 79 + #[serde(default)] 65 80 pub rotation_keys: Vec<String>, 66 81 pub verification_methods: serde_json::Value, 67 82 pub services: HashMap<String, PlcService>, ··· 75 90 pub endpoint: String, 76 91 } 77 92 93 + // ── W3C DID Document (private, for parsing `GET /{did}` responses) ─────────── 94 + 95 + /// W3C DID Document as returned by `GET {plc_directory_url}/{did}`. 96 + /// Different shape from PLC operations: `id` not `did`, arrays not HashMaps. 97 + #[derive(Debug, Deserialize)] 98 + #[serde(rename_all = "camelCase")] 99 + struct W3cDidDocument { 100 + id: String, 101 + #[serde(default)] 102 + also_known_as: Vec<String>, 103 + #[serde(default)] 104 + verification_method: Vec<W3cVerificationMethod>, 105 + #[serde(default)] 106 + service: Vec<W3cService>, 107 + } 108 + 109 + #[derive(Debug, Deserialize)] 110 + #[serde(rename_all = "camelCase")] 111 + struct W3cVerificationMethod { 112 + id: String, 113 + #[serde(default)] 114 + public_key_multibase: Option<String>, 115 + } 116 + 117 + #[derive(Debug, Deserialize)] 118 + #[serde(rename_all = "camelCase")] 119 + struct W3cService { 120 + id: String, 121 + #[serde(rename = "type")] 122 + service_type: String, 123 + service_endpoint: String, 124 + } 125 + 126 + impl W3cDidDocument { 127 + /// Convert to PlcDidDocument. `rotation_keys` will be empty — the caller 128 + /// must populate them from the audit log if needed. 129 + fn into_plc_doc(self) -> PlcDidDocument { 130 + // Convert verification_method array to the { "atproto": "did:key:..." } shape 131 + let mut vm_map = serde_json::Map::new(); 132 + for method in &self.verification_method { 133 + // Strip the "did:plc:...#" prefix from the id to get the key name 134 + let key_name = method.id.rsplit_once('#') 135 + .map(|(_, name)| name.to_string()) 136 + .unwrap_or_else(|| method.id.clone()); 137 + if let Some(ref pkm) = method.public_key_multibase { 138 + vm_map.insert(key_name, serde_json::Value::String(pkm.clone())); 139 + } 140 + } 141 + 142 + // Convert service array to HashMap keyed by id (strip leading '#') 143 + let services = self.service.into_iter().map(|svc| { 144 + let key = svc.id.strip_prefix('#').unwrap_or(&svc.id).to_string(); 145 + let plc_svc = PlcService { 146 + service_type: svc.service_type, 147 + endpoint: svc.service_endpoint, 148 + }; 149 + (key, plc_svc) 150 + }).collect(); 151 + 152 + PlcDidDocument { 153 + did: self.id, 154 + also_known_as: self.also_known_as, 155 + rotation_keys: Vec::new(), 156 + verification_methods: serde_json::Value::Object(vm_map), 157 + services, 158 + } 159 + } 160 + } 161 + 78 162 /// OAuth authorization server metadata. 79 163 /// 80 164 /// Returned from `GET {pds_url}/.well-known/oauth-authorization-server`. ··· 243 327 _ => {} 244 328 } 245 329 246 - // Parse response as PlcDidDocument 247 - let doc: PlcDidDocument = 330 + // Parse W3C DID Document and convert to PlcDidDocument. 331 + // rotation_keys will be empty — callers that need them must fetch the audit log. 332 + let w3c_doc: W3cDidDocument = 248 333 response 249 334 .json() 250 335 .await 251 336 .map_err(|e| PdsClientError::InvalidResponse { 252 337 message: format!("failed to parse DID document: {}", e), 253 338 })?; 339 + let doc = w3c_doc.into_plc_doc(); 254 340 255 341 // Extract the atproto_pds service 256 342 let pds_service = ··· 275 361 Ok((pds_endpoint.to_string(), doc)) 276 362 } 277 363 278 - /// Fetch OAuth authorization server metadata from the PDS. 364 + /// Discover the OAuth authorization server for a PDS. 365 + /// 366 + /// Follows RFC 9728 (OAuth Protected Resource Metadata): 367 + /// 1. Try `GET {pds_url}/.well-known/oauth-protected-resource` to find the 368 + /// authorization server URL (e.g. Bluesky entryway at `bsky.social`) 369 + /// 2. Fetch `GET {auth_server}/.well-known/oauth-authorization-server` 370 + /// 3. Fall back to `GET {pds_url}/.well-known/oauth-authorization-server` 371 + /// if the protected resource endpoint doesn't exist (self-hosted PDS) 279 372 /// 280 - /// Fetches `/.well-known/oauth-authorization-server` and validates that 281 - /// `response_types_supported` includes "code" and `code_challenge_methods_supported` 282 - /// includes "S256". 373 + /// Validates that the metadata includes "code" in `response_types_supported` 374 + /// and "S256" in `code_challenge_methods_supported`. 283 375 pub async fn discover_auth_server( 284 376 &self, 285 377 pds_url: &str, 286 378 ) -> Result<AuthServerMetadata, PdsClientError> { 287 - let url = format!("{}/.well-known/oauth-authorization-server", pds_url); 379 + // Step 1: Try protected resource metadata to find the auth server 380 + let auth_server_base = self.discover_protected_resource_auth_server(pds_url).await; 381 + 382 + let metadata_base = match &auth_server_base { 383 + Some(server) => { 384 + tracing::debug!(auth_server = %server, "using authorization server from protected resource metadata"); 385 + server.as_str() 386 + } 387 + None => { 388 + tracing::debug!(pds_url = %pds_url, "no protected resource metadata, falling back to PDS directly"); 389 + pds_url 390 + } 391 + }; 392 + 393 + // Step 2: Fetch the OAuth authorization server metadata 394 + let url = format!("{}/.well-known/oauth-authorization-server", metadata_base); 395 + tracing::debug!(url = %url, "fetching OAuth authorization server metadata"); 288 396 289 397 let response = 290 398 self.client 291 399 .get(&url) 292 400 .send() 293 401 .await 294 - .map_err(|e| PdsClientError::NetworkError { 295 - message: format!("failed to fetch OAuth metadata: {}", e), 402 + .map_err(|e| { 403 + tracing::error!(url = %url, error = %e, "OAuth metadata fetch failed"); 404 + PdsClientError::NetworkError { 405 + message: format!("failed to fetch OAuth metadata: {}", e), 406 + } 296 407 })?; 297 408 298 409 if !response.status().is_success() { 410 + tracing::error!(url = %url, status = %response.status(), "OAuth metadata returned non-success"); 299 411 return Err(PdsClientError::InvalidResponse { 300 412 message: format!( 301 413 "OAuth metadata fetch returned {} from {}", 302 414 response.status(), 303 - pds_url 415 + metadata_base 304 416 ), 305 417 }); 306 418 } ··· 309 421 response 310 422 .json() 311 423 .await 312 - .map_err(|e| PdsClientError::InvalidResponse { 313 - message: format!("failed to parse OAuth metadata: {}", e), 424 + .map_err(|e| { 425 + tracing::error!(url = %url, error = %e, "OAuth metadata parsing failed"); 426 + PdsClientError::InvalidResponse { 427 + message: format!("failed to parse OAuth metadata: {}", e), 428 + } 314 429 })?; 430 + tracing::debug!(issuer = %metadata.issuer, "OAuth metadata parsed"); 315 431 316 432 // Validate required capabilities 317 433 if !metadata ··· 336 452 Ok(metadata) 337 453 } 338 454 455 + /// Try to discover the authorization server URL from the PDS's protected 456 + /// resource metadata (RFC 9728). Returns `None` if the endpoint doesn't 457 + /// exist or can't be parsed — the caller should fall back to the PDS URL. 458 + async fn discover_protected_resource_auth_server(&self, pds_url: &str) -> Option<String> { 459 + let url = format!("{}/.well-known/oauth-protected-resource", pds_url); 460 + tracing::debug!(url = %url, "checking protected resource metadata"); 461 + 462 + let response = match self.client.get(&url).send().await { 463 + Ok(r) if r.status().is_success() => r, 464 + Ok(r) => { 465 + tracing::debug!(url = %url, status = %r.status(), "protected resource metadata not available"); 466 + return None; 467 + } 468 + Err(e) => { 469 + tracing::debug!(url = %url, error = %e, "protected resource metadata fetch failed"); 470 + return None; 471 + } 472 + }; 473 + 474 + #[derive(serde::Deserialize)] 475 + struct ProtectedResource { 476 + #[serde(default)] 477 + authorization_servers: Vec<String>, 478 + } 479 + 480 + match response.json::<ProtectedResource>().await { 481 + Ok(pr) => { 482 + let server = pr.authorization_servers.into_iter().next(); 483 + if let Some(ref s) = server { 484 + tracing::debug!(auth_server = %s, "found authorization server in protected resource metadata"); 485 + } 486 + server 487 + } 488 + Err(e) => { 489 + tracing::debug!(url = %url, error = %e, "failed to parse protected resource metadata"); 490 + None 491 + } 492 + } 493 + } 494 + 339 495 /// Perform a Pushed Authorization Request to an arbitrary PDS. 340 496 /// 341 497 /// Sends a PAR request with PKCE challenge, DPoP proof, and optional login_hint. ··· 347 503 dpop_proof: &str, 348 504 dpop_jkt: &str, 349 505 login_hint: Option<&str>, 506 + client_id: &str, 350 507 ) -> Result<PdsParResponse, PdsClientError> { 351 508 let par_url = metadata 352 509 .pushed_authorization_request_endpoint ··· 358 515 ("code_challenge_method", "S256".to_string()), 359 516 ("code_challenge", pkce_challenge.to_string()), 360 517 ("state", state_param.to_string()), 361 - ("client_id", CLIENT_ID.to_string()), 518 + ("client_id", client_id.to_string()), 362 519 ("redirect_uri", REDIRECT_URI.to_string()), 363 520 ("scope", "atproto transition:generic".to_string()), 364 521 ("dpop_jkt", dpop_jkt.to_string()), ··· 412 569 code: &str, 413 570 pkce_verifier: &str, 414 571 dpop_proof: &str, 572 + client_id: &str, 415 573 ) -> Result<reqwest::Response, PdsClientError> { 416 574 let token_url = &metadata.token_endpoint; 417 575 ··· 420 578 ("code", code), 421 579 ("redirect_uri", REDIRECT_URI), 422 580 ("code_verifier", pkce_verifier), 423 - ("client_id", CLIENT_ID), 581 + ("client_id", client_id), 424 582 ]; 425 583 426 584 self.client ··· 441 599 metadata: &AuthServerMetadata, 442 600 request_uri: &str, 443 601 login_hint: Option<&str>, 602 + client_id: &str, 444 603 ) -> String { 445 604 let mut url = format!( 446 605 "{}?client_id={}&request_uri={}", 447 606 metadata.authorization_endpoint, 448 - CLIENT_ID, 607 + urlencoding::encode(client_id), 449 608 urlencoding::encode(request_uri) 450 609 ); 451 610 ··· 522 681 fn default() -> Self { 523 682 Self::new() 524 683 } 684 + } 685 + 686 + // ============================================================================ 687 + // Public helpers 688 + // ============================================================================ 689 + 690 + /// Extract rotation keys from the latest entry in a raw PLC audit log JSON string. 691 + /// Returns an empty Vec if parsing fails or the log has no entries. 692 + pub fn rotation_keys_from_audit_log(raw_json: &str) -> Vec<String> { 693 + let entries: Vec<serde_json::Value> = match serde_json::from_str(raw_json) { 694 + Ok(v) => v, 695 + Err(_) => return Vec::new(), 696 + }; 697 + entries 698 + .last() 699 + .and_then(|entry| entry.get("operation")) 700 + .and_then(|op| op.get("rotationKeys")) 701 + .and_then(|keys| keys.as_array()) 702 + .map(|arr| { 703 + arr.iter() 704 + .filter_map(|v| v.as_str().map(String::from)) 705 + .collect() 706 + }) 707 + .unwrap_or_default() 525 708 } 526 709 527 710 // ============================================================================ ··· 532 715 /// `Ok(None)` if no matching TXT record found, `Err` on transport failure. 533 716 async fn try_resolve_dns(handle: &str) -> Result<Option<String>, PdsClientError> { 534 717 let dns_name = format!("_atproto.{}", handle); 718 + tracing::debug!(dns_name = %dns_name, "attempting DNS TXT lookup"); 535 719 536 720 // Create a resolver using system DNS config (matches relay pattern in dns.rs:49) 537 721 let resolver = hickory_resolver::Resolver::builder_tokio() ··· 549 733 Ok(s) => { 550 734 if let Some(did_value) = s.strip_prefix("did=") { 551 735 let did = did_value.trim().to_string(); 736 + tracing::debug!(did = %did, "DNS TXT resolved"); 552 737 return Ok(Some(did)); 553 738 } 554 739 } ··· 558 743 } 559 744 } 560 745 } 746 + tracing::debug!(dns_name = %dns_name, "no did= TXT record found"); 561 747 Ok(None) 562 748 } 563 749 Err(e) => { 564 750 // Check if it's a "no records found" error (normal for unregistered handles) 565 751 // vs. a transport error (network failure) 566 752 if e.is_no_records_found() { 753 + tracing::debug!(dns_name = %dns_name, "no DNS TXT records found"); 567 754 Ok(None) 568 755 } else { 756 + tracing::warn!(dns_name = %dns_name, error = %e, "DNS TXT lookup failed"); 569 757 Err(PdsClientError::NetworkError { 570 758 message: format!("DNS lookup failed: {}", e), 571 759 }) ··· 581 769 client: &reqwest::Client, 582 770 url: &str, 583 771 ) -> Result<Option<String>, PdsClientError> { 772 + tracing::debug!(url = %url, "attempting HTTP well-known lookup"); 584 773 match client.get(url).send().await { 585 774 Ok(response) => { 586 775 if response.status().is_success() { 587 776 match response.text().await { 588 - Ok(body) => Ok(Some(body.trim().to_string())), 589 - Err(e) => Err(PdsClientError::NetworkError { 590 - message: format!("failed to read response body: {}", e), 591 - }), 777 + Ok(body) => { 778 + tracing::debug!(url = %url, did = %body.trim(), "HTTP well-known resolved"); 779 + Ok(Some(body.trim().to_string())) 780 + } 781 + Err(e) => { 782 + tracing::warn!(url = %url, error = %e, "HTTP well-known body read failed"); 783 + Err(PdsClientError::NetworkError { 784 + message: format!("failed to read response body: {}", e), 785 + }) 786 + } 592 787 } 593 788 } else if response.status().is_client_error() { 594 789 // 4xx = handle not found at this endpoint 790 + tracing::debug!(url = %url, status = %response.status(), "HTTP well-known not found"); 595 791 Ok(None) 596 792 } else { 597 793 // 5xx = temporary server error 794 + tracing::warn!(url = %url, status = %response.status(), "HTTP well-known server error"); 598 795 Err(PdsClientError::NetworkError { 599 796 message: format!("server error from {}: {}", url, response.status()), 600 797 }) 601 798 } 602 799 } 603 800 Err(e) => { 604 - // Transport error 801 + tracing::warn!(url = %url, error = %e, "HTTP well-known request failed"); 605 802 Err(PdsClientError::NetworkError { 606 803 message: format!("HTTP request failed: {}", e), 607 804 }) ··· 749 946 let mock_server = MockServer::start(); 750 947 let pds_endpoint = format!("{}/pds", mock_server.base_url()); 751 948 949 + // W3C DID Document format (what plc.directory actually returns) 752 950 let did_doc_json = serde_json::json!({ 753 - "did": "did:plc:test123", 951 + "@context": [ 952 + "https://www.w3.org/ns/did/v1", 953 + "https://w3id.org/security/multikey/v1" 954 + ], 955 + "id": "did:plc:test123", 754 956 "alsoKnownAs": ["at://alice.example.com"], 755 - "rotationKeys": ["did:key:zQ3test1", "did:key:zQ3test2"], 756 - "verificationMethods": {"atproto": "did:key:zQ3test1"}, 757 - "services": { 758 - "atproto_pds": { 759 - "type": "AtprotoPersonalDataServer", 760 - "endpoint": pds_endpoint 761 - } 762 - } 957 + "verificationMethod": [{ 958 + "id": "did:plc:test123#atproto", 959 + "type": "Multikey", 960 + "controller": "did:plc:test123", 961 + "publicKeyMultibase": "zQ3test1" 962 + }], 963 + "service": [{ 964 + "id": "#atproto_pds", 965 + "type": "AtprotoPersonalDataServer", 966 + "serviceEndpoint": pds_endpoint 967 + }] 763 968 }); 764 969 765 970 // Mock the plc.directory GET request ··· 784 989 assert!(pds_url.contains("/pds")); 785 990 assert_eq!(doc.did, "did:plc:test123"); 786 991 assert_eq!(doc.also_known_as, vec!["at://alice.example.com"]); 787 - assert_eq!(doc.rotation_keys.len(), 2); 992 + // W3C DID Document doesn't include rotation keys — they come from the audit log 993 + assert!(doc.rotation_keys.is_empty()); 994 + // Service array converted to HashMap keyed by id (without '#' prefix) 995 + assert!(doc.services.contains_key("atproto_pds")); 996 + // verificationMethod array converted to { "atproto": "zQ3test1" } 997 + assert_eq!(doc.verification_methods["atproto"], "zQ3test1"); 788 998 } 789 999 790 1000 /// DID_NOT_FOUND error when plc.directory returns 404 ··· 1188 1398 "test_dpop_proof", 1189 1399 "test_dpop_jkt", 1190 1400 Some("user@example.com"), 1401 + "https://test.example.com/oauth/client-metadata.json", 1191 1402 ) 1192 1403 .await; 1193 1404 ··· 1231 1442 1232 1443 let client = PdsClient::new(); 1233 1444 let result = client 1234 - .pds_par(&metadata, "challenge", "state", "proof", "jkt", None) 1445 + .pds_par(&metadata, "challenge", "state", "proof", "jkt", None, "https://test.example.com/oauth/client-metadata.json") 1235 1446 .await; 1236 1447 1237 1448 assert!(result.is_ok()); ··· 1267 1478 1268 1479 let client = PdsClient::new(); 1269 1480 let result = client 1270 - .pds_par(&metadata, "challenge", "state", "proof", "jkt", None) 1481 + .pds_par(&metadata, "challenge", "state", "proof", "jkt", None, "https://test.example.com/oauth/client-metadata.json") 1271 1482 .await; 1272 1483 1273 1484 assert!(result.is_err()); ··· 1311 1522 1312 1523 let client = PdsClient::new(); 1313 1524 let result = client 1314 - .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1525 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof", "https://test.example.com/oauth/client-metadata.json") 1315 1526 .await; 1316 1527 1317 1528 assert!(result.is_ok()); ··· 1346 1557 1347 1558 let client = PdsClient::new(); 1348 1559 let result = client 1349 - .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1560 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof", "https://test.example.com/oauth/client-metadata.json") 1350 1561 .await; 1351 1562 1352 1563 // Should return Ok(Response) with 400 status — caller handles error interpretation. ··· 1372 1583 1373 1584 let client = PdsClient::new(); 1374 1585 let result = client 1375 - .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1586 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof", "https://test.example.com/oauth/client-metadata.json") 1376 1587 .await; 1377 1588 1378 1589 assert!(result.is_err()); ··· 1403 1614 &metadata, 1404 1615 "urn:ietf:params:oauth:request_uri:test", 1405 1616 Some("user@example.com"), 1617 + "https://test.example.com/oauth/client-metadata.json", 1406 1618 ); 1407 1619 1408 - assert!(url.contains("client_id=dev.malpercio.identitywallet")); 1620 + assert!(url.contains("client_id=https%3A%2F%2Ftest.example.com%2Foauth%2Fclient-metadata.json")); 1409 1621 assert!(url.contains("request_uri=")); 1410 1622 assert!(url.contains("login_hint=")); 1411 1623 assert!(url.starts_with("https://pds.example.com/oauth/authorize?")); ··· 1430 1642 &metadata, 1431 1643 "urn:ietf:params:oauth:request_uri:test2", 1432 1644 None, 1645 + "https://test.example.com/oauth/client-metadata.json", 1433 1646 ); 1434 1647 1435 - assert!(url.contains("client_id=dev.malpercio.identitywallet")); 1648 + assert!(url.contains("client_id=https%3A%2F%2Ftest.example.com%2Foauth%2Fclient-metadata.json")); 1436 1649 assert!(url.contains("request_uri=")); 1437 1650 assert!(!url.contains("login_hint=")); 1438 1651 assert!(url.starts_with("https://pds.example.com/oauth/authorize?"));
+3 -5
apps/identity-wallet/src-tauri/src/recovery.rs
··· 299 299 })?; 300 300 301 301 Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> { 302 - use security_framework::item::{ItemClass, ItemSearchOptions, SearchResult}; 302 + use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult}; 303 303 use security_framework::key::Algorithm; 304 304 305 305 let query_results = ItemSearchOptions::new() ··· 309 309 .search() 310 310 .map_err(|e| crypto::CryptoError::PlcOperation(format!("SE key lookup failed: {e}")))?; 311 311 312 - let sec_key = match query_results.first() { 313 - Some(SearchResult::Ref(r)) => r.as_sec_key().ok_or_else(|| { 314 - crypto::CryptoError::PlcOperation("SE result is not a key".into()) 315 - })?, 312 + let sec_key = match query_results.into_iter().next() { 313 + Some(SearchResult::Ref(Reference::Key(key))) => key, 316 314 _ => return Err(crypto::CryptoError::PlcOperation("SE key not found".into())), 317 315 }; 318 316
+2
crates/relay/src/app.rs
··· 33 33 use crate::routes::get_session::get_session; 34 34 use crate::routes::health::health; 35 35 use crate::routes::oauth_authorize::{get_authorization, post_authorization}; 36 + use crate::routes::oauth_client_metadata::oauth_client_metadata; 36 37 use crate::routes::oauth_jwks::oauth_jwks; 37 38 use crate::routes::oauth_par::post_par; 38 39 use crate::routes::oauth_server_metadata::oauth_server_metadata; ··· 154 155 "/oauth/authorize", 155 156 get(get_authorization).post(post_authorization), 156 157 ) 158 + .route("/oauth/client-metadata.json", get(oauth_client_metadata)) 157 159 .route("/oauth/jwks", get(oauth_jwks)) 158 160 .route("/oauth/par", post(post_par)) 159 161 .route("/oauth/token", post(post_token))
+1
crates/relay/src/routes/mod.rs
··· 16 16 pub mod get_session; 17 17 pub mod health; 18 18 pub mod oauth_authorize; 19 + pub mod oauth_client_metadata; 19 20 pub mod oauth_jwks; 20 21 pub mod oauth_par; 21 22 pub mod oauth_server_metadata;
+127
crates/relay/src/routes/oauth_client_metadata.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: public_url from AppState config 4 + // Processes: none (static JSON shape with dynamic client_id from config) 5 + // Returns: OAuth client metadata JSON per AT Protocol spec 6 + 7 + use axum::{extract::State, response::IntoResponse, Json}; 8 + use serde::Serialize; 9 + 10 + use crate::app::AppState; 11 + 12 + #[derive(Serialize)] 13 + struct ClientMetadata { 14 + client_id: String, 15 + client_name: &'static str, 16 + client_uri: String, 17 + application_type: &'static str, 18 + grant_types: Vec<&'static str>, 19 + response_types: Vec<&'static str>, 20 + redirect_uris: Vec<&'static str>, 21 + scope: &'static str, 22 + dpop_bound_access_tokens: bool, 23 + token_endpoint_auth_method: &'static str, 24 + } 25 + 26 + pub async fn oauth_client_metadata(State(state): State<AppState>) -> impl IntoResponse { 27 + let base = state.config.public_url.trim_end_matches('/'); 28 + let client_id = format!("{base}/oauth/client-metadata.json"); 29 + 30 + Json(ClientMetadata { 31 + client_id, 32 + client_name: "Identity Wallet", 33 + client_uri: base.to_string(), 34 + application_type: "native", 35 + grant_types: vec!["authorization_code", "refresh_token"], 36 + response_types: vec!["code"], 37 + redirect_uris: vec!["dev.malpercio.identitywallet:/oauth/callback"], 38 + scope: "atproto transition:generic", 39 + dpop_bound_access_tokens: true, 40 + token_endpoint_auth_method: "none", 41 + }) 42 + } 43 + 44 + #[cfg(test)] 45 + mod tests { 46 + use axum::{ 47 + body::Body, 48 + http::{Request, StatusCode}, 49 + }; 50 + use tower::ServiceExt; 51 + 52 + use crate::app::{app, test_state}; 53 + 54 + #[tokio::test] 55 + async fn client_metadata_returns_200_with_correct_client_id() { 56 + let state = test_state().await; 57 + let response = app(state.clone()) 58 + .oneshot( 59 + Request::builder() 60 + .uri("/oauth/client-metadata.json") 61 + .body(Body::empty()) 62 + .unwrap(), 63 + ) 64 + .await 65 + .unwrap(); 66 + 67 + assert_eq!(response.status(), StatusCode::OK); 68 + 69 + let body = axum::body::to_bytes(response.into_body(), 4096) 70 + .await 71 + .unwrap(); 72 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 73 + 74 + // client_id must be the full URL to this endpoint 75 + assert_eq!( 76 + json["client_id"], 77 + format!( 78 + "{}/oauth/client-metadata.json", 79 + state.config.public_url.trim_end_matches('/') 80 + ) 81 + ); 82 + assert_eq!(json["application_type"], "native"); 83 + assert_eq!(json["dpop_bound_access_tokens"], true); 84 + assert_eq!(json["token_endpoint_auth_method"], "none"); 85 + } 86 + 87 + #[tokio::test] 88 + async fn client_metadata_has_json_content_type() { 89 + let response = app(test_state().await) 90 + .oneshot( 91 + Request::builder() 92 + .uri("/oauth/client-metadata.json") 93 + .body(Body::empty()) 94 + .unwrap(), 95 + ) 96 + .await 97 + .unwrap(); 98 + 99 + assert_eq!( 100 + response.headers().get("content-type").unwrap(), 101 + "application/json" 102 + ); 103 + } 104 + 105 + #[tokio::test] 106 + async fn client_metadata_redirect_uri_matches_wallet() { 107 + let response = app(test_state().await) 108 + .oneshot( 109 + Request::builder() 110 + .uri("/oauth/client-metadata.json") 111 + .body(Body::empty()) 112 + .unwrap(), 113 + ) 114 + .await 115 + .unwrap(); 116 + 117 + let body = axum::body::to_bytes(response.into_body(), 4096) 118 + .await 119 + .unwrap(); 120 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 121 + 122 + let uris = json["redirect_uris"].as_array().unwrap(); 123 + assert!(uris 124 + .iter() 125 + .any(|u| u == "dev.malpercio.identitywallet:/oauth/callback")); 126 + } 127 + }