A better Rust ATProto crate

part-way through oauth stuff :(

Orual a0fe35e3 05de6ab5

+1
.gitignore
··· 10 crates/jacquard-lexicon/target 11 codegen_plan.md 12 /lex_js
··· 10 crates/jacquard-lexicon/target 11 codegen_plan.md 12 /lex_js 13 + /docs
+511 -4
Cargo.lock
··· 37 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 38 39 [[package]] 40 name = "aho-corasick" 41 version = "1.1.3" 42 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 52 checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 53 54 [[package]] 55 name = "android_system_properties" 56 version = "0.1.5" 57 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 111 ] 112 113 [[package]] 114 name = "async-compression" 115 version = "0.4.30" 116 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 181 version = "0.2.0" 182 source = "registry+https://github.com/rust-lang/crates.io-index" 183 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 184 185 [[package]] 186 name = "base64" ··· 189 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 190 191 [[package]] 192 name = "bitflags" 193 version = "2.9.4" 194 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 238 ] 239 240 [[package]] 241 name = "btree-range-map" 242 version = "0.7.2" 243 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 262 ] 263 264 [[package]] 265 name = "bumpalo" 266 version = "3.19.0" 267 source = "registry+https://github.com/rust-lang/crates.io-index" 268 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 269 270 [[package]] 271 name = "bytes" ··· 329 "wasm-bindgen", 330 "windows-link 0.2.0", 331 ] 332 333 [[package]] 334 name = "ciborium" ··· 599 ] 600 601 [[package]] 602 name = "der" 603 version = "0.7.10" 604 source = "registry+https://github.com/rust-lang/crates.io-index" 605 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 606 dependencies = [ 607 "const-oid", 608 "zeroize", 609 ] 610 ··· 625 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 626 dependencies = [ 627 "block-buffer", 628 "crypto-common", 629 ] 630 631 [[package]] ··· 646 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 647 648 [[package]] 649 name = "ed25519" 650 version = "2.2.3" 651 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 681 dependencies = [ 682 "base16ct", 683 "crypto-bigint", 684 "ff", 685 "generic-array", 686 "group", 687 "rand_core 0.6.4", 688 "sec1", 689 "subtle", ··· 762 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 763 764 [[package]] 765 name = "find-msvc-tools" 766 version = "0.1.2" 767 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 894 ] 895 896 [[package]] 897 name = "h2" 898 version = "0.4.12" 899 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 947 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 948 949 [[package]] 950 name = "hex" 951 version = "0.4.3" 952 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1004 ] 1005 1006 [[package]] 1007 name = "http" 1008 version = "1.3.1" 1009 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1044 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1045 1046 [[package]] 1047 name = "hyper" 1048 version = "1.7.0" 1049 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1088 source = "registry+https://github.com/rust-lang/crates.io-index" 1089 checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1090 dependencies = [ 1091 - "base64", 1092 "bytes", 1093 "futures-channel", 1094 "futures-core", ··· 1364 "jacquard-api", 1365 "jacquard-common", 1366 "jacquard-derive", 1367 "miette", 1368 "percent-encoding", 1369 "reqwest", 1370 "serde", 1371 "serde_html_form", 1372 "serde_ipld_dagcbor", ··· 1395 name = "jacquard-common" 1396 version = "0.2.0" 1397 dependencies = [ 1398 - "base64", 1399 "bon", 1400 "bytes", 1401 "chrono", 1402 "cid", 1403 "ed25519-dalek", 1404 "enum_dispatch", 1405 "ipld-core", 1406 "k256", 1407 "langtag", ··· 1413 "p256", 1414 "rand 0.9.2", 1415 "regex", 1416 "serde", 1417 "serde_html_form", 1418 "serde_json", 1419 "serde_with", 1420 "smol_str", 1421 "thiserror 2.0.17", 1422 "url", 1423 ] 1424 ··· 1461 ] 1462 1463 [[package]] 1464 name = "js-sys" 1465 version = "0.3.81" 1466 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1492 ] 1493 1494 [[package]] 1495 name = "libc" 1496 version = "0.2.176" 1497 source = "registry+https://github.com/rust-lang/crates.io-index" 1498 checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 1499 1500 [[package]] 1501 name = "linked-hash-map" ··· 1588 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1589 1590 [[package]] 1591 name = "minimal-lexical" 1592 version = "0.2.1" 1593 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1636 ] 1637 1638 [[package]] 1639 name = "nom" 1640 version = "7.1.3" 1641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1646 ] 1647 1648 [[package]] 1649 name = "num-conv" 1650 version = "0.1.0" 1651 source = "registry+https://github.com/rust-lang/crates.io-index" 1652 checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1653 1654 [[package]] 1655 name = "num-traits" ··· 1658 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1659 dependencies = [ 1660 "autocfg", 1661 ] 1662 1663 [[package]] ··· 1716 version = "0.13.2" 1717 source = "registry+https://github.com/rust-lang/crates.io-index" 1718 checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 1719 dependencies = [ 1720 "elliptic-curve", 1721 "primeorder", ··· 1745 ] 1746 1747 [[package]] 1748 name = "percent-encoding" 1749 version = "2.3.2" 1750 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1761 version = "0.1.0" 1762 source = "registry+https://github.com/rust-lang/crates.io-index" 1763 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1764 1765 [[package]] 1766 name = "pkcs8" ··· 1860 "version_check", 1861 "yansi", 1862 ] 1863 1864 [[package]] 1865 name = "quinn" ··· 2061 checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" 2062 dependencies = [ 2063 "async-compression", 2064 - "base64", 2065 "bytes", 2066 "encoding_rs", 2067 "futures-core", ··· 2105 checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2106 2107 [[package]] 2108 name = "ring" 2109 version = "0.17.14" 2110 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2119 ] 2120 2121 [[package]] 2122 name = "rustc-demangle" 2123 version = "0.1.26" 2124 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2198 version = "1.0.20" 2199 source = "registry+https://github.com/rust-lang/crates.io-index" 2200 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2201 2202 [[package]] 2203 name = "schemars" ··· 2238 "base16ct", 2239 "der", 2240 "generic-array", 2241 "subtle", 2242 "zeroize", 2243 ] ··· 2354 source = "registry+https://github.com/rust-lang/crates.io-index" 2355 checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" 2356 dependencies = [ 2357 - "base64", 2358 "chrono", 2359 "hex", 2360 "indexmap 1.9.3", ··· 2381 ] 2382 2383 [[package]] 2384 name = "sha2" 2385 version = "0.10.9" 2386 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2402 version = "2.2.0" 2403 source = "registry+https://github.com/rust-lang/crates.io-index" 2404 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2405 2406 [[package]] 2407 name = "slab" ··· 2446 ] 2447 2448 [[package]] 2449 name = "spki" 2450 version = "0.7.3" 2451 source = "registry+https://github.com/rust-lang/crates.io-index" 2452 checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2453 dependencies = [ 2454 "der", 2455 ] 2456 ··· 2655 ] 2656 2657 [[package]] 2658 name = "time" 2659 version = "0.3.44" 2660 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2662 dependencies = [ 2663 "deranged", 2664 "itoa", 2665 "num-conv", 2666 "powerfmt", 2667 "serde", 2668 "time-core", ··· 2686 ] 2687 2688 [[package]] 2689 name = "tinystr" 2690 version = "0.8.1" 2691 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2845 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2846 2847 [[package]] 2848 name = "typenum" 2849 version = "1.18.0" 2850 source = "registry+https://github.com/rust-lang/crates.io-index" 2851 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 2852 2853 [[package]] 2854 name = "unicode-ident" ··· 2915 version = "0.2.2" 2916 source = "registry+https://github.com/rust-lang/crates.io-index" 2917 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2918 2919 [[package]] 2920 name = "version_check" ··· 3476 version = "1.8.2" 3477 source = "registry+https://github.com/rust-lang/crates.io-index" 3478 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 3479 3480 [[package]] 3481 name = "zerotrie"
··· 37 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 38 39 [[package]] 40 + name = "adler32" 41 + version = "1.2.0" 42 + source = "registry+https://github.com/rust-lang/crates.io-index" 43 + checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 44 + 45 + [[package]] 46 name = "aho-corasick" 47 version = "1.1.3" 48 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 58 checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 59 60 [[package]] 61 + name = "alloc-no-stdlib" 62 + version = "2.0.4" 63 + source = "registry+https://github.com/rust-lang/crates.io-index" 64 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 65 + 66 + [[package]] 67 + name = "alloc-stdlib" 68 + version = "0.2.2" 69 + source = "registry+https://github.com/rust-lang/crates.io-index" 70 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 71 + dependencies = [ 72 + "alloc-no-stdlib", 73 + ] 74 + 75 + [[package]] 76 name = "android_system_properties" 77 version = "0.1.5" 78 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 132 ] 133 134 [[package]] 135 + name = "ascii" 136 + version = "1.1.0" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 139 + 140 + [[package]] 141 name = "async-compression" 142 version = "0.4.30" 143 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 208 version = "0.2.0" 209 source = "registry+https://github.com/rust-lang/crates.io-index" 210 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 211 + 212 + [[package]] 213 + name = "base64" 214 + version = "0.13.1" 215 + source = "registry+https://github.com/rust-lang/crates.io-index" 216 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 217 218 [[package]] 219 name = "base64" ··· 222 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 223 224 [[package]] 225 + name = "base64ct" 226 + version = "1.8.0" 227 + source = "registry+https://github.com/rust-lang/crates.io-index" 228 + checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 229 + 230 + [[package]] 231 name = "bitflags" 232 version = "2.9.4" 233 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 277 ] 278 279 [[package]] 280 + name = "brotli" 281 + version = "3.5.0" 282 + source = "registry+https://github.com/rust-lang/crates.io-index" 283 + checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" 284 + dependencies = [ 285 + "alloc-no-stdlib", 286 + "alloc-stdlib", 287 + "brotli-decompressor", 288 + ] 289 + 290 + [[package]] 291 + name = "brotli-decompressor" 292 + version = "2.5.1" 293 + source = "registry+https://github.com/rust-lang/crates.io-index" 294 + checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" 295 + dependencies = [ 296 + "alloc-no-stdlib", 297 + "alloc-stdlib", 298 + ] 299 + 300 + [[package]] 301 name = "btree-range-map" 302 version = "0.7.2" 303 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 322 ] 323 324 [[package]] 325 + name = "buf_redux" 326 + version = "0.8.4" 327 + source = "registry+https://github.com/rust-lang/crates.io-index" 328 + checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" 329 + dependencies = [ 330 + "memchr", 331 + "safemem", 332 + ] 333 + 334 + [[package]] 335 name = "bumpalo" 336 version = "3.19.0" 337 source = "registry+https://github.com/rust-lang/crates.io-index" 338 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 339 + 340 + [[package]] 341 + name = "byteorder" 342 + version = "1.5.0" 343 + source = "registry+https://github.com/rust-lang/crates.io-index" 344 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 345 346 [[package]] 347 name = "bytes" ··· 405 "wasm-bindgen", 406 "windows-link 0.2.0", 407 ] 408 + 409 + [[package]] 410 + name = "chunked_transfer" 411 + version = "1.5.0" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" 414 415 [[package]] 416 name = "ciborium" ··· 681 ] 682 683 [[package]] 684 + name = "deflate" 685 + version = "1.0.0" 686 + source = "registry+https://github.com/rust-lang/crates.io-index" 687 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 688 + dependencies = [ 689 + "adler32", 690 + "gzip-header", 691 + ] 692 + 693 + [[package]] 694 name = "der" 695 version = "0.7.10" 696 source = "registry+https://github.com/rust-lang/crates.io-index" 697 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 698 dependencies = [ 699 "const-oid", 700 + "pem-rfc7468", 701 "zeroize", 702 ] 703 ··· 718 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 719 dependencies = [ 720 "block-buffer", 721 + "const-oid", 722 "crypto-common", 723 + "subtle", 724 ] 725 726 [[package]] ··· 741 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 742 743 [[package]] 744 + name = "ecdsa" 745 + version = "0.16.9" 746 + source = "registry+https://github.com/rust-lang/crates.io-index" 747 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 748 + dependencies = [ 749 + "der", 750 + "digest", 751 + "elliptic-curve", 752 + "rfc6979", 753 + "signature", 754 + "spki", 755 + ] 756 + 757 + [[package]] 758 name = "ed25519" 759 version = "2.2.3" 760 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 790 dependencies = [ 791 "base16ct", 792 "crypto-bigint", 793 + "digest", 794 "ff", 795 "generic-array", 796 "group", 797 + "pem-rfc7468", 798 + "pkcs8", 799 "rand_core 0.6.4", 800 "sec1", 801 "subtle", ··· 874 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 875 876 [[package]] 877 + name = "filetime" 878 + version = "0.2.26" 879 + source = "registry+https://github.com/rust-lang/crates.io-index" 880 + checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" 881 + dependencies = [ 882 + "cfg-if", 883 + "libc", 884 + "libredox", 885 + "windows-sys 0.60.2", 886 + ] 887 + 888 + [[package]] 889 name = "find-msvc-tools" 890 version = "0.1.2" 891 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1018 ] 1019 1020 [[package]] 1021 + name = "gzip-header" 1022 + version = "1.0.0" 1023 + source = "registry+https://github.com/rust-lang/crates.io-index" 1024 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1025 + dependencies = [ 1026 + "crc32fast", 1027 + ] 1028 + 1029 + [[package]] 1030 name = "h2" 1031 version = "0.4.12" 1032 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1080 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1081 1082 [[package]] 1083 + name = "hermit-abi" 1084 + version = "0.5.2" 1085 + source = "registry+https://github.com/rust-lang/crates.io-index" 1086 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1087 + 1088 + [[package]] 1089 name = "hex" 1090 version = "0.4.3" 1091 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1143 ] 1144 1145 [[package]] 1146 + name = "hmac" 1147 + version = "0.12.1" 1148 + source = "registry+https://github.com/rust-lang/crates.io-index" 1149 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1150 + dependencies = [ 1151 + "digest", 1152 + ] 1153 + 1154 + [[package]] 1155 name = "http" 1156 version = "1.3.1" 1157 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1192 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1193 1194 [[package]] 1195 + name = "httpdate" 1196 + version = "1.0.3" 1197 + source = "registry+https://github.com/rust-lang/crates.io-index" 1198 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1199 + 1200 + [[package]] 1201 name = "hyper" 1202 version = "1.7.0" 1203 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1242 source = "registry+https://github.com/rust-lang/crates.io-index" 1243 checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1244 dependencies = [ 1245 + "base64 0.22.1", 1246 "bytes", 1247 "futures-channel", 1248 "futures-core", ··· 1518 "jacquard-api", 1519 "jacquard-common", 1520 "jacquard-derive", 1521 + "jacquard-oauth", 1522 + "jose-jwk", 1523 "miette", 1524 + "p256", 1525 "percent-encoding", 1526 + "rand_core 0.6.4", 1527 "reqwest", 1528 + "rouille", 1529 "serde", 1530 "serde_html_form", 1531 "serde_ipld_dagcbor", ··· 1554 name = "jacquard-common" 1555 version = "0.2.0" 1556 dependencies = [ 1557 + "async-trait", 1558 + "base64 0.22.1", 1559 "bon", 1560 "bytes", 1561 "chrono", 1562 "cid", 1563 "ed25519-dalek", 1564 "enum_dispatch", 1565 + "hickory-resolver", 1566 + "http", 1567 "ipld-core", 1568 "k256", 1569 "langtag", ··· 1575 "p256", 1576 "rand 0.9.2", 1577 "regex", 1578 + "reqwest", 1579 "serde", 1580 "serde_html_form", 1581 + "serde_ipld_dagcbor", 1582 "serde_json", 1583 "serde_with", 1584 "smol_str", 1585 "thiserror 2.0.17", 1586 + "tokio", 1587 "url", 1588 ] 1589 ··· 1626 ] 1627 1628 [[package]] 1629 + name = "jacquard-oauth" 1630 + version = "0.1.0" 1631 + dependencies = [ 1632 + "async-trait", 1633 + "base64 0.22.1", 1634 + "chrono", 1635 + "elliptic-curve", 1636 + "http", 1637 + "jacquard-common", 1638 + "jose-jwa", 1639 + "jose-jwk", 1640 + "miette", 1641 + "p256", 1642 + "rand 0.8.5", 1643 + "rand_core 0.6.4", 1644 + "serde", 1645 + "serde_html_form", 1646 + "serde_json", 1647 + "sha2", 1648 + "signature", 1649 + "smol_str", 1650 + "thiserror 2.0.17", 1651 + "url", 1652 + "uuid", 1653 + ] 1654 + 1655 + [[package]] 1656 + name = "jose-b64" 1657 + version = "0.1.2" 1658 + source = "registry+https://github.com/rust-lang/crates.io-index" 1659 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 1660 + dependencies = [ 1661 + "base64ct", 1662 + "serde", 1663 + "subtle", 1664 + "zeroize", 1665 + ] 1666 + 1667 + [[package]] 1668 + name = "jose-jwa" 1669 + version = "0.1.2" 1670 + source = "registry+https://github.com/rust-lang/crates.io-index" 1671 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 1672 + dependencies = [ 1673 + "serde", 1674 + ] 1675 + 1676 + [[package]] 1677 + name = "jose-jwk" 1678 + version = "0.1.2" 1679 + source = "registry+https://github.com/rust-lang/crates.io-index" 1680 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 1681 + dependencies = [ 1682 + "jose-b64", 1683 + "jose-jwa", 1684 + "p256", 1685 + "p384", 1686 + "rsa", 1687 + "serde", 1688 + "zeroize", 1689 + ] 1690 + 1691 + [[package]] 1692 name = "js-sys" 1693 version = "0.3.81" 1694 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1720 ] 1721 1722 [[package]] 1723 + name = "lazy_static" 1724 + version = "1.5.0" 1725 + source = "registry+https://github.com/rust-lang/crates.io-index" 1726 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1727 + dependencies = [ 1728 + "spin", 1729 + ] 1730 + 1731 + [[package]] 1732 name = "libc" 1733 version = "0.2.176" 1734 source = "registry+https://github.com/rust-lang/crates.io-index" 1735 checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 1736 + 1737 + [[package]] 1738 + name = "libm" 1739 + version = "0.2.15" 1740 + source = "registry+https://github.com/rust-lang/crates.io-index" 1741 + checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" 1742 + 1743 + [[package]] 1744 + name = "libredox" 1745 + version = "0.1.10" 1746 + source = "registry+https://github.com/rust-lang/crates.io-index" 1747 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 1748 + dependencies = [ 1749 + "bitflags", 1750 + "libc", 1751 + "redox_syscall", 1752 + ] 1753 1754 [[package]] 1755 name = "linked-hash-map" ··· 1842 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1843 1844 [[package]] 1845 + name = "mime_guess" 1846 + version = "2.0.5" 1847 + source = "registry+https://github.com/rust-lang/crates.io-index" 1848 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1849 + dependencies = [ 1850 + "mime", 1851 + "unicase", 1852 + ] 1853 + 1854 + [[package]] 1855 name = "minimal-lexical" 1856 version = "0.2.1" 1857 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1900 ] 1901 1902 [[package]] 1903 + name = "multipart" 1904 + version = "0.18.0" 1905 + source = "registry+https://github.com/rust-lang/crates.io-index" 1906 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 1907 + dependencies = [ 1908 + "buf_redux", 1909 + "httparse", 1910 + "log", 1911 + "mime", 1912 + "mime_guess", 1913 + "quick-error", 1914 + "rand 0.8.5", 1915 + "safemem", 1916 + "tempfile", 1917 + "twoway", 1918 + ] 1919 + 1920 + [[package]] 1921 name = "nom" 1922 version = "7.1.3" 1923 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1928 ] 1929 1930 [[package]] 1931 + name = "num-bigint-dig" 1932 + version = "0.8.4" 1933 + source = "registry+https://github.com/rust-lang/crates.io-index" 1934 + checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" 1935 + dependencies = [ 1936 + "byteorder", 1937 + "lazy_static", 1938 + "libm", 1939 + "num-integer", 1940 + "num-iter", 1941 + "num-traits", 1942 + "rand 0.8.5", 1943 + "smallvec", 1944 + "zeroize", 1945 + ] 1946 + 1947 + [[package]] 1948 name = "num-conv" 1949 version = "0.1.0" 1950 source = "registry+https://github.com/rust-lang/crates.io-index" 1951 checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1952 + 1953 + [[package]] 1954 + name = "num-integer" 1955 + version = "0.1.46" 1956 + source = "registry+https://github.com/rust-lang/crates.io-index" 1957 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1958 + dependencies = [ 1959 + "num-traits", 1960 + ] 1961 + 1962 + [[package]] 1963 + name = "num-iter" 1964 + version = "0.1.45" 1965 + source = "registry+https://github.com/rust-lang/crates.io-index" 1966 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 1967 + dependencies = [ 1968 + "autocfg", 1969 + "num-integer", 1970 + "num-traits", 1971 + ] 1972 1973 [[package]] 1974 name = "num-traits" ··· 1977 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1978 dependencies = [ 1979 "autocfg", 1980 + "libm", 1981 + ] 1982 + 1983 + [[package]] 1984 + name = "num_cpus" 1985 + version = "1.17.0" 1986 + source = "registry+https://github.com/rust-lang/crates.io-index" 1987 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 1988 + dependencies = [ 1989 + "hermit-abi", 1990 + "libc", 1991 + ] 1992 + 1993 + [[package]] 1994 + name = "num_threads" 1995 + version = "0.1.7" 1996 + source = "registry+https://github.com/rust-lang/crates.io-index" 1997 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 1998 + dependencies = [ 1999 + "libc", 2000 ] 2001 2002 [[package]] ··· 2055 version = "0.13.2" 2056 source = "registry+https://github.com/rust-lang/crates.io-index" 2057 checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2058 + dependencies = [ 2059 + "ecdsa", 2060 + "elliptic-curve", 2061 + "primeorder", 2062 + "sha2", 2063 + ] 2064 + 2065 + [[package]] 2066 + name = "p384" 2067 + version = "0.13.1" 2068 + source = "registry+https://github.com/rust-lang/crates.io-index" 2069 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2070 dependencies = [ 2071 "elliptic-curve", 2072 "primeorder", ··· 2096 ] 2097 2098 [[package]] 2099 + name = "pem-rfc7468" 2100 + version = "0.7.0" 2101 + source = "registry+https://github.com/rust-lang/crates.io-index" 2102 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 2103 + dependencies = [ 2104 + "base64ct", 2105 + ] 2106 + 2107 + [[package]] 2108 name = "percent-encoding" 2109 version = "2.3.2" 2110 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2121 version = "0.1.0" 2122 source = "registry+https://github.com/rust-lang/crates.io-index" 2123 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2124 + 2125 + [[package]] 2126 + name = "pkcs1" 2127 + version = "0.7.5" 2128 + source = "registry+https://github.com/rust-lang/crates.io-index" 2129 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 2130 + dependencies = [ 2131 + "der", 2132 + "pkcs8", 2133 + "spki", 2134 + ] 2135 2136 [[package]] 2137 name = "pkcs8" ··· 2231 "version_check", 2232 "yansi", 2233 ] 2234 + 2235 + [[package]] 2236 + name = "quick-error" 2237 + version = "1.2.3" 2238 + source = "registry+https://github.com/rust-lang/crates.io-index" 2239 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2240 2241 [[package]] 2242 name = "quinn" ··· 2438 checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" 2439 dependencies = [ 2440 "async-compression", 2441 + "base64 0.22.1", 2442 "bytes", 2443 "encoding_rs", 2444 "futures-core", ··· 2482 checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2483 2484 [[package]] 2485 + name = "rfc6979" 2486 + version = "0.4.0" 2487 + source = "registry+https://github.com/rust-lang/crates.io-index" 2488 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2489 + dependencies = [ 2490 + "hmac", 2491 + "subtle", 2492 + ] 2493 + 2494 + [[package]] 2495 name = "ring" 2496 version = "0.17.14" 2497 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2506 ] 2507 2508 [[package]] 2509 + name = "rouille" 2510 + version = "3.6.2" 2511 + source = "registry+https://github.com/rust-lang/crates.io-index" 2512 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 2513 + dependencies = [ 2514 + "base64 0.13.1", 2515 + "brotli", 2516 + "chrono", 2517 + "deflate", 2518 + "filetime", 2519 + "multipart", 2520 + "percent-encoding", 2521 + "rand 0.8.5", 2522 + "serde", 2523 + "serde_derive", 2524 + "serde_json", 2525 + "sha1_smol", 2526 + "threadpool", 2527 + "time", 2528 + "tiny_http", 2529 + "url", 2530 + ] 2531 + 2532 + [[package]] 2533 + name = "rsa" 2534 + version = "0.9.8" 2535 + source = "registry+https://github.com/rust-lang/crates.io-index" 2536 + checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" 2537 + dependencies = [ 2538 + "const-oid", 2539 + "digest", 2540 + "num-bigint-dig", 2541 + "num-integer", 2542 + "num-traits", 2543 + "pkcs1", 2544 + "pkcs8", 2545 + "rand_core 0.6.4", 2546 + "signature", 2547 + "spki", 2548 + "subtle", 2549 + "zeroize", 2550 + ] 2551 + 2552 + [[package]] 2553 name = "rustc-demangle" 2554 version = "0.1.26" 2555 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2629 version = "1.0.20" 2630 source = "registry+https://github.com/rust-lang/crates.io-index" 2631 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2632 + 2633 + [[package]] 2634 + name = "safemem" 2635 + version = "0.3.3" 2636 + source = "registry+https://github.com/rust-lang/crates.io-index" 2637 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2638 2639 [[package]] 2640 name = "schemars" ··· 2675 "base16ct", 2676 "der", 2677 "generic-array", 2678 + "pkcs8", 2679 "subtle", 2680 "zeroize", 2681 ] ··· 2792 source = "registry+https://github.com/rust-lang/crates.io-index" 2793 checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" 2794 dependencies = [ 2795 + "base64 0.22.1", 2796 "chrono", 2797 "hex", 2798 "indexmap 1.9.3", ··· 2819 ] 2820 2821 [[package]] 2822 + name = "sha1_smol" 2823 + version = "1.0.1" 2824 + source = "registry+https://github.com/rust-lang/crates.io-index" 2825 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 2826 + 2827 + [[package]] 2828 name = "sha2" 2829 version = "0.10.9" 2830 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2846 version = "2.2.0" 2847 source = "registry+https://github.com/rust-lang/crates.io-index" 2848 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2849 + dependencies = [ 2850 + "digest", 2851 + "rand_core 0.6.4", 2852 + ] 2853 2854 [[package]] 2855 name = "slab" ··· 2894 ] 2895 2896 [[package]] 2897 + name = "spin" 2898 + version = "0.9.8" 2899 + source = "registry+https://github.com/rust-lang/crates.io-index" 2900 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 2901 + 2902 + [[package]] 2903 name = "spki" 2904 version = "0.7.3" 2905 source = "registry+https://github.com/rust-lang/crates.io-index" 2906 checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2907 dependencies = [ 2908 + "base64ct", 2909 "der", 2910 ] 2911 ··· 3110 ] 3111 3112 [[package]] 3113 + name = "threadpool" 3114 + version = "1.8.1" 3115 + source = "registry+https://github.com/rust-lang/crates.io-index" 3116 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 3117 + dependencies = [ 3118 + "num_cpus", 3119 + ] 3120 + 3121 + [[package]] 3122 name = "time" 3123 version = "0.3.44" 3124 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3126 dependencies = [ 3127 "deranged", 3128 "itoa", 3129 + "libc", 3130 "num-conv", 3131 + "num_threads", 3132 "powerfmt", 3133 "serde", 3134 "time-core", ··· 3152 ] 3153 3154 [[package]] 3155 + name = "tiny_http" 3156 + version = "0.12.0" 3157 + source = "registry+https://github.com/rust-lang/crates.io-index" 3158 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 3159 + dependencies = [ 3160 + "ascii", 3161 + "chunked_transfer", 3162 + "httpdate", 3163 + "log", 3164 + ] 3165 + 3166 + [[package]] 3167 name = "tinystr" 3168 version = "0.8.1" 3169 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3323 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3324 3325 [[package]] 3326 + name = "twoway" 3327 + version = "0.1.8" 3328 + source = "registry+https://github.com/rust-lang/crates.io-index" 3329 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 3330 + dependencies = [ 3331 + "memchr", 3332 + ] 3333 + 3334 + [[package]] 3335 name = "typenum" 3336 version = "1.18.0" 3337 source = "registry+https://github.com/rust-lang/crates.io-index" 3338 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 3339 + 3340 + [[package]] 3341 + name = "unicase" 3342 + version = "2.8.1" 3343 + source = "registry+https://github.com/rust-lang/crates.io-index" 3344 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 3345 3346 [[package]] 3347 name = "unicode-ident" ··· 3408 version = "0.2.2" 3409 source = "registry+https://github.com/rust-lang/crates.io-index" 3410 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3411 + 3412 + [[package]] 3413 + name = "uuid" 3414 + version = "1.18.1" 3415 + source = "registry+https://github.com/rust-lang/crates.io-index" 3416 + checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 3417 + dependencies = [ 3418 + "getrandom 0.3.3", 3419 + "js-sys", 3420 + "wasm-bindgen", 3421 + ] 3422 3423 [[package]] 3424 name = "version_check" ··· 3980 version = "1.8.2" 3981 source = "registry+https://github.com/rust-lang/crates.io-index" 3982 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 3983 + dependencies = [ 3984 + "serde", 3985 + ] 3986 3987 [[package]] 3988 name = "zerotrie"
+8
crates/jacquard-common/Cargo.toml
··· 35 smol_str.workspace = true 36 thiserror.workspace = true 37 url.workspace = true 38 39 [features] 40 default = [] 41 crypto = [] 42 crypto-ed25519 = ["crypto", "dep:ed25519-dalek"] 43 crypto-k256 = ["crypto", "dep:k256"] 44 crypto-p256 = ["crypto", "dep:p256"] 45 46 [dependencies.ed25519-dalek] 47 version = "2"
··· 35 smol_str.workspace = true 36 thiserror.workspace = true 37 url.workspace = true 38 + http.workspace = true 39 + async-trait = "0.1" 40 + tokio = { version = "1", features = ["sync"] } 41 + reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 42 + hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true } 43 + serde_ipld_dagcbor.workspace = true 44 45 [features] 46 default = [] 47 + dns = ["dep:hickory-resolver"] 48 crypto = [] 49 crypto-ed25519 = ["crypto", "dep:ed25519-dalek"] 50 crypto-k256 = ["crypto", "dep:k256"] 51 crypto-p256 = ["crypto", "dep:p256"] 52 + reqwest-client = ["dep:reqwest"] 53 54 [dependencies.ed25519-dalek] 55 version = "2"
+7
crates/jacquard-common/src/cowstr.rs
··· 105 } 106 } 107 108 impl From<String> for CowStr<'_> { 109 #[inline] 110 fn from(s: String) -> Self {
··· 105 } 106 } 107 108 + impl Default for CowStr<'_> { 109 + #[inline] 110 + fn default() -> Self { 111 + CowStr::new_static("") 112 + } 113 + } 114 + 115 impl From<String> for CowStr<'_> { 116 #[inline] 117 fn from(s: String) -> Self {
+64
crates/jacquard-common/src/http_client.rs
···
··· 1 + //! Minimal HTTP client abstraction shared across crates. 2 + 3 + use std::fmt::Display; 4 + use std::future::Future; 5 + use std::sync::Arc; 6 + 7 + /// HTTP client trait for sending raw HTTP requests. 8 + pub trait HttpClient { 9 + /// Error type returned by the HTTP client 10 + type Error: std::error::Error + Display + Send + Sync + 'static; 11 + /// Send an HTTP request and return the response. 12 + fn send_http( 13 + &self, 14 + request: http::Request<Vec<u8>>, 15 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send; 16 + } 17 + 18 + #[cfg(feature = "reqwest-client")] 19 + impl HttpClient for reqwest::Client { 20 + type Error = reqwest::Error; 21 + 22 + async fn send_http( 23 + &self, 24 + request: http::Request<Vec<u8>>, 25 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 26 + // Convert http::Request to reqwest::Request 27 + let (parts, body) = request.into_parts(); 28 + 29 + let mut req = self.request(parts.method, parts.uri.to_string()).body(body); 30 + 31 + // Copy headers 32 + for (name, value) in parts.headers.iter() { 33 + req = req.header(name.as_str(), value.as_bytes()); 34 + } 35 + 36 + // Send request 37 + let resp = req.send().await?; 38 + 39 + // Convert reqwest::Response to http::Response 40 + let mut builder = http::Response::builder().status(resp.status()); 41 + 42 + // Copy headers 43 + for (name, value) in resp.headers().iter() { 44 + builder = builder.header(name.as_str(), value.as_bytes()); 45 + } 46 + 47 + // Read body 48 + let body = resp.bytes().await?.to_vec(); 49 + 50 + Ok(builder.body(body).expect("Failed to build response")) 51 + } 52 + } 53 + 54 + impl<T: HttpClient> HttpClient for Arc<T> { 55 + type Error = T::Error; 56 + 57 + fn send_http( 58 + &self, 59 + request: http::Request<Vec<u8>>, 60 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 61 + { 62 + self.as_ref().send_http(request) 63 + } 64 + }
+399
crates/jacquard-common/src/ident_resolver.rs
···
··· 1 + //! Identity resolution: handle → DID and DID → document, with smart fallbacks. 2 + //! 3 + //! Fallback order (default): 4 + //! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC 5 + //! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured). 6 + //! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured), 7 + //! then Slingshot mini‑doc (partial) if configured. 8 + //! 9 + //! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 10 + //! and optionally validate the document `id` against the requested DID. 11 + 12 + use std::collections::BTreeMap; 13 + use std::str::FromStr; 14 + 15 + use crate::error::TransportError; 16 + use crate::types::did_doc::Service; 17 + use crate::types::ident::AtIdentifier; 18 + use crate::types::string::AtprotoStr; 19 + use crate::types::uri::Uri; 20 + use crate::types::value::Data; 21 + use crate::{CowStr, IntoStatic}; 22 + use bon::Builder; 23 + use bytes::Bytes; 24 + use http::StatusCode; 25 + use miette::Diagnostic; 26 + use thiserror::Error; 27 + use url::Url; 28 + 29 + use crate::types::did_doc::DidDocument; 30 + use crate::types::string::{Did, Handle}; 31 + use crate::types::value::AtDataError; 32 + /// Errors that can occur during identity resolution. 33 + /// 34 + /// Note: when validating a fetched DID document against a requested DID, a 35 + /// `DocIdMismatch` error is returned that includes the owned document so callers 36 + /// can inspect it and decide how to proceed. 37 + #[derive(Debug, Error, Diagnostic)] 38 + #[allow(missing_docs)] 39 + pub enum IdentityError { 40 + #[error("unsupported DID method: {0}")] 41 + UnsupportedDidMethod(String), 42 + #[error("invalid well-known atproto-did content")] 43 + InvalidWellKnown, 44 + #[error("missing PDS endpoint in DID document")] 45 + MissingPdsEndpoint, 46 + #[error("HTTP error: {0}")] 47 + Http(#[from] TransportError), 48 + #[error("HTTP status {0}")] 49 + HttpStatus(StatusCode), 50 + #[error("XRPC error: {0}")] 51 + Xrpc(String), 52 + #[error("URL parse error: {0}")] 53 + Url(#[from] url::ParseError), 54 + #[error("DNS error: {0}")] 55 + #[cfg(feature = "dns")] 56 + Dns(#[from] hickory_resolver::error::ResolveError), 57 + #[error("serialize/deserialize error: {0}")] 58 + Serde(#[from] serde_json::Error), 59 + #[error("invalid DID document: {0}")] 60 + InvalidDoc(String), 61 + #[error(transparent)] 62 + Data(#[from] AtDataError), 63 + /// DID document id did not match requested DID; includes the fetched document 64 + #[error("DID doc id mismatch")] 65 + DocIdMismatch { 66 + expected: Did<'static>, 67 + doc: DidDocument<'static>, 68 + }, 69 + } 70 + 71 + /// Source to fetch PLC (did:plc) documents from. 72 + /// 73 + /// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). 74 + /// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as 75 + /// `com.atproto.identity.resolveHandle` and a "mini-doc" 76 + /// endpoint (`com.bad-example.identity.resolveMiniDoc`). 77 + #[derive(Debug, Clone, PartialEq, Eq)] 78 + pub enum PlcSource { 79 + /// Use the public PLC directory 80 + PlcDirectory { 81 + /// Base URL for the PLC directory 82 + base: Url, 83 + }, 84 + /// Use the slingshot mini-docs service 85 + Slingshot { 86 + /// Base URL for the Slingshot service 87 + base: Url, 88 + }, 89 + } 90 + 91 + impl Default for PlcSource { 92 + fn default() -> Self { 93 + Self::PlcDirectory { 94 + base: Url::parse("https://plc.directory/").expect("valid url"), 95 + } 96 + } 97 + } 98 + 99 + impl PlcSource { 100 + /// Default Slingshot source (`https://slingshot.microcosm.blue`) 101 + pub fn slingshot_default() -> Self { 102 + PlcSource::Slingshot { 103 + base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), 104 + } 105 + } 106 + } 107 + 108 + /// DID Document fetch response for borrowed/owned parsing. 109 + /// 110 + /// Carries the raw response bytes and the HTTP status, plus the requested DID 111 + /// (if supplied) to enable validation. Use `parse()` to borrow from the buffer 112 + /// or `parse_validated()` to also enforce that the doc `id` matches the 113 + /// requested DID (returns a `DocIdMismatch` containing the fetched doc on 114 + /// mismatch). Use `into_owned()` to parse into an owned document. 115 + #[derive(Clone)] 116 + pub struct DidDocResponse { 117 + pub buffer: Bytes, 118 + pub status: StatusCode, 119 + /// Optional DID we intended to resolve; used for validation helpers 120 + pub requested: Option<Did<'static>>, 121 + } 122 + 123 + impl DidDocResponse { 124 + /// Parse as borrowed DidDocument<'_> 125 + pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 126 + if self.status.is_success() { 127 + if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 128 + Ok(doc) 129 + } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) { 130 + Ok(DidDocument { 131 + id: mini_doc.did, 132 + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 133 + verification_method: None, 134 + service: Some(vec![Service { 135 + id: CowStr::new_static("#atproto_pds"), 136 + r#type: CowStr::new_static("AtprotoPersonalDataServer"), 137 + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 138 + Url::from_str(&mini_doc.pds).unwrap(), 139 + )))), 140 + extra_data: BTreeMap::new(), 141 + }]), 142 + extra_data: BTreeMap::new(), 143 + }) 144 + } else { 145 + Err(IdentityError::MissingPdsEndpoint) 146 + } 147 + } else { 148 + Err(IdentityError::HttpStatus(self.status)) 149 + } 150 + } 151 + 152 + /// Parse and validate that the DID in the document matches the requested DID if present. 153 + /// 154 + /// On mismatch, returns an error that contains the owned document for inspection. 155 + pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 156 + let doc = self.parse()?; 157 + if let Some(expected) = &self.requested { 158 + if doc.id.as_str() != expected.as_str() { 159 + return Err(IdentityError::DocIdMismatch { 160 + expected: expected.clone(), 161 + doc: doc.clone().into_static(), 162 + }); 163 + } 164 + } 165 + Ok(doc) 166 + } 167 + 168 + /// Parse as owned DidDocument<'static> 169 + pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 170 + if self.status.is_success() { 171 + if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 172 + Ok(doc.into_static()) 173 + } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) { 174 + Ok(DidDocument { 175 + id: mini_doc.did, 176 + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 177 + verification_method: None, 178 + service: Some(vec![Service { 179 + id: CowStr::new_static("#atproto_pds"), 180 + r#type: CowStr::new_static("AtprotoPersonalDataServer"), 181 + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 182 + Url::from_str(&mini_doc.pds).unwrap(), 183 + )))), 184 + extra_data: BTreeMap::new(), 185 + }]), 186 + extra_data: BTreeMap::new(), 187 + } 188 + .into_static()) 189 + } else { 190 + Err(IdentityError::MissingPdsEndpoint) 191 + } 192 + } else { 193 + Err(IdentityError::HttpStatus(self.status)) 194 + } 195 + } 196 + } 197 + 198 + /// Slingshot mini-doc data (subset of DID doc info) 199 + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 200 + #[serde(rename_all = "camelCase")] 201 + #[allow(missing_docs)] 202 + pub struct MiniDoc<'a> { 203 + #[serde(borrow)] 204 + pub did: Did<'a>, 205 + #[serde(borrow)] 206 + pub handle: Handle<'a>, 207 + #[serde(borrow)] 208 + pub pds: crate::CowStr<'a>, 209 + #[serde(borrow, rename = "signingKey", alias = "signing_key")] 210 + pub signing_key: crate::CowStr<'a>, 211 + } 212 + 213 + /// Handle → DID fallback step. 214 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 215 + pub enum HandleStep { 216 + /// DNS TXT _atproto.\<handle\> 217 + DnsTxt, 218 + /// HTTPS GET https://\<handle\>/.well-known/atproto-did 219 + HttpsWellKnown, 220 + /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 221 + PdsResolveHandle, 222 + } 223 + 224 + /// DID → Doc fallback step. 225 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 226 + pub enum DidStep { 227 + /// For did:web: fetch from the well-known location 228 + DidWebHttps, 229 + /// For did:plc: fetch from PLC source 230 + PlcHttp, 231 + /// If a PDS base is known, ask it for the DID doc 232 + PdsResolveDid, 233 + } 234 + 235 + /// Configurable resolver options. 236 + /// 237 + /// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 238 + /// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless 239 + /// XRPC over reqwest; authentication can be layered as needed). 240 + /// - `handle_order`/`did_order`: ordered strategies for resolution. 241 + /// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 242 + /// returning `DocIdMismatch` with the fetched document on mismatch. 243 + /// - `public_fallback_for_handle`: if true (default), attempt 244 + /// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 245 + /// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC 246 + /// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 247 + #[derive(Debug, Clone, Builder)] 248 + #[builder(start_fn = new)] 249 + pub struct ResolverOptions { 250 + /// PLC data source (directory or slingshot) 251 + pub plc_source: PlcSource, 252 + /// Optional PDS base to use for fallbacks 253 + pub pds_fallback: Option<Url>, 254 + /// Order of attempts for handle → DID resolution 255 + pub handle_order: Vec<HandleStep>, 256 + /// Order of attempts for DID → Doc resolution 257 + pub did_order: Vec<DidStep>, 258 + /// Validate that fetched DID document id matches the requested DID 259 + pub validate_doc_id: bool, 260 + /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 261 + pub public_fallback_for_handle: bool, 262 + } 263 + 264 + impl Default for ResolverOptions { 265 + fn default() -> Self { 266 + // By default, prefer DNS then HTTPS for handles, then PDS fallback 267 + // For DID documents, prefer method-native sources, then PDS fallback 268 + Self::new() 269 + .plc_source(PlcSource::default()) 270 + .handle_order(vec![ 271 + HandleStep::DnsTxt, 272 + HandleStep::HttpsWellKnown, 273 + HandleStep::PdsResolveHandle, 274 + ]) 275 + .did_order(vec![ 276 + DidStep::DidWebHttps, 277 + DidStep::PlcHttp, 278 + DidStep::PdsResolveDid, 279 + ]) 280 + .validate_doc_id(true) 281 + .public_fallback_for_handle(true) 282 + .build() 283 + } 284 + } 285 + 286 + /// Trait for identity resolution, for pluggable implementations. 287 + /// 288 + /// The provided `DefaultResolver` supports: 289 + /// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 290 + /// - HTTPS well-known for handles and `did:web` 291 + /// - PLC directory or Slingshot for `did:plc` 292 + /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 293 + /// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 294 + #[async_trait::async_trait] 295 + pub trait IdentityResolver { 296 + /// Access options for validation decisions in default methods 297 + fn options(&self) -> &ResolverOptions; 298 + 299 + /// Resolve handle 300 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>; 301 + 302 + /// Resolve DID document 303 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>; 304 + 305 + /// Resolve DID doc from an identifier 306 + async fn resolve_ident( 307 + &self, 308 + actor: &AtIdentifier<'_>, 309 + ) -> Result<DidDocResponse, IdentityError> { 310 + match actor { 311 + AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, 312 + AtIdentifier::Handle(handle) => { 313 + let did = self.resolve_handle(&handle).await?; 314 + self.resolve_did_doc(&did).await 315 + } 316 + } 317 + } 318 + 319 + /// Resolve DID doc from an identifier 320 + async fn resolve_ident_owned( 321 + &self, 322 + actor: &AtIdentifier<'_>, 323 + ) -> Result<DidDocument<'static>, IdentityError> { 324 + match actor { 325 + AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, 326 + AtIdentifier::Handle(handle) => { 327 + let did = self.resolve_handle(&handle).await?; 328 + self.resolve_did_doc_owned(&did).await 329 + } 330 + } 331 + } 332 + 333 + /// Resolve the DID document and return an owned version 334 + async fn resolve_did_doc_owned( 335 + &self, 336 + did: &Did<'_>, 337 + ) -> Result<DidDocument<'static>, IdentityError> { 338 + self.resolve_did_doc(did).await?.into_owned() 339 + } 340 + /// Return the PDS url for a DID 341 + async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 342 + let resp = self.resolve_did_doc(did).await?; 343 + let doc = resp.parse()?; 344 + // Default-on doc id equality check 345 + if self.options().validate_doc_id { 346 + if doc.id.as_str() != did.as_str() { 347 + return Err(IdentityError::DocIdMismatch { 348 + expected: did.clone().into_static(), 349 + doc: doc.clone().into_static(), 350 + }); 351 + } 352 + } 353 + doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 354 + } 355 + /// Return the DIS and PDS url for a handle 356 + async fn pds_for_handle( 357 + &self, 358 + handle: &Handle<'_>, 359 + ) -> Result<(Did<'static>, Url), IdentityError> { 360 + let did = self.resolve_handle(handle).await?; 361 + let pds = self.pds_for_did(&did).await?; 362 + Ok((did, pds)) 363 + } 364 + } 365 + 366 + #[cfg(test)] 367 + mod tests { 368 + use super::*; 369 + 370 + #[test] 371 + fn parse_validated_ok() { 372 + let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 373 + let requested = Did::new_owned("did:plc:alice").unwrap(); 374 + let resp = DidDocResponse { 375 + buffer: buf, 376 + status: StatusCode::OK, 377 + requested: Some(requested), 378 + }; 379 + let _doc = resp.parse_validated().expect("valid"); 380 + } 381 + 382 + #[test] 383 + fn parse_validated_mismatch() { 384 + let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 385 + let requested = Did::new_owned("did:plc:alice").unwrap(); 386 + let resp = DidDocResponse { 387 + buffer: buf, 388 + status: StatusCode::OK, 389 + requested: Some(requested), 390 + }; 391 + match resp.parse_validated() { 392 + Err(IdentityError::DocIdMismatch { expected, doc }) => { 393 + assert_eq!(expected.as_str(), "did:plc:alice"); 394 + assert_eq!(doc.id.as_str(), "did:plc:bob"); 395 + } 396 + other => panic!("unexpected result: {:?}", other), 397 + } 398 + } 399 + }
+15
crates/jacquard-common/src/lib.rs
··· 13 #[macro_use] 14 /// Trait for taking ownership of most borrowed types in jacquard. 15 pub mod into_static; 16 pub mod macros; 17 /// Baseline fundamental AT Protocol data types. 18 pub mod types;
··· 13 #[macro_use] 14 /// Trait for taking ownership of most borrowed types in jacquard. 15 pub mod into_static; 16 + pub mod error; 17 + /// HTTP client abstraction used by jacquard crates. 18 + pub mod http_client; 19 + pub mod ident_resolver; 20 pub mod macros; 21 + /// Generic session storage traits and utilities. 22 + pub mod session; 23 /// Baseline fundamental AT Protocol data types. 24 pub mod types; 25 + 26 + /// Authorization token types for XRPC requests. 27 + #[derive(Debug, Clone)] 28 + pub enum AuthorizationToken<'s> { 29 + /// Bearer token (access JWT, refresh JWT to refresh the session) 30 + Bearer(CowStr<'s>), 31 + /// DPoP token (proof-of-possession) for OAuth 32 + Dpop(CowStr<'s>), 33 + }
+76
crates/jacquard-common/src/session.rs
···
··· 1 + //! Generic session storage traits and utilities. 2 + 3 + use async_trait::async_trait; 4 + use miette::Diagnostic; 5 + use std::collections::HashMap; 6 + use std::error::Error as StdError; 7 + use std::hash::Hash; 8 + use std::sync::Arc; 9 + use tokio::sync::RwLock; 10 + 11 + /// Errors emitted by session stores. 12 + #[derive(Debug, thiserror::Error, Diagnostic)] 13 + pub enum SessionStoreError { 14 + /// Filesystem or I/O error 15 + #[error("I/O error: {0}")] 16 + #[diagnostic(code(jacquard::session_store::io))] 17 + Io(#[from] std::io::Error), 18 + /// Serialization error (e.g., JSON) 19 + #[error("serialization error: {0}")] 20 + #[diagnostic(code(jacquard::session_store::serde))] 21 + Serde(#[from] serde_json::Error), 22 + /// Any other error from a backend implementation 23 + #[error(transparent)] 24 + #[diagnostic(code(jacquard::session_store::other))] 25 + Other(#[from] Box<dyn StdError + Send + Sync>), 26 + } 27 + 28 + /// Pluggable storage for arbitrary session records. 29 + #[async_trait] 30 + pub trait SessionStore<K, T>: Send + Sync 31 + where 32 + K: Eq + Hash, 33 + T: Clone, 34 + { 35 + /// Get the current session if present. 36 + async fn get(&self, key: &K) -> Option<T>; 37 + /// Persist the given session. 38 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; 39 + /// Delete the given session. 40 + async fn del(&self, key: &K) -> Result<(), SessionStoreError>; 41 + /// Remove all stored sessions. 42 + async fn clear(&self) -> Result<(), SessionStoreError>; 43 + } 44 + 45 + /// In-memory session store suitable for short-lived sessions and tests. 46 + #[derive(Clone)] 47 + pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>); 48 + 49 + impl<K, T> Default for MemorySessionStore<K, T> { 50 + fn default() -> Self { 51 + Self(Arc::new(RwLock::new(HashMap::new()))) 52 + } 53 + } 54 + 55 + #[async_trait] 56 + impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T> 57 + where 58 + K: Eq + Hash + Send + Sync, 59 + T: Clone + Send + Sync + 'static, 60 + { 61 + async fn get(&self, key: &K) -> Option<T> { 62 + self.0.read().await.get(key).cloned() 63 + } 64 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { 65 + self.0.write().await.insert(key, session); 66 + Ok(()) 67 + } 68 + async fn del(&self, key: &K) -> Result<(), SessionStoreError> { 69 + self.0.write().await.remove(key); 70 + Ok(()) 71 + } 72 + async fn clear(&self) -> Result<(), SessionStoreError> { 73 + self.0.write().await.clear(); 74 + Ok(()) 75 + } 76 + }
+1 -1
crates/jacquard-common/src/types/did.rs
··· 21 /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. 22 /// 23 /// See: <https://atproto.com/specs/did> 24 - #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 25 #[serde(transparent)] 26 #[repr(transparent)] 27 pub struct Did<'d>(CowStr<'d>);
··· 21 /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. 22 /// 23 /// See: <https://atproto.com/specs/did> 24 + #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 25 #[serde(transparent)] 26 #[repr(transparent)] 27 pub struct Did<'d>(CowStr<'d>);
+1 -1
crates/jacquard-common/src/types/nsid.rs
··· 24 /// - Case-sensitive 25 /// 26 /// See: <https://atproto.com/specs/nsid> 27 - #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 28 #[serde(transparent)] 29 #[repr(transparent)] 30 pub struct Nsid<'n>(CowStr<'n>);
··· 24 /// - Case-sensitive 25 /// 26 /// See: <https://atproto.com/specs/nsid> 27 + #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 28 #[serde(transparent)] 29 #[repr(transparent)] 30 pub struct Nsid<'n>(CowStr<'n>);
+2 -2
crates/jacquard-common/src/types/string.rs
··· 251 /// detailing the specification for the type 252 /// `source` is the source string, or part of it 253 /// `kind` is the type of parsing error: `[StrParseKind]` 254 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 255 #[error("error in `{source}`: {kind}")] 256 #[diagnostic( 257 url("https://atproto.com/specs/{spec}"), ··· 406 } 407 408 /// Kinds of parsing errors for AT Protocol string types 409 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 410 pub enum StrParseKind { 411 /// Regex pattern validation failed 412 #[error("regex failure - {message}")]
··· 251 /// detailing the specification for the type 252 /// `source` is the source string, or part of it 253 /// `kind` is the type of parsing error: `[StrParseKind]` 254 + #[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] 255 #[error("error in `{source}`: {kind}")] 256 #[diagnostic( 257 url("https://atproto.com/specs/{spec}"), ··· 406 } 407 408 /// Kinds of parsing errors for AT Protocol string types 409 + #[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] 410 pub enum StrParseKind { 411 /// Regex pattern validation failed 412 #[error("regex failure - {message}")]
+446 -1
crates/jacquard-common/src/types/xrpc.rs
··· 1 use serde::{Deserialize, Serialize}; 2 - use std::error::Error; 3 use std::fmt::{self, Debug}; 4 5 use crate::IntoStatic; 6 use crate::types::value::Data; 7 8 /// Error type for encoding XRPC requests 9 #[derive(Debug, thiserror::Error, miette::Diagnostic)] ··· 103 GenericError(self.0.into_static()) 104 } 105 }
··· 1 + use bytes::Bytes; 2 + use http::{ 3 + HeaderName, HeaderValue, Request, StatusCode, 4 + header::{AUTHORIZATION, CONTENT_TYPE}, 5 + }; 6 use serde::{Deserialize, Serialize}; 7 + use smol_str::SmolStr; 8 use std::fmt::{self, Debug}; 9 + use std::{error::Error, marker::PhantomData}; 10 + use url::Url; 11 12 use crate::IntoStatic; 13 + use crate::error::TransportError; 14 + use crate::http_client::HttpClient; 15 use crate::types::value::Data; 16 + use crate::{AuthorizationToken, error::AuthError}; 17 + use crate::{CowStr, error::XrpcResult}; 18 19 /// Error type for encoding XRPC requests 20 #[derive(Debug, thiserror::Error, miette::Diagnostic)] ··· 114 GenericError(self.0.into_static()) 115 } 116 } 117 + 118 + /// Per-request options for XRPC calls. 119 + #[derive(Debug, Default, Clone)] 120 + pub struct CallOptions<'a> { 121 + /// Optional Authorization to apply (`Bearer` or `DPoP`). 122 + pub auth: Option<AuthorizationToken<'a>>, 123 + /// `atproto-proxy` header value. 124 + pub atproto_proxy: Option<CowStr<'a>>, 125 + /// `atproto-accept-labelers` header values. 126 + pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>, 127 + /// Extra headers to attach to this request. 128 + pub extra_headers: Vec<(HeaderName, HeaderValue)>, 129 + } 130 + 131 + /// Extension for stateless XRPC calls on any `HttpClient`. 132 + /// 133 + /// Example 134 + /// ```ignore 135 + /// use jacquard::client::XrpcExt; 136 + /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 137 + /// use jacquard::types::ident::AtIdentifier; 138 + /// use miette::IntoDiagnostic; 139 + /// 140 + /// #[tokio::main] 141 + /// async fn main() -> miette::Result<()> { 142 + /// let http = reqwest::Client::new(); 143 + /// let base = url::Url::parse("https://public.api.bsky.app")?; 144 + /// let resp = http 145 + /// .xrpc(base) 146 + /// .send( 147 + /// GetAuthorFeed::new() 148 + /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 149 + /// .limit(5) 150 + /// .build(), 151 + /// ) 152 + /// .await?; 153 + /// let out = resp.into_output()?; 154 + /// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 155 + /// Ok(()) 156 + /// } 157 + /// ``` 158 + pub trait XrpcExt: HttpClient { 159 + /// Start building an XRPC call for the given base URL. 160 + fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> 161 + where 162 + Self: Sized, 163 + { 164 + XrpcCall { 165 + client: self, 166 + base, 167 + opts: CallOptions::default(), 168 + } 169 + } 170 + } 171 + 172 + impl<T: HttpClient> XrpcExt for T {} 173 + 174 + /// Stateless XRPC call builder. 175 + /// 176 + /// Example (per-request overrides) 177 + /// ```ignore 178 + /// use jacquard::client::{XrpcExt, AuthorizationToken}; 179 + /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 180 + /// use jacquard::types::ident::AtIdentifier; 181 + /// use jacquard::CowStr; 182 + /// use miette::IntoDiagnostic; 183 + /// 184 + /// #[tokio::main] 185 + /// async fn main() -> miette::Result<()> { 186 + /// let http = reqwest::Client::new(); 187 + /// let base = url::Url::parse("https://public.api.bsky.app")?; 188 + /// let resp = http 189 + /// .xrpc(base) 190 + /// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 191 + /// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 192 + /// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 193 + /// .send( 194 + /// GetAuthorFeed::new() 195 + /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 196 + /// .limit(5) 197 + /// .build(), 198 + /// ) 199 + /// .await?; 200 + /// let out = resp.into_output()?; 201 + /// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 202 + /// Ok(()) 203 + /// } 204 + /// ``` 205 + pub struct XrpcCall<'a, C: HttpClient> { 206 + pub(crate) client: &'a C, 207 + pub(crate) base: Url, 208 + pub(crate) opts: CallOptions<'a>, 209 + } 210 + 211 + impl<'a, C: HttpClient> XrpcCall<'a, C> { 212 + /// Apply Authorization to this call. 213 + pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { 214 + self.opts.auth = Some(token); 215 + self 216 + } 217 + /// Set `atproto-proxy` header for this call. 218 + pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { 219 + self.opts.atproto_proxy = Some(proxy); 220 + self 221 + } 222 + /// Set `atproto-accept-labelers` header(s) for this call. 223 + pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self { 224 + self.opts.atproto_accept_labelers = Some(labelers); 225 + self 226 + } 227 + /// Add an extra header. 228 + pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { 229 + self.opts.extra_headers.push((name, value)); 230 + self 231 + } 232 + /// Replace the builder's options entirely. 233 + pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { 234 + self.opts = opts; 235 + self 236 + } 237 + 238 + /// Send the given typed XRPC request and return a response wrapper. 239 + pub async fn send<R: XrpcRequest + Send>(self, request: R) -> XrpcResult<Response<R>> { 240 + let http_request = build_http_request(&self.base, &request, &self.opts) 241 + .map_err(crate::error::TransportError::from)?; 242 + 243 + let http_response = self 244 + .client 245 + .send_http(http_request) 246 + .await 247 + .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 248 + 249 + let status = http_response.status(); 250 + let buffer = Bytes::from(http_response.into_body()); 251 + 252 + if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 253 + return Err(crate::error::HttpError { 254 + status, 255 + body: Some(buffer), 256 + } 257 + .into()); 258 + } 259 + 260 + Ok(Response::new(buffer, status)) 261 + } 262 + } 263 + 264 + /// HTTP headers commonly used in XRPC requests 265 + pub enum Header { 266 + /// Content-Type header 267 + ContentType, 268 + /// Authorization header 269 + Authorization, 270 + /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 271 + /// 272 + /// See: <https://atproto.com/specs/xrpc#service-proxying> 273 + AtprotoProxy, 274 + /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details. 275 + AtprotoAcceptLabelers, 276 + } 277 + 278 + impl From<Header> for HeaderName { 279 + fn from(value: Header) -> Self { 280 + match value { 281 + Header::ContentType => CONTENT_TYPE, 282 + Header::Authorization => AUTHORIZATION, 283 + Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 284 + Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 285 + } 286 + } 287 + } 288 + 289 + /// Build an HTTP request for an XRPC call given base URL and options 290 + pub fn build_http_request<R: XrpcRequest>( 291 + base: &Url, 292 + req: &R, 293 + opts: &CallOptions<'_>, 294 + ) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> { 295 + let mut url = base.clone(); 296 + let mut path = url.path().trim_end_matches('/').to_owned(); 297 + path.push_str("/xrpc/"); 298 + path.push_str(R::NSID); 299 + url.set_path(&path); 300 + 301 + if let XrpcMethod::Query = R::METHOD { 302 + let qs = serde_html_form::to_string(&req) 303 + .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; 304 + if !qs.is_empty() { 305 + url.set_query(Some(&qs)); 306 + } else { 307 + url.set_query(None); 308 + } 309 + } 310 + 311 + let method = match R::METHOD { 312 + XrpcMethod::Query => http::Method::GET, 313 + XrpcMethod::Procedure(_) => http::Method::POST, 314 + }; 315 + 316 + let mut builder = Request::builder().method(method).uri(url.as_str()); 317 + 318 + if let XrpcMethod::Procedure(encoding) = R::METHOD { 319 + builder = builder.header(Header::ContentType, encoding); 320 + } 321 + builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING); 322 + 323 + if let Some(token) = &opts.auth { 324 + let hv = match token { 325 + AuthorizationToken::Bearer(t) => { 326 + HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 327 + } 328 + AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 329 + } 330 + .map_err(|e| { 331 + TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 332 + })?; 333 + builder = builder.header(Header::Authorization, hv); 334 + } 335 + 336 + if let Some(proxy) = &opts.atproto_proxy { 337 + builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); 338 + } 339 + if let Some(labelers) = &opts.atproto_accept_labelers { 340 + if !labelers.is_empty() { 341 + let joined = labelers 342 + .iter() 343 + .map(|s| s.as_ref()) 344 + .collect::<Vec<_>>() 345 + .join(", "); 346 + builder = builder.header(Header::AtprotoAcceptLabelers, joined); 347 + } 348 + } 349 + for (name, value) in &opts.extra_headers { 350 + builder = builder.header(name, value); 351 + } 352 + 353 + let body = if let XrpcMethod::Procedure(_) = R::METHOD { 354 + req.encode_body() 355 + .map_err(|e| TransportError::InvalidRequest(e.to_string()))? 356 + } else { 357 + vec![] 358 + }; 359 + 360 + builder 361 + .body(body) 362 + .map_err(|e| TransportError::InvalidRequest(e.to_string())) 363 + } 364 + 365 + /// XRPC response wrapper that owns the response buffer 366 + /// 367 + /// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 368 + /// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). 369 + pub struct Response<R: XrpcRequest> { 370 + buffer: Bytes, 371 + status: StatusCode, 372 + _marker: PhantomData<R>, 373 + } 374 + 375 + impl<R: XrpcRequest> Response<R> { 376 + /// Create a new response from a buffer and status code 377 + pub fn new(buffer: Bytes, status: StatusCode) -> Self { 378 + Self { 379 + buffer, 380 + status, 381 + _marker: PhantomData, 382 + } 383 + } 384 + 385 + /// Get the HTTP status code 386 + pub fn status(&self) -> StatusCode { 387 + self.status 388 + } 389 + 390 + /// Parse the response, borrowing from the internal buffer 391 + pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> { 392 + // Use a helper to make lifetime inference work 393 + fn parse_output<'b, R: XrpcRequest>( 394 + buffer: &'b [u8], 395 + ) -> Result<R::Output<'b>, serde_json::Error> { 396 + serde_json::from_slice(buffer) 397 + } 398 + 399 + fn parse_error<'b, R: XrpcRequest>( 400 + buffer: &'b [u8], 401 + ) -> Result<R::Err<'b>, serde_json::Error> { 402 + serde_json::from_slice(buffer) 403 + } 404 + 405 + // 200: parse as output 406 + if self.status.is_success() { 407 + match parse_output::<R>(&self.buffer) { 408 + Ok(output) => Ok(output), 409 + Err(e) => Err(XrpcError::Decode(e)), 410 + } 411 + // 400: try typed XRPC error, fallback to generic error 412 + } else if self.status.as_u16() == 400 { 413 + match parse_error::<R>(&self.buffer) { 414 + Ok(error) => Err(XrpcError::Xrpc(error)), 415 + Err(_) => { 416 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 417 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 418 + Ok(generic) => { 419 + // Map auth-related errors to AuthError 420 + match generic.error.as_str() { 421 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 422 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 423 + _ => Err(XrpcError::Generic(generic)), 424 + } 425 + } 426 + Err(e) => Err(XrpcError::Decode(e)), 427 + } 428 + } 429 + } 430 + // 401: always auth error 431 + } else { 432 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 433 + Ok(generic) => match generic.error.as_str() { 434 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 435 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 436 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 437 + }, 438 + Err(e) => Err(XrpcError::Decode(e)), 439 + } 440 + } 441 + } 442 + 443 + /// Parse the response into an owned output 444 + pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>> 445 + where 446 + for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>, 447 + for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>, 448 + { 449 + // Use a helper to make lifetime inference work 450 + fn parse_output<'b, R: XrpcRequest>( 451 + buffer: &'b [u8], 452 + ) -> Result<R::Output<'b>, serde_json::Error> { 453 + serde_json::from_slice(buffer) 454 + } 455 + 456 + fn parse_error<'b, R: XrpcRequest>( 457 + buffer: &'b [u8], 458 + ) -> Result<R::Err<'b>, serde_json::Error> { 459 + serde_json::from_slice(buffer) 460 + } 461 + 462 + // 200: parse as output 463 + if self.status.is_success() { 464 + match parse_output::<R>(&self.buffer) { 465 + Ok(output) => Ok(output.into_static()), 466 + Err(e) => Err(XrpcError::Decode(e)), 467 + } 468 + // 400: try typed XRPC error, fallback to generic error 469 + } else if self.status.as_u16() == 400 { 470 + match parse_error::<R>(&self.buffer) { 471 + Ok(error) => Err(XrpcError::Xrpc(error.into_static())), 472 + Err(_) => { 473 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 474 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 475 + Ok(generic) => { 476 + // Map auth-related errors to AuthError 477 + match generic.error.as_ref() { 478 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 479 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 480 + _ => Err(XrpcError::Generic(generic)), 481 + } 482 + } 483 + Err(e) => Err(XrpcError::Decode(e)), 484 + } 485 + } 486 + } 487 + // 401: always auth error 488 + } else { 489 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 490 + Ok(generic) => match generic.error.as_ref() { 491 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 492 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 493 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 494 + }, 495 + Err(e) => Err(XrpcError::Decode(e)), 496 + } 497 + } 498 + } 499 + 500 + /// Get the raw buffer 501 + pub fn buffer(&self) -> &Bytes { 502 + &self.buffer 503 + } 504 + } 505 + 506 + /// Generic XRPC error format for untyped errors like InvalidRequest 507 + /// 508 + /// Used when the error doesn't match the endpoint's specific error enum 509 + #[derive(Debug, Clone, Deserialize)] 510 + pub struct GenericXrpcError { 511 + /// Error code (e.g., "InvalidRequest") 512 + pub error: SmolStr, 513 + /// Optional error message with details 514 + pub message: Option<SmolStr>, 515 + } 516 + 517 + impl std::fmt::Display for GenericXrpcError { 518 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 519 + if let Some(msg) = &self.message { 520 + write!(f, "{}: {}", self.error, msg) 521 + } else { 522 + write!(f, "{}", self.error) 523 + } 524 + } 525 + } 526 + 527 + impl std::error::Error for GenericXrpcError {} 528 + 529 + /// XRPC-specific errors returned from endpoints 530 + /// 531 + /// Represents errors returned in the response body 532 + /// Type parameter `E` is the endpoint's specific error enum type. 533 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 534 + pub enum XrpcError<E: std::error::Error + IntoStatic> { 535 + /// Typed XRPC error from the endpoint's specific error enum 536 + #[error("XRPC error: {0}")] 537 + Xrpc(E), 538 + 539 + /// Authentication error (ExpiredToken, InvalidToken, etc.) 540 + #[error("Authentication error: {0}")] 541 + Auth(#[from] AuthError), 542 + 543 + /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 544 + #[error("XRPC error: {0}")] 545 + Generic(GenericXrpcError), 546 + 547 + /// Failed to decode the response body 548 + #[error("Failed to decode response: {0}")] 549 + Decode(#[from] serde_json::Error), 550 + }
+29
crates/jacquard-oauth/Cargo.toml
···
··· 1 + [package] 2 + name = "jacquard-oauth" 3 + version = "0.1.0" 4 + edition = "2024" 5 + description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 + license = "MPL-2.0" 7 + 8 + [dependencies] 9 + jacquard-common = { version = "0.2.0", path = "../jacquard-common" } 10 + serde = { workspace = true, features = ["derive"] } 11 + serde_json = { workspace = true } 12 + url = { workspace = true } 13 + smol_str = { workspace = true } 14 + base64 = { version = "0.22" } 15 + sha2 = { version = "0.10" } 16 + thiserror = { workspace = true } 17 + serde_html_form = { workspace = true } 18 + miette = { workspace = true } 19 + uuid = { version = "1", features = ["v4","std"] } 20 + p256 = { version = "0.13", features = ["ecdsa"] } 21 + signature = "2" 22 + rand_core = "0.6" 23 + jose-jwa = "0.1" 24 + jose-jwk = { version = "0.1", features = ["p256"] } 25 + chrono = "0.4" 26 + elliptic-curve = "0.13.8" 27 + http.workspace = true 28 + rand = { version = "0.8.5", features = ["small_rng"] } 29 + async-trait = "0.1.89"
+360
crates/jacquard-oauth/src/atproto.rs
···
··· 1 + use std::str::FromStr; 2 + 3 + use crate::types::OAuthClientMetadata; 4 + use crate::{keyset::Keyset, scopes::Scope}; 5 + use jacquard_common::CowStr; 6 + use serde::{Deserialize, Serialize}; 7 + use thiserror::Error; 8 + use url::{Host, Url}; 9 + 10 + #[derive(Error, Debug)] 11 + pub enum Error { 12 + #[error("`client_id` must be a valid URL")] 13 + InvalidClientId, 14 + #[error("`grant_types` must include `authorization_code`")] 15 + InvalidGrantTypes, 16 + #[error("`scope` must not include `atproto`")] 17 + InvalidScope, 18 + #[error("`redirect_uris` must not be empty")] 19 + EmptyRedirectUris, 20 + #[error("`private_key_jwt` auth method requires `jwks` keys")] 21 + EmptyJwks, 22 + #[error( 23 + "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided" 24 + )] 25 + AuthSigningAlg, 26 + #[error(transparent)] 27 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 28 + #[error(transparent)] 29 + LocalhostClient(#[from] LocalhostClientError), 30 + } 31 + 32 + #[derive(Error, Debug)] 33 + pub enum LocalhostClientError { 34 + #[error("invalid redirect_uri: {0}")] 35 + Invalid(#[from] url::ParseError), 36 + #[error("loopback client_id must use `http:` redirect_uri")] 37 + NotHttpScheme, 38 + #[error("loopback client_id must not use `localhost` as redirect_uri hostname")] 39 + Localhost, 40 + #[error("loopback client_id must not use loopback addresses as redirect_uri")] 41 + NotLoopbackHost, 42 + } 43 + 44 + pub type Result<T> = core::result::Result<T, Error>; 45 + 46 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 47 + #[serde(rename_all = "snake_case")] 48 + pub enum AuthMethod { 49 + None, 50 + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication 51 + PrivateKeyJwt, 52 + } 53 + 54 + impl From<AuthMethod> for CowStr<'static> { 55 + fn from(value: AuthMethod) -> Self { 56 + match value { 57 + AuthMethod::None => CowStr::new_static("none"), 58 + AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"), 59 + } 60 + } 61 + } 62 + 63 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 64 + #[serde(rename_all = "snake_case")] 65 + pub enum GrantType { 66 + AuthorizationCode, 67 + RefreshToken, 68 + } 69 + 70 + impl From<GrantType> for CowStr<'static> { 71 + fn from(value: GrantType) -> Self { 72 + match value { 73 + GrantType::AuthorizationCode => CowStr::new_static("authorization_code"), 74 + GrantType::RefreshToken => CowStr::new_static("refresh_token"), 75 + } 76 + } 77 + } 78 + 79 + pub fn localhost_client_metadata<'s>( 80 + redirect_uris: Option<Vec<Url>>, 81 + scopes: Option<&'s [Scope<'s>]>, 82 + ) -> Result<OAuthClientMetadata<'s>> { 83 + // validate redirect_uris 84 + if let Some(redirect_uris) = &redirect_uris { 85 + for redirect_uri in redirect_uris { 86 + if redirect_uri.scheme() != "http" { 87 + return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme)); 88 + } 89 + if redirect_uri.host().map(|h| h.to_owned()) == Some(Host::parse("localhost").unwrap()) 90 + { 91 + return Err(Error::LocalhostClient(LocalhostClientError::Localhost)); 92 + } 93 + if redirect_uri 94 + .host() 95 + .map(|h| h.to_owned()) 96 + .map_or(true, |host| { 97 + host != Host::parse("127.0.0.1").unwrap() 98 + && host != Host::parse("[::1]").unwrap() 99 + }) 100 + { 101 + return Err(Error::LocalhostClient( 102 + LocalhostClientError::NotLoopbackHost, 103 + )); 104 + } 105 + } 106 + } 107 + // determine client_id 108 + #[derive(serde::Serialize)] 109 + struct Parameters<'a> { 110 + #[serde(skip_serializing_if = "Option::is_none")] 111 + redirect_uri: Option<Vec<Url>>, 112 + #[serde(skip_serializing_if = "Option::is_none")] 113 + scope: Option<CowStr<'a>>, 114 + } 115 + let query = serde_html_form::to_string(Parameters { 116 + redirect_uri: redirect_uris.clone(), 117 + scope: scopes.map(|s| Scope::serialize_multiple(s)), 118 + })?; 119 + let mut client_id = String::from("http://localhost"); 120 + if !query.is_empty() { 121 + client_id.push_str(&format!("?{query}")); 122 + } 123 + Ok(OAuthClientMetadata { 124 + client_id: Url::parse(&client_id).unwrap(), 125 + client_uri: None, 126 + redirect_uris: redirect_uris.unwrap_or(vec![ 127 + Url::from_str("http://127.0.0.1/").unwrap(), 128 + Url::from_str("http://[::1]/").unwrap(), 129 + ]), 130 + scope: None, 131 + grant_types: None, // will be set to `authorization_code` and `refresh_token` 132 + token_endpoint_auth_method: Some(CowStr::new_static("none")), 133 + dpop_bound_access_tokens: None, // will be set to `true` 134 + jwks_uri: None, 135 + jwks: None, 136 + token_endpoint_auth_signing_alg: None, 137 + }) 138 + } 139 + 140 + #[derive(Clone, Debug, PartialEq, Eq)] 141 + pub struct AtprotoClientMetadata<'m> { 142 + pub client_id: Url, 143 + pub client_uri: Option<Url>, 144 + pub redirect_uris: Vec<Url>, 145 + pub token_endpoint_auth_method: AuthMethod, 146 + pub grant_types: Vec<GrantType>, 147 + pub scopes: Vec<Scope<'m>>, 148 + pub jwks_uri: Option<Url>, 149 + pub token_endpoint_auth_signing_alg: Option<CowStr<'m>>, 150 + } 151 + 152 + pub fn atproto_client_metadata<'m>( 153 + metadata: AtprotoClientMetadata<'m>, 154 + keyset: &Option<Keyset>, 155 + ) -> Result<OAuthClientMetadata<'m>> { 156 + if metadata.redirect_uris.is_empty() { 157 + return Err(Error::EmptyRedirectUris); 158 + } 159 + if !metadata.grant_types.contains(&GrantType::AuthorizationCode) { 160 + return Err(Error::InvalidGrantTypes); 161 + } 162 + if !metadata.scopes.contains(&Scope::Atproto) { 163 + return Err(Error::InvalidScope); 164 + } 165 + let (jwks_uri, mut jwks) = (metadata.jwks_uri, None); 166 + match metadata.token_endpoint_auth_method { 167 + AuthMethod::None => { 168 + if metadata.token_endpoint_auth_signing_alg.is_some() { 169 + return Err(Error::AuthSigningAlg); 170 + } 171 + } 172 + AuthMethod::PrivateKeyJwt => { 173 + if let Some(keyset) = keyset { 174 + if metadata.token_endpoint_auth_signing_alg.is_none() { 175 + return Err(Error::AuthSigningAlg); 176 + } 177 + if jwks_uri.is_none() { 178 + jwks = Some(keyset.public_jwks()); 179 + } 180 + } else { 181 + return Err(Error::EmptyJwks); 182 + } 183 + } 184 + } 185 + Ok(OAuthClientMetadata { 186 + client_id: metadata.client_id, 187 + client_uri: metadata.client_uri, 188 + redirect_uris: metadata.redirect_uris, 189 + token_endpoint_auth_method: Some(metadata.token_endpoint_auth_method.into()), 190 + grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()), 191 + scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 192 + dpop_bound_access_tokens: Some(true), 193 + jwks_uri, 194 + jwks, 195 + token_endpoint_auth_signing_alg: metadata.token_endpoint_auth_signing_alg, 196 + }) 197 + } 198 + 199 + #[cfg(test)] 200 + mod tests { 201 + use std::str::FromStr; 202 + 203 + use crate::scopes::TransitionScope; 204 + 205 + use super::*; 206 + use elliptic_curve::SecretKey; 207 + use jose_jwk::{Jwk, Key, Parameters}; 208 + use p256::pkcs8::DecodePrivateKey; 209 + 210 + const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- 211 + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T 212 + 4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P 213 + gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 214 + -----END PRIVATE KEY-----"#; 215 + 216 + #[test] 217 + fn test_localhost_client_metadata_default() { 218 + assert_eq!( 219 + localhost_client_metadata(None, None).expect("failed to convert metadata"), 220 + OAuthClientMetadata { 221 + client_id: Url::from_str("http://localhost").unwrap(), 222 + client_uri: None, 223 + redirect_uris: vec![ 224 + Url::from_str("http://127.0.0.1/").unwrap(), 225 + Url::from_str("http://[::1]/").unwrap(), 226 + ], 227 + scope: None, 228 + grant_types: None, 229 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 230 + dpop_bound_access_tokens: None, 231 + jwks_uri: None, 232 + jwks: None, 233 + token_endpoint_auth_signing_alg: None, 234 + } 235 + ); 236 + } 237 + 238 + #[test] 239 + fn test_localhost_client_metadata_custom() { 240 + assert_eq!( 241 + localhost_client_metadata( 242 + Some(vec![ 243 + Url::from_str("http://127.0.0.1/callback").unwrap(), 244 + Url::from_str("http://[::1]/callback").unwrap(), 245 + ]), 246 + Some( 247 + vec![ 248 + Scope::Atproto, 249 + Scope::Transition(TransitionScope::Generic), 250 + Scope::parse("account:email").unwrap() 251 + ] 252 + .as_slice() 253 + ) 254 + ) 255 + .expect("failed to convert metadata"), 256 + OAuthClientMetadata { 257 + client_id: Url::from_str( 258 + "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" 259 + ).unwrap(), 260 + client_uri: None, 261 + redirect_uris: vec![ 262 + Url::from_str("http://127.0.0.1/callback").unwrap(), 263 + Url::from_str("http://[::1]/callback").unwrap(), 264 + ], 265 + scope: None, 266 + grant_types: None, 267 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 268 + dpop_bound_access_tokens: None, 269 + jwks_uri: None, 270 + jwks: None, 271 + token_endpoint_auth_signing_alg: None, 272 + } 273 + ); 274 + } 275 + 276 + #[test] 277 + fn test_localhost_client_metadata_invalid() { 278 + { 279 + let err = localhost_client_metadata( 280 + Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]), 281 + None, 282 + ) 283 + .expect_err("expected to fail"); 284 + assert!(matches!( 285 + err, 286 + Error::LocalhostClient(LocalhostClientError::NotHttpScheme) 287 + )); 288 + } 289 + { 290 + let err = localhost_client_metadata( 291 + Some(vec![Url::from_str("http://localhost:8000/").unwrap()]), 292 + None, 293 + ) 294 + .expect_err("expected to fail"); 295 + assert!(matches!( 296 + err, 297 + Error::LocalhostClient(LocalhostClientError::Localhost) 298 + )); 299 + } 300 + { 301 + let err = localhost_client_metadata( 302 + Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]), 303 + None, 304 + ) 305 + .expect_err("expected to fail"); 306 + assert!(matches!( 307 + err, 308 + Error::LocalhostClient(LocalhostClientError::NotLoopbackHost) 309 + )); 310 + } 311 + } 312 + 313 + #[test] 314 + fn test_client_metadata() { 315 + let metadata = AtprotoClientMetadata { 316 + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), 317 + client_uri: Some(Url::from_str("https://example.com").unwrap()), 318 + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], 319 + token_endpoint_auth_method: AuthMethod::PrivateKeyJwt, 320 + grant_types: vec![GrantType::AuthorizationCode], 321 + scopes: vec![Scope::Atproto], 322 + jwks_uri: None, 323 + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 324 + }; 325 + { 326 + let metadata = metadata.clone(); 327 + let err = atproto_client_metadata(metadata, &None).expect_err("expected to fail"); 328 + assert!(matches!(err, Error::EmptyJwks)); 329 + } 330 + { 331 + let metadata = metadata.clone(); 332 + let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY) 333 + .expect("failed to parse private key"); 334 + let keys = vec![Jwk { 335 + key: Key::from(&secret_key.into()), 336 + prm: Parameters { 337 + kid: Some(String::from("kid00")), 338 + ..Default::default() 339 + }, 340 + }]; 341 + let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset"); 342 + assert_eq!( 343 + atproto_client_metadata(metadata, &Some(keyset.clone())) 344 + .expect("failed to convert metadata"), 345 + OAuthClientMetadata { 346 + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), 347 + client_uri: Some(Url::from_str("https://example.com").unwrap()), 348 + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], 349 + scope: Some(CowStr::new_static("atproto")), 350 + grant_types: Some(vec![CowStr::new_static("authorization_code")]), 351 + token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 352 + dpop_bound_access_tokens: Some(true), 353 + jwks_uri: None, 354 + jwks: Some(keyset.public_jwks()), 355 + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 356 + } 357 + ); 358 + } 359 + } 360 + }
+260
crates/jacquard-oauth/src/dpop.rs
···
··· 1 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2 + use chrono::Utc; 3 + use http::{Request, Response, header::InvalidHeaderValue}; 4 + use jacquard_common::{ 5 + CowStr, 6 + http_client::HttpClient, 7 + session::{MemorySessionStore, SessionStore, SessionStoreError}, 8 + }; 9 + use jose_jwa::{Algorithm, Signing}; 10 + use jose_jwk::{Jwk, Key, crypto}; 11 + use p256::ecdsa::SigningKey; 12 + use rand::{RngCore, SeedableRng}; 13 + use sha2::Digest; 14 + use smol_str::{SmolStr, ToSmolStr}; 15 + 16 + use crate::jose::{ 17 + create_signed_jwt, 18 + jws::RegisteredHeader, 19 + jwt::{Claims, PublicClaims, RegisteredClaims}, 20 + }; 21 + 22 + pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; 23 + 24 + #[derive(serde::Deserialize)] 25 + struct ErrorResponse { 26 + error: String, 27 + } 28 + 29 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 30 + pub enum Error { 31 + #[error(transparent)] 32 + InvalidHeaderValue(#[from] InvalidHeaderValue), 33 + #[error(transparent)] 34 + SessionStore(#[from] SessionStoreError), 35 + #[error("crypto error: {0:?}")] 36 + JwkCrypto(crypto::Error), 37 + #[error("key does not match any alg supported by the server")] 38 + UnsupportedKey, 39 + #[error(transparent)] 40 + SerdeJson(#[from] serde_json::Error), 41 + #[error("Inner: {0}")] 42 + Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 43 + } 44 + 45 + type Result<T> = core::result::Result<T, Error>; 46 + 47 + #[inline] 48 + pub(crate) fn generate_jti() -> CowStr<'static> { 49 + let mut rng = rand::rngs::SmallRng::from_entropy(); 50 + let mut bytes = [0u8; 12]; 51 + rng.fill_bytes(&mut bytes); 52 + URL_SAFE_NO_PAD.encode(bytes).into() 53 + } 54 + 55 + /// Build a compact JWS (ES256) for DPoP with embedded public JWK. 56 + #[inline] 57 + pub fn build_dpop_proof<'s>( 58 + key: &Key, 59 + method: CowStr<'s>, 60 + url: CowStr<'s>, 61 + nonce: Option<CowStr<'s>>, 62 + ath: Option<CowStr<'s>>, 63 + ) -> Result<CowStr<'s>> { 64 + let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 65 + crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 66 + _ => return Err(Error::UnsupportedKey), 67 + }; 68 + build_dpop_proof_with_secret(&secret, method, url, nonce, ath) 69 + } 70 + 71 + /// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips. 72 + #[inline] 73 + pub fn build_dpop_proof_with_secret<'s>( 74 + secret: &p256::SecretKey, 75 + method: CowStr<'s>, 76 + url: CowStr<'s>, 77 + nonce: Option<CowStr<'s>>, 78 + ath: Option<CowStr<'s>>, 79 + ) -> Result<CowStr<'s>> { 80 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 81 + header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 82 + header.jwk = Some(Jwk { 83 + key: Key::from(&crypto::Key::from(secret.public_key())), 84 + prm: Default::default(), 85 + }); 86 + 87 + let claims = Claims { 88 + registered: RegisteredClaims { 89 + jti: Some(generate_jti()), 90 + iat: Some(Utc::now().timestamp()), 91 + ..Default::default() 92 + }, 93 + public: PublicClaims { 94 + htm: Some(method), 95 + htu: Some(url), 96 + ath: ath, 97 + nonce: nonce, 98 + }, 99 + }; 100 + Ok(create_signed_jwt( 101 + SigningKey::from(secret.clone()), 102 + header.into(), 103 + claims, 104 + )?) 105 + } 106 + 107 + pub struct DpopClient<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>> 108 + where 109 + S: SessionStore<CowStr<'static>, CowStr<'static>>, 110 + { 111 + inner: T, 112 + pub(crate) key: Key, 113 + nonces: S, 114 + is_auth_server: bool, 115 + } 116 + 117 + impl<T> DpopClient<T> { 118 + pub fn new( 119 + key: Key, 120 + http_client: T, 121 + is_auth_server: bool, 122 + supported_algs: &Option<Vec<CowStr<'static>>>, 123 + ) -> Result<Self> { 124 + if let Some(algs) = supported_algs { 125 + let alg = CowStr::from(match &key { 126 + Key::Ec(ec) => match &ec.crv { 127 + jose_jwk::EcCurves::P256 => "ES256", 128 + _ => unimplemented!(), 129 + }, 130 + _ => unimplemented!(), 131 + }); 132 + if !algs.contains(&alg) { 133 + return Err(Error::UnsupportedKey); 134 + } 135 + } 136 + let nonces = MemorySessionStore::<CowStr<'static>, CowStr<'static>>::default(); 137 + Ok(Self { 138 + inner: http_client, 139 + key, 140 + nonces, 141 + is_auth_server, 142 + }) 143 + } 144 + } 145 + 146 + impl<T, S> DpopClient<T, S> 147 + where 148 + S: SessionStore<CowStr<'static>, CowStr<'static>>, 149 + { 150 + fn build_proof<'s>( 151 + &self, 152 + method: CowStr<'s>, 153 + url: CowStr<'s>, 154 + ath: Option<CowStr<'s>>, 155 + nonce: Option<CowStr<'s>>, 156 + ) -> Result<CowStr<'s>> { 157 + build_dpop_proof(&self.key, method, url, nonce, ath) 158 + } 159 + fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> bool { 160 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 161 + if self.is_auth_server { 162 + if response.status() == 400 { 163 + if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 164 + return res.error == "use_dpop_nonce"; 165 + }; 166 + } 167 + } 168 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 169 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 170 + else if response.status() == 401 { 171 + if let Some(www_auth) = response 172 + .headers() 173 + .get("WWW-Authenticate") 174 + .and_then(|v| v.to_str().ok()) 175 + { 176 + return www_auth.starts_with("DPoP") 177 + && www_auth.contains(r#"error="use_dpop_nonce""#); 178 + } 179 + } 180 + false 181 + } 182 + } 183 + 184 + impl<T, S> HttpClient for DpopClient<T, S> 185 + where 186 + T: HttpClient + Send + Sync + 'static, 187 + S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static, 188 + { 189 + type Error = Error; 190 + 191 + async fn send_http( 192 + &self, 193 + mut request: Request<Vec<u8>>, 194 + ) -> core::result::Result<Response<Vec<u8>>, Self::Error> { 195 + let uri = request.uri(); 196 + let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr()); 197 + let method = CowStr::Owned(request.method().to_smolstr()); 198 + let uri = CowStr::Owned(uri.to_smolstr()); 199 + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 200 + let ath = request 201 + .headers() 202 + .get("Authorization") 203 + .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 204 + .map(|auth| { 205 + URL_SAFE_NO_PAD 206 + .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 207 + .into() 208 + }); 209 + 210 + let init_nonce = self.nonces.get(&nonce_key).await; 211 + let init_proof = 212 + self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?; 213 + request.headers_mut().insert("DPoP", init_proof.parse()?); 214 + let response = self 215 + .inner 216 + .send_http(request.clone()) 217 + .await 218 + .map_err(|e| Error::Inner(e.into()))?; 219 + 220 + let next_nonce = response 221 + .headers() 222 + .get("DPoP-Nonce") 223 + .and_then(|v| v.to_str().ok()) 224 + .map(|c| CowStr::Owned(SmolStr::new(c))); 225 + match &next_nonce { 226 + Some(s) if next_nonce != init_nonce => { 227 + // Store the fresh nonce for future requests 228 + self.nonces.set(nonce_key, s.clone()).await?; 229 + } 230 + _ => { 231 + // No nonce was returned or it is the same as the one we sent. No need to 232 + // update the nonce store, or retry the request. 233 + return Ok(response); 234 + } 235 + } 236 + 237 + if !self.is_use_dpop_nonce_error(&response) { 238 + return Ok(response); 239 + } 240 + let next_proof = self.build_proof(method, uri, ath, next_nonce)?; 241 + request.headers_mut().insert("DPoP", next_proof.parse()?); 242 + let response = self 243 + .inner 244 + .send_http(request) 245 + .await 246 + .map_err(|e| Error::Inner(e.into()))?; 247 + Ok(response) 248 + } 249 + } 250 + 251 + impl<T: Clone> Clone for DpopClient<T> { 252 + fn clone(&self) -> Self { 253 + Self { 254 + inner: self.inner.clone(), 255 + key: self.key.clone(), 256 + nonces: self.nonces.clone(), 257 + is_auth_server: self.is_auth_server, 258 + } 259 + } 260 + }
+42
crates/jacquard-oauth/src/error.rs
···
··· 1 + use miette::Diagnostic; 2 + use thiserror::Error; 3 + 4 + /// Errors emitted by OAuth helpers. 5 + #[derive(Debug, Error, Diagnostic)] 6 + pub enum OAuthError { 7 + /// Invalid or unsupported JWK 8 + #[error("invalid JWK: {0}")] 9 + #[diagnostic( 10 + code(jacquard_oauth::jwk), 11 + help("Ensure EC P-256 JWK with base64url x,y,d values") 12 + )] 13 + Jwk(String), 14 + /// Signing error 15 + #[error("signing error: {0}")] 16 + #[diagnostic( 17 + code(jacquard_oauth::signing), 18 + help("Check ES256 key material and input payloads") 19 + )] 20 + Signing(String), 21 + /// Serialization error 22 + #[error(transparent)] 23 + #[diagnostic(code(jacquard_oauth::serde))] 24 + Serde(#[from] serde_json::Error), 25 + /// URL error 26 + #[error(transparent)] 27 + #[diagnostic(code(jacquard_oauth::url))] 28 + Url(#[from] url::ParseError), 29 + /// URL error 30 + #[error(transparent)] 31 + #[diagnostic(code(jacquard_oauth::url))] 32 + UrlEncoding(#[from] serde_html_form::ser::Error), 33 + /// PKCE error 34 + #[error("pkce error: {0}")] 35 + #[diagnostic( 36 + code(jacquard_oauth::pkce), 37 + help("PKCE must use S256; ensure verifier/challenge generated") 38 + )] 39 + Pkce(String), 40 + } 41 + 42 + pub type Result<T> = core::result::Result<T, OAuthError>;
+14
crates/jacquard-oauth/src/jose.rs
···
··· 1 + pub mod jws; 2 + pub mod jwt; 3 + pub mod signing; 4 + 5 + use serde::{Deserialize, Serialize}; 6 + 7 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 8 + #[serde(untagged)] 9 + pub enum Header<'a> { 10 + #[serde(borrow)] 11 + Jws(jws::Header<'a>), 12 + } 13 + 14 + pub use self::signing::create_signed_jwt;
+85
crates/jacquard-oauth/src/jose/jws.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use jose_jwa::Algorithm; 3 + use jose_jwk::Jwk; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 7 + pub struct Header<'a> { 8 + #[serde(flatten)] 9 + #[serde(borrow)] 10 + pub registered: RegisteredHeader<'a>, 11 + } 12 + 13 + impl<'a> From<Header<'a>> for super::super::jose::Header<'a> { 14 + fn from(header: Header<'a>) -> Self { 15 + super::super::jose::Header::Jws(header) 16 + } 17 + } 18 + 19 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 20 + 21 + pub struct RegisteredHeader<'a> { 22 + pub alg: Algorithm, 23 + #[serde(borrow)] 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + pub jku: Option<CowStr<'a>>, 26 + #[serde(skip_serializing_if = "Option::is_none")] 27 + pub jwk: Option<Jwk>, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub kid: Option<CowStr<'a>>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub x5u: Option<CowStr<'a>>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub x5c: Option<CowStr<'a>>, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub x5t: Option<CowStr<'a>>, 36 + #[serde(skip_serializing_if = "Option::is_none")] 37 + #[serde(rename = "x5t#S256")] 38 + pub x5ts256: Option<CowStr<'a>>, 39 + 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + pub typ: Option<CowStr<'a>>, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub cty: Option<CowStr<'a>>, 44 + } 45 + 46 + impl From<Algorithm> for RegisteredHeader<'_> { 47 + fn from(alg: Algorithm) -> Self { 48 + Self { 49 + alg, 50 + jku: None, 51 + jwk: None, 52 + kid: None, 53 + x5u: None, 54 + x5c: None, 55 + x5t: None, 56 + x5ts256: None, 57 + typ: None, 58 + cty: None, 59 + } 60 + } 61 + } 62 + 63 + impl<'a> From<RegisteredHeader<'a>> for super::super::jose::Header<'a> { 64 + fn from(registered: RegisteredHeader<'a>) -> Self { 65 + super::super::jose::Header::Jws(Header { registered }) 66 + } 67 + } 68 + 69 + impl IntoStatic for RegisteredHeader<'_> { 70 + type Output = RegisteredHeader<'static>; 71 + fn into_static(self) -> Self::Output { 72 + RegisteredHeader { 73 + alg: self.alg, 74 + jku: self.jku.map(IntoStatic::into_static), 75 + jwk: self.jwk, 76 + kid: self.kid.map(IntoStatic::into_static), 77 + x5u: self.x5u.map(IntoStatic::into_static), 78 + x5c: self.x5c.map(IntoStatic::into_static), 79 + x5t: self.x5t.map(IntoStatic::into_static), 80 + x5ts256: self.x5ts256.map(IntoStatic::into_static), 81 + typ: self.typ.map(IntoStatic::into_static), 82 + cty: self.cty.map(IntoStatic::into_static), 83 + } 84 + } 85 + }
+101
crates/jacquard-oauth/src/jose/jwt.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 5 + pub struct Claims<'a> { 6 + #[serde(flatten)] 7 + pub registered: RegisteredClaims<'a>, 8 + #[serde(flatten)] 9 + #[serde(borrow)] 10 + pub public: PublicClaims<'a>, 11 + } 12 + 13 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 14 + 15 + pub struct RegisteredClaims<'a> { 16 + #[serde(borrow)] 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + pub iss: Option<CowStr<'a>>, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub sub: Option<CowStr<'a>>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub aud: Option<RegisteredClaimsAud<'a>>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub exp: Option<i64>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub nbf: Option<i64>, 27 + #[serde(skip_serializing_if = "Option::is_none")] 28 + pub iat: Option<i64>, 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub jti: Option<CowStr<'a>>, 31 + } 32 + 33 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 34 + 35 + pub struct PublicClaims<'a> { 36 + #[serde(borrow)] 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub htm: Option<CowStr<'a>>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub htu: Option<CowStr<'a>>, 41 + #[serde(skip_serializing_if = "Option::is_none")] 42 + pub ath: Option<CowStr<'a>>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub nonce: Option<CowStr<'a>>, 45 + } 46 + 47 + impl<'a> From<RegisteredClaims<'a>> for Claims<'a> { 48 + fn from(registered: RegisteredClaims<'a>) -> Self { 49 + Self { 50 + registered, 51 + public: PublicClaims::default(), 52 + } 53 + } 54 + } 55 + 56 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 57 + #[serde(untagged)] 58 + pub enum RegisteredClaimsAud<'a> { 59 + #[serde(borrow)] 60 + Single(CowStr<'a>), 61 + Multiple(Vec<CowStr<'a>>), 62 + } 63 + 64 + impl IntoStatic for RegisteredClaims<'_> { 65 + type Output = RegisteredClaims<'static>; 66 + fn into_static(self) -> Self::Output { 67 + RegisteredClaims { 68 + iss: self.iss.map(IntoStatic::into_static), 69 + sub: self.sub.map(IntoStatic::into_static), 70 + aud: self.aud.map(IntoStatic::into_static), 71 + exp: self.exp, 72 + nbf: self.nbf, 73 + iat: self.iat, 74 + jti: self.jti.map(IntoStatic::into_static), 75 + } 76 + } 77 + } 78 + 79 + impl IntoStatic for PublicClaims<'_> { 80 + type Output = PublicClaims<'static>; 81 + fn into_static(self) -> Self::Output { 82 + PublicClaims { 83 + htm: self.htm.map(IntoStatic::into_static), 84 + htu: self.htu.map(IntoStatic::into_static), 85 + ath: self.ath.map(IntoStatic::into_static), 86 + nonce: self.nonce.map(IntoStatic::into_static), 87 + } 88 + } 89 + } 90 + 91 + impl IntoStatic for RegisteredClaimsAud<'_> { 92 + type Output = RegisteredClaimsAud<'static>; 93 + fn into_static(self) -> Self::Output { 94 + match self { 95 + RegisteredClaimsAud::Single(s) => RegisteredClaimsAud::Single(s.into_static()), 96 + RegisteredClaimsAud::Multiple(v) => { 97 + RegisteredClaimsAud::Multiple(v.into_iter().map(IntoStatic::into_static).collect()) 98 + } 99 + } 100 + } 101 + }
+21
crates/jacquard-oauth/src/jose/signing.rs
···
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use jacquard_common::CowStr; 4 + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 5 + 6 + use super::{Header, jwt::Claims}; 7 + 8 + pub fn create_signed_jwt( 9 + key: SigningKey, 10 + header: Header, 11 + claims: Claims, 12 + ) -> serde_json::Result<CowStr<'static>> { 13 + let header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?); 14 + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims)?); 15 + let signature: Signature = key.sign(format!("{header}.{payload}").as_bytes()); 16 + Ok(format!( 17 + "{header}.{payload}.{}", 18 + URL_SAFE_NO_PAD.encode(signature.to_bytes()) 19 + ) 20 + .into()) 21 + }
+128
crates/jacquard-oauth/src/keyset.rs
···
··· 1 + use crate::jose::create_signed_jwt; 2 + use crate::jose::jws::RegisteredHeader; 3 + use crate::jose::jwt::Claims; 4 + use jacquard_common::CowStr; 5 + use jose_jwa::{Algorithm, Signing}; 6 + use jose_jwk::{Class, EcCurves, crypto}; 7 + use jose_jwk::{Jwk, JwkSet, Key}; 8 + use smol_str::{SmolStr, ToSmolStr}; 9 + use std::collections::HashSet; 10 + use thiserror::Error; 11 + 12 + #[derive(Error, Debug)] 13 + pub enum Error { 14 + #[error("duplicate kid: {0}")] 15 + DuplicateKid(String), 16 + #[error("keys must not be empty")] 17 + EmptyKeys, 18 + #[error("key must have a `kid`")] 19 + EmptyKid, 20 + #[error("no signing key found for algorithms: {0:?}")] 21 + NotFound(Vec<SmolStr>), 22 + #[error("key for signing must be a secret key")] 23 + PublicKey, 24 + #[error("crypto error: {0:?}")] 25 + JwkCrypto(crypto::Error), 26 + #[error(transparent)] 27 + SerdeJson(#[from] serde_json::Error), 28 + } 29 + 30 + pub type Result<T> = core::result::Result<T, Error>; 31 + 32 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 33 + pub struct Keyset(Vec<Jwk>); 34 + 35 + impl Keyset { 36 + const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [ 37 + "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", 38 + ]; 39 + pub fn public_jwks(&self) -> JwkSet { 40 + let mut keys = Vec::with_capacity(self.0.len()); 41 + for mut key in self.0.clone() { 42 + match key.key { 43 + Key::Ec(ref mut ec) => { 44 + ec.d = None; 45 + } 46 + _ => unimplemented!(), 47 + } 48 + keys.push(key); 49 + } 50 + JwkSet { keys } 51 + } 52 + pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> { 53 + let Some(jwk) = self.find_key(algs, Class::Signing) else { 54 + return Err(Error::NotFound(algs.to_vec())); 55 + }; 56 + self.create_jwt_with_key(jwk, claims) 57 + } 58 + fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> { 59 + let candidates = self 60 + .0 61 + .iter() 62 + .filter_map(|key| { 63 + if key.prm.cls.is_some_and(|c| c != cls) { 64 + return None; 65 + } 66 + let alg = match &key.key { 67 + Key::Ec(ec) => match ec.crv { 68 + EcCurves::P256 => "ES256", 69 + _ => unimplemented!(), 70 + }, 71 + _ => unimplemented!(), 72 + }; 73 + Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr())) 74 + }) 75 + .collect::<Vec<_>>(); 76 + for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { 77 + for (alg, key) in &candidates { 78 + if alg == &pref_alg { 79 + return Some(key); 80 + } 81 + } 82 + } 83 + None 84 + } 85 + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> { 86 + let kid = key.prm.kid.clone().unwrap(); 87 + match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 88 + crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { 89 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 90 + header.kid = Some(kid.into()); 91 + Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) 92 + } 93 + _ => unimplemented!(), 94 + } 95 + } 96 + } 97 + 98 + impl TryFrom<Vec<Jwk>> for Keyset { 99 + type Error = Error; 100 + 101 + fn try_from(keys: Vec<Jwk>) -> Result<Self> { 102 + if keys.is_empty() { 103 + return Err(Error::EmptyKeys); 104 + } 105 + let mut v = Vec::with_capacity(keys.len()); 106 + let mut hs = HashSet::with_capacity(keys.len()); 107 + for key in keys { 108 + if let Some(kid) = key.prm.kid.clone() { 109 + if hs.contains(&kid) { 110 + return Err(Error::DuplicateKid(kid)); 111 + } 112 + hs.insert(kid); 113 + // ensure that the key is a secret key 114 + if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 115 + crypto::Key::P256(crypto::Kind::Public(_)) => true, 116 + crypto::Key::P256(crypto::Kind::Secret(_)) => false, 117 + _ => unimplemented!(), 118 + } { 119 + return Err(Error::PublicKey); 120 + } 121 + v.push(key); 122 + } else { 123 + return Err(Error::EmptyKid); 124 + } 125 + } 126 + Ok(Self(v)) 127 + } 128 + }
+14
crates/jacquard-oauth/src/lib.rs
···
··· 1 + //! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard. 2 + //! Transport, discovery, and orchestration live in `jacquard`. 3 + 4 + pub mod atproto; 5 + pub mod dpop; 6 + pub mod error; 7 + pub mod jose; 8 + pub mod keyset; 9 + pub mod resolver; 10 + pub mod scopes; 11 + pub mod session; 12 + pub mod types; 13 + 14 + pub const FALLBACK_ALG: &str = "ES256";
+214
crates/jacquard-oauth/src/resolver.rs
···
··· 1 + use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 2 + use http::{Request, StatusCode}; 3 + use jacquard_common::IntoStatic; 4 + use jacquard_common::ident_resolver::{IdentityError, IdentityResolver}; 5 + use jacquard_common::types::did_doc::DidDocument; 6 + use jacquard_common::types::ident::AtIdentifier; 7 + use jacquard_common::{http_client::HttpClient, types::did::Did}; 8 + use url::Url; 9 + 10 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 11 + pub enum ResolverError { 12 + #[error("resource not found")] 13 + NotFound, 14 + #[error("invalid at identifier: {0}")] 15 + AtIdentifier(String), 16 + #[error("invalid did: {0}")] 17 + Did(String), 18 + #[error("invalid did document: {0}")] 19 + DidDocument(String), 20 + #[error("protected resource metadata is invalid: {0}")] 21 + ProtectedResourceMetadata(String), 22 + #[error("authorization server metadata is invalid: {0}")] 23 + AuthorizationServerMetadata(String), 24 + #[error("error resolving identity: {0}")] 25 + IdentityResolverError(#[from] IdentityError), 26 + #[error("unsupported did method: {0:?}")] 27 + UnsupportedDidMethod(Did<'static>), 28 + #[error(transparent)] 29 + Http(#[from] http::Error), 30 + #[error("http client error: {0}")] 31 + HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>), 32 + #[error("http status: {0:?}")] 33 + HttpStatus(StatusCode), 34 + #[error(transparent)] 35 + SerdeJson(#[from] serde_json::Error), 36 + #[error(transparent)] 37 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 38 + #[error(transparent)] 39 + Uri(#[from] url::ParseError), 40 + } 41 + 42 + #[async_trait::async_trait] 43 + pub trait OAuthResolver: IdentityResolver + HttpClient { 44 + async fn resolve_oauth( 45 + &self, 46 + input: &str, 47 + ) -> Result< 48 + ( 49 + OAuthAuthorizationServerMetadata<'static>, 50 + Option<DidDocument<'static>>, 51 + ), 52 + ResolverError, 53 + > { 54 + // Allow using an entryway, or PDS url, directly as login input (e.g. 55 + // when the user forgot their handle, or when the handle does not 56 + // resolve to a DID) 57 + Ok(if input.starts_with("https://") { 58 + let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 59 + (self.resolve_from_service(&url).await?, None) 60 + } else { 61 + let (metadata, identity) = self.resolve_from_identity(input).await?; 62 + (metadata, Some(identity)) 63 + }) 64 + } 65 + async fn resolve_from_service( 66 + &self, 67 + input: &Url, 68 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 69 + // Assume first that input is a PDS URL (as required by ATPROTO) 70 + if let Ok(metadata) = self.get_resource_server_metadata(input).await { 71 + return Ok(metadata); 72 + } 73 + // Fallback to trying to fetch as an issuer (Entryway) 74 + self.get_authorization_server_metadata(input).await 75 + } 76 + async fn resolve_from_identity( 77 + &self, 78 + input: &str, 79 + ) -> Result< 80 + ( 81 + OAuthAuthorizationServerMetadata<'static>, 82 + DidDocument<'static>, 83 + ), 84 + ResolverError, 85 + > { 86 + let actor = AtIdentifier::new(input) 87 + .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 88 + let identity = self.resolve_ident_owned(&actor).await?; 89 + if let Some(pds) = &identity.pds_endpoint() { 90 + let metadata = self.get_resource_server_metadata(pds).await?; 91 + Ok((metadata, identity)) 92 + } else { 93 + Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 94 + } 95 + } 96 + async fn get_authorization_server_metadata( 97 + &self, 98 + issuer: &Url, 99 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 100 + Ok(resolve_authorization_server(self, issuer).await?) 101 + } 102 + async fn get_resource_server_metadata( 103 + &self, 104 + pds: &Url, 105 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 106 + let rs_metadata = resolve_protected_resource_info(self, pds).await?; 107 + // ATPROTO requires one, and only one, authorization server entry 108 + // > That document MUST contain a single item in the authorization_servers array. 109 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 110 + let issuer = match &rs_metadata.authorization_servers { 111 + Some(servers) if !servers.is_empty() => { 112 + if servers.len() > 1 { 113 + return Err(ResolverError::ProtectedResourceMetadata(format!( 114 + "unable to determine authorization server for PDS: {pds}" 115 + ))); 116 + } 117 + &servers[0] 118 + } 119 + _ => { 120 + return Err(ResolverError::ProtectedResourceMetadata(format!( 121 + "no authorization server found for PDS: {pds}" 122 + ))); 123 + } 124 + }; 125 + let as_metadata = self.get_authorization_server_metadata(issuer).await?; 126 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 127 + if let Some(protected_resources) = &as_metadata.protected_resources { 128 + if !protected_resources.contains(&rs_metadata.resource) { 129 + return Err(ResolverError::AuthorizationServerMetadata(format!( 130 + "pds {pds} does not protected by issuer: {issuer}", 131 + ))); 132 + } 133 + } 134 + 135 + // TODO: atproot specific validation? 136 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 137 + // 138 + // eg. 139 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 140 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 141 + // return Err(Error::AuthorizationServerMetadata(format!( 142 + // "authorization server does not support client_id_metadata_document: {issuer}" 143 + // ))); 144 + // } 145 + 146 + Ok(as_metadata) 147 + } 148 + } 149 + 150 + pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 151 + client: &T, 152 + server: &Url, 153 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 154 + let url = server 155 + .join("/.well-known/oauth-authorization-server") 156 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 157 + 158 + let req = Request::builder() 159 + .uri(url.to_string()) 160 + .body(Vec::new()) 161 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 162 + let res = client 163 + .send_http(req) 164 + .await 165 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 166 + if res.status() == StatusCode::OK { 167 + let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 168 + .map_err(ResolverError::SerdeJson)?; 169 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 170 + if metadata.issuer == server.as_str() { 171 + Ok(metadata.into_static()) 172 + } else { 173 + Err(ResolverError::AuthorizationServerMetadata(format!( 174 + "invalid issuer: {}", 175 + metadata.issuer 176 + ))) 177 + } 178 + } else { 179 + Err(ResolverError::HttpStatus(res.status())) 180 + } 181 + } 182 + 183 + pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 184 + client: &T, 185 + server: &Url, 186 + ) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 187 + let url = server 188 + .join("/.well-known/oauth-protected-resource") 189 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 190 + 191 + let req = Request::builder() 192 + .uri(url.to_string()) 193 + .body(Vec::new()) 194 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 195 + let res = client 196 + .send_http(req) 197 + .await 198 + .map_err(|e| ResolverError::HttpClient(e.into()))?; 199 + if res.status() == StatusCode::OK { 200 + let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 201 + .map_err(ResolverError::SerdeJson)?; 202 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 203 + if metadata.resource == server.as_str() { 204 + Ok(metadata.into_static()) 205 + } else { 206 + Err(ResolverError::AuthorizationServerMetadata(format!( 207 + "invalid resource: {}", 208 + metadata.resource 209 + ))) 210 + } 211 + } else { 212 + Err(ResolverError::HttpStatus(res.status())) 213 + } 214 + }
+1969
crates/jacquard-oauth/src/scopes.rs
···
··· 1 + //! AT Protocol OAuth scopes module 2 + //! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs 3 + //! 4 + //! This module provides comprehensive support for AT Protocol OAuth scopes, 5 + //! including parsing, serialization, normalization, and permission checking. 6 + //! 7 + //! Scopes in AT Protocol follow a prefix-based format with optional query parameters: 8 + //! - `account`: Access to account information (email, repo, status) 9 + //! - `identity`: Access to identity information (handle) 10 + //! - `blob`: Access to blob operations with mime type constraints 11 + //! - `repo`: Repository operations with collection and action constraints 12 + //! - `rpc`: RPC method access with lexicon and audience constraints 13 + //! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used 14 + //! - `transition`: Migration operations (generic or email) 15 + //! 16 + //! Standard OpenID Connect scopes (no suffixes or query parameters): 17 + //! - `openid`: Required for OpenID Connect authentication 18 + //! - `profile`: Access to user profile information 19 + //! - `email`: Access to user email address 20 + 21 + use std::collections::{BTreeMap, BTreeSet}; 22 + use std::fmt; 23 + use std::str::FromStr; 24 + 25 + use jacquard_common::types::did::Did; 26 + use jacquard_common::types::nsid::Nsid; 27 + use jacquard_common::types::string::AtStrError; 28 + use jacquard_common::{CowStr, IntoStatic}; 29 + use smol_str::{SmolStr, ToSmolStr}; 30 + 31 + /// Represents an AT Protocol OAuth scope 32 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 33 + pub enum Scope<'s> { 34 + /// Account scope for accessing account information 35 + Account(AccountScope), 36 + /// Identity scope for accessing identity information 37 + Identity(IdentityScope), 38 + /// Blob scope for blob operations with mime type constraints 39 + Blob(BlobScope<'s>), 40 + /// Repository scope for collection operations 41 + Repo(RepoScope<'s>), 42 + /// RPC scope for method access 43 + Rpc(RpcScope<'s>), 44 + /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 45 + Atproto, 46 + /// Transition scope for migration operations 47 + Transition(TransitionScope), 48 + /// OpenID Connect scope - required for OpenID Connect authentication 49 + OpenId, 50 + /// Profile scope - access to user profile information 51 + Profile, 52 + /// Email scope - access to user email address 53 + Email, 54 + } 55 + 56 + impl IntoStatic for Scope<'_> { 57 + type Output = Scope<'static>; 58 + 59 + fn into_static(self) -> Self::Output { 60 + match self { 61 + Scope::Account(scope) => Scope::Account(scope), 62 + Scope::Identity(scope) => Scope::Identity(scope), 63 + Scope::Blob(scope) => Scope::Blob(scope.into_static()), 64 + Scope::Repo(scope) => Scope::Repo(scope.into_static()), 65 + Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 66 + Scope::Atproto => Scope::Atproto, 67 + Scope::Transition(scope) => Scope::Transition(scope), 68 + Scope::OpenId => Scope::OpenId, 69 + Scope::Profile => Scope::Profile, 70 + Scope::Email => Scope::Email, 71 + } 72 + } 73 + } 74 + 75 + /// Account scope attributes 76 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 77 + pub struct AccountScope { 78 + /// The account resource type 79 + pub resource: AccountResource, 80 + /// The action permission level 81 + pub action: AccountAction, 82 + } 83 + 84 + /// Account resource types 85 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 86 + pub enum AccountResource { 87 + /// Email access 88 + Email, 89 + /// Repository access 90 + Repo, 91 + /// Status access 92 + Status, 93 + } 94 + 95 + /// Account action permissions 96 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 97 + pub enum AccountAction { 98 + /// Read-only access 99 + Read, 100 + /// Management access (includes read) 101 + Manage, 102 + } 103 + 104 + /// Identity scope attributes 105 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 106 + pub enum IdentityScope { 107 + /// Handle access 108 + Handle, 109 + /// All identity access (wildcard) 110 + All, 111 + } 112 + 113 + /// Transition scope types 114 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 115 + pub enum TransitionScope { 116 + /// Generic transition operations 117 + Generic, 118 + /// Email transition operations 119 + Email, 120 + } 121 + 122 + /// Blob scope with mime type constraints 123 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 124 + pub struct BlobScope<'s> { 125 + /// Accepted mime types 126 + pub accept: BTreeSet<MimePattern<'s>>, 127 + } 128 + 129 + impl IntoStatic for BlobScope<'_> { 130 + type Output = BlobScope<'static>; 131 + 132 + fn into_static(self) -> Self::Output { 133 + BlobScope { 134 + accept: self.accept.into_iter().map(|p| p.into_static()).collect(), 135 + } 136 + } 137 + } 138 + 139 + /// MIME type pattern for blob scope 140 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 141 + pub enum MimePattern<'s> { 142 + /// Match all types 143 + All, 144 + /// Match all subtypes of a type (e.g., "image/*") 145 + TypeWildcard(CowStr<'s>), 146 + /// Exact mime type match 147 + Exact(CowStr<'s>), 148 + } 149 + 150 + impl IntoStatic for MimePattern<'_> { 151 + type Output = MimePattern<'static>; 152 + 153 + fn into_static(self) -> Self::Output { 154 + match self { 155 + MimePattern::All => MimePattern::All, 156 + MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()), 157 + MimePattern::Exact(s) => MimePattern::Exact(s.into_static()), 158 + } 159 + } 160 + } 161 + 162 + /// Repository scope with collection and action constraints 163 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 164 + pub struct RepoScope<'s> { 165 + /// Collection NSID or wildcard 166 + pub collection: RepoCollection<'s>, 167 + /// Allowed actions 168 + pub actions: BTreeSet<RepoAction>, 169 + } 170 + 171 + impl IntoStatic for RepoScope<'_> { 172 + type Output = RepoScope<'static>; 173 + 174 + fn into_static(self) -> Self::Output { 175 + RepoScope { 176 + collection: self.collection.into_static(), 177 + actions: self.actions, 178 + } 179 + } 180 + } 181 + 182 + /// Repository collection identifier 183 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 184 + pub enum RepoCollection<'s> { 185 + /// All collections (wildcard) 186 + All, 187 + /// Specific collection NSID 188 + Nsid(Nsid<'s>), 189 + } 190 + 191 + impl IntoStatic for RepoCollection<'_> { 192 + type Output = RepoCollection<'static>; 193 + 194 + fn into_static(self) -> Self::Output { 195 + match self { 196 + RepoCollection::All => RepoCollection::All, 197 + RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), 198 + } 199 + } 200 + } 201 + 202 + /// Repository actions 203 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 204 + pub enum RepoAction { 205 + /// Create records 206 + Create, 207 + /// Update records 208 + Update, 209 + /// Delete records 210 + Delete, 211 + } 212 + 213 + /// RPC scope with lexicon method and audience constraints 214 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 215 + pub struct RpcScope<'s> { 216 + /// Lexicon methods (NSIDs or wildcard) 217 + pub lxm: BTreeSet<RpcLexicon<'s>>, 218 + /// Audiences (DIDs or wildcard) 219 + pub aud: BTreeSet<RpcAudience<'s>>, 220 + } 221 + 222 + impl IntoStatic for RpcScope<'_> { 223 + type Output = RpcScope<'static>; 224 + 225 + fn into_static(self) -> Self::Output { 226 + RpcScope { 227 + lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(), 228 + aud: self.aud.into_iter().map(|s| s.into_static()).collect(), 229 + } 230 + } 231 + } 232 + 233 + /// RPC lexicon identifier 234 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 235 + pub enum RpcLexicon<'s> { 236 + /// All lexicons (wildcard) 237 + All, 238 + /// Specific lexicon NSID 239 + Nsid(Nsid<'s>), 240 + } 241 + 242 + impl IntoStatic for RpcLexicon<'_> { 243 + type Output = RpcLexicon<'static>; 244 + 245 + fn into_static(self) -> Self::Output { 246 + match self { 247 + RpcLexicon::All => RpcLexicon::All, 248 + RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()), 249 + } 250 + } 251 + } 252 + 253 + /// RPC audience identifier 254 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 255 + pub enum RpcAudience<'s> { 256 + /// All audiences (wildcard) 257 + All, 258 + /// Specific DID 259 + Did(Did<'s>), 260 + } 261 + 262 + impl IntoStatic for RpcAudience<'_> { 263 + type Output = RpcAudience<'static>; 264 + 265 + fn into_static(self) -> Self::Output { 266 + match self { 267 + RpcAudience::All => RpcAudience::All, 268 + RpcAudience::Did(did) => RpcAudience::Did(did.into_static()), 269 + } 270 + } 271 + } 272 + 273 + impl<'s> Scope<'s> { 274 + /// Parse multiple space-separated scopes from a string 275 + /// 276 + /// # Examples 277 + /// ``` 278 + /// # use jacquard_oauth::scopes::Scope; 279 + /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 280 + /// assert_eq!(scopes.len(), 2); 281 + /// ``` 282 + pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> { 283 + if s.trim().is_empty() { 284 + return Ok(Vec::new()); 285 + } 286 + 287 + let mut scopes = Vec::new(); 288 + for scope_str in s.split_whitespace() { 289 + scopes.push(Self::parse(scope_str)?); 290 + } 291 + 292 + Ok(scopes) 293 + } 294 + 295 + /// Parse multiple space-separated scopes and return the minimal set needed 296 + /// 297 + /// This method removes duplicate scopes and scopes that are already granted 298 + /// by other scopes in the list, returning only the minimal set of scopes needed. 299 + /// 300 + /// # Examples 301 + /// ``` 302 + /// # use jacquard_oauth::scopes::Scope; 303 + /// // repo:* grants repo:foo.bar, so only repo:* is kept 304 + /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 305 + /// assert_eq!(scopes.len(), 2); // atproto and repo:* 306 + /// ``` 307 + pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> { 308 + let all_scopes = Self::parse_multiple(s)?; 309 + 310 + if all_scopes.is_empty() { 311 + return Ok(Vec::new()); 312 + } 313 + 314 + let mut result: Vec<Self> = Vec::new(); 315 + 316 + for scope in all_scopes { 317 + // Check if this scope is already granted by something in the result 318 + let mut is_granted = false; 319 + for existing in &result { 320 + if existing.grants(&scope) && existing != &scope { 321 + is_granted = true; 322 + break; 323 + } 324 + } 325 + 326 + if is_granted { 327 + continue; // Skip this scope, it's already covered 328 + } 329 + 330 + // Check if this scope grants any existing scopes in the result 331 + let mut indices_to_remove = Vec::new(); 332 + for (i, existing) in result.iter().enumerate() { 333 + if scope.grants(existing) && &scope != existing { 334 + indices_to_remove.push(i); 335 + } 336 + } 337 + 338 + // Remove scopes that are granted by the new scope (in reverse order to maintain indices) 339 + for i in indices_to_remove.into_iter().rev() { 340 + result.remove(i); 341 + } 342 + 343 + // Add the new scope if it's not a duplicate 344 + if !result.contains(&scope) { 345 + result.push(scope); 346 + } 347 + } 348 + 349 + Ok(result) 350 + } 351 + 352 + /// Serialize a list of scopes into a space-separated OAuth scopes string 353 + /// 354 + /// The scopes are sorted alphabetically by their string representation to ensure 355 + /// consistent output regardless of input order. 356 + /// 357 + /// # Examples 358 + /// ``` 359 + /// # use jacquard_oauth::scopes::Scope; 360 + /// let scopes = vec![ 361 + /// Scope::parse("repo:*").unwrap(), 362 + /// Scope::parse("atproto").unwrap(), 363 + /// Scope::parse("account:email").unwrap(), 364 + /// ]; 365 + /// let result = Scope::serialize_multiple(&scopes); 366 + /// assert_eq!(result, "account:email atproto repo:*"); 367 + /// ``` 368 + pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> { 369 + if scopes.is_empty() { 370 + return CowStr::default(); 371 + } 372 + 373 + let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect(); 374 + 375 + serialized.sort(); 376 + serialized.join(" ").into() 377 + } 378 + 379 + /// Remove a scope from a list of scopes 380 + /// 381 + /// Returns a new vector with all instances of the specified scope removed. 382 + /// If the scope doesn't exist in the list, returns a copy of the original list. 383 + /// 384 + /// # Examples 385 + /// ``` 386 + /// # use jacquard_oauth::scopes::Scope; 387 + /// let scopes = vec![ 388 + /// Scope::parse("repo:*").unwrap(), 389 + /// Scope::parse("atproto").unwrap(), 390 + /// Scope::parse("account:email").unwrap(), 391 + /// ]; 392 + /// let to_remove = Scope::parse("atproto").unwrap(); 393 + /// let result = Scope::remove_scope(&scopes, &to_remove); 394 + /// assert_eq!(result.len(), 2); 395 + /// assert!(!result.contains(&to_remove)); 396 + /// ``` 397 + pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> { 398 + scopes 399 + .iter() 400 + .filter(|s| *s != scope_to_remove) 401 + .cloned() 402 + .collect() 403 + } 404 + 405 + /// Parse a scope from a string 406 + pub fn parse(s: &'s str) -> Result<Self, ParseError> { 407 + // Determine the prefix first by checking for known prefixes 408 + let prefixes = [ 409 + "account", 410 + "identity", 411 + "blob", 412 + "repo", 413 + "rpc", 414 + "atproto", 415 + "transition", 416 + "openid", 417 + "profile", 418 + "email", 419 + ]; 420 + let mut found_prefix = None; 421 + let mut suffix = None; 422 + 423 + for prefix in &prefixes { 424 + if let Some(remainder) = s.strip_prefix(prefix) 425 + && (remainder.is_empty() 426 + || remainder.starts_with(':') 427 + || remainder.starts_with('?')) 428 + { 429 + found_prefix = Some(*prefix); 430 + if let Some(stripped) = remainder.strip_prefix(':') { 431 + suffix = Some(stripped); 432 + } else if remainder.starts_with('?') { 433 + suffix = Some(remainder); 434 + } else { 435 + suffix = None; 436 + } 437 + break; 438 + } 439 + } 440 + 441 + let prefix = found_prefix.ok_or_else(|| { 442 + // If no known prefix found, extract what looks like a prefix for error reporting 443 + let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); 444 + ParseError::UnknownPrefix(s[..end].to_string()) 445 + })?; 446 + 447 + match prefix { 448 + "account" => Self::parse_account(suffix), 449 + "identity" => Self::parse_identity(suffix), 450 + "blob" => Self::parse_blob(suffix), 451 + "repo" => Self::parse_repo(suffix), 452 + "rpc" => Self::parse_rpc(suffix), 453 + "atproto" => Self::parse_atproto(suffix), 454 + "transition" => Self::parse_transition(suffix), 455 + "openid" => Self::parse_openid(suffix), 456 + "profile" => Self::parse_profile(suffix), 457 + "email" => Self::parse_email(suffix), 458 + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), 459 + } 460 + } 461 + 462 + fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> { 463 + let (resource_str, params) = match suffix { 464 + Some(s) => { 465 + if let Some(pos) = s.find('?') { 466 + (&s[..pos], Some(&s[pos + 1..])) 467 + } else { 468 + (s, None) 469 + } 470 + } 471 + None => return Err(ParseError::MissingResource), 472 + }; 473 + 474 + let resource = match resource_str { 475 + "email" => AccountResource::Email, 476 + "repo" => AccountResource::Repo, 477 + "status" => AccountResource::Status, 478 + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), 479 + }; 480 + 481 + let action = if let Some(params) = params { 482 + let parsed_params = parse_query_string(params); 483 + match parsed_params 484 + .get("action") 485 + .and_then(|v| v.first()) 486 + .map(|s| s.as_ref()) 487 + { 488 + Some("read") => AccountAction::Read, 489 + Some("manage") => AccountAction::Manage, 490 + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), 491 + None => AccountAction::Read, 492 + } 493 + } else { 494 + AccountAction::Read 495 + }; 496 + 497 + Ok(Scope::Account(AccountScope { resource, action })) 498 + } 499 + 500 + fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> { 501 + let scope = match suffix { 502 + Some("handle") => IdentityScope::Handle, 503 + Some("*") => IdentityScope::All, 504 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 505 + None => return Err(ParseError::MissingResource), 506 + }; 507 + 508 + Ok(Scope::Identity(scope)) 509 + } 510 + 511 + fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> { 512 + let mut accept = BTreeSet::new(); 513 + 514 + match suffix { 515 + Some(s) if s.starts_with('?') => { 516 + let params = parse_query_string(&s[1..]); 517 + if let Some(values) = params.get("accept") { 518 + for value in values { 519 + accept.insert(MimePattern::from_str(value)?); 520 + } 521 + } 522 + } 523 + Some(s) => { 524 + accept.insert(MimePattern::from_str(s)?); 525 + } 526 + None => { 527 + accept.insert(MimePattern::All); 528 + } 529 + } 530 + 531 + if accept.is_empty() { 532 + accept.insert(MimePattern::All); 533 + } 534 + 535 + Ok(Scope::Blob(BlobScope { accept })) 536 + } 537 + 538 + fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> { 539 + let (collection_str, params) = match suffix { 540 + Some(s) => { 541 + if let Some(pos) = s.find('?') { 542 + (Some(&s[..pos]), Some(&s[pos + 1..])) 543 + } else { 544 + (Some(s), None) 545 + } 546 + } 547 + None => (None, None), 548 + }; 549 + 550 + let collection = match collection_str { 551 + Some("*") | None => RepoCollection::All, 552 + Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?), 553 + }; 554 + 555 + let mut actions = BTreeSet::new(); 556 + if let Some(params) = params { 557 + let parsed_params = parse_query_string(params); 558 + if let Some(values) = parsed_params.get("action") { 559 + for value in values { 560 + match value.as_ref() { 561 + "create" => { 562 + actions.insert(RepoAction::Create); 563 + } 564 + "update" => { 565 + actions.insert(RepoAction::Update); 566 + } 567 + "delete" => { 568 + actions.insert(RepoAction::Delete); 569 + } 570 + "*" => { 571 + actions.insert(RepoAction::Create); 572 + actions.insert(RepoAction::Update); 573 + actions.insert(RepoAction::Delete); 574 + } 575 + other => return Err(ParseError::InvalidAction(other.to_string())), 576 + } 577 + } 578 + } 579 + } 580 + 581 + if actions.is_empty() { 582 + actions.insert(RepoAction::Create); 583 + actions.insert(RepoAction::Update); 584 + actions.insert(RepoAction::Delete); 585 + } 586 + 587 + Ok(Scope::Repo(RepoScope { 588 + collection, 589 + actions, 590 + })) 591 + } 592 + 593 + fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> { 594 + let mut lxm = BTreeSet::new(); 595 + let mut aud = BTreeSet::new(); 596 + 597 + match suffix { 598 + Some("*") => { 599 + lxm.insert(RpcLexicon::All); 600 + aud.insert(RpcAudience::All); 601 + } 602 + Some(s) if s.starts_with('?') => { 603 + let params = parse_query_string(&s[1..]); 604 + 605 + if let Some(values) = params.get("lxm") { 606 + for value in values { 607 + if value.as_ref() == "*" { 608 + lxm.insert(RpcLexicon::All); 609 + } else { 610 + lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static())); 611 + } 612 + } 613 + } 614 + 615 + if let Some(values) = params.get("aud") { 616 + for value in values { 617 + if value.as_ref() == "*" { 618 + aud.insert(RpcAudience::All); 619 + } else { 620 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 621 + } 622 + } 623 + } 624 + } 625 + Some(s) => { 626 + // Check if there's a query string in the suffix 627 + if let Some(pos) = s.find('?') { 628 + let nsid = &s[..pos]; 629 + let params = parse_query_string(&s[pos + 1..]); 630 + 631 + lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static())); 632 + 633 + if let Some(values) = params.get("aud") { 634 + for value in values { 635 + if value.as_ref() == "*" { 636 + aud.insert(RpcAudience::All); 637 + } else { 638 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 639 + } 640 + } 641 + } 642 + } else { 643 + lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static())); 644 + } 645 + } 646 + None => {} 647 + } 648 + 649 + if lxm.is_empty() { 650 + lxm.insert(RpcLexicon::All); 651 + } 652 + if aud.is_empty() { 653 + aud.insert(RpcAudience::All); 654 + } 655 + 656 + Ok(Scope::Rpc(RpcScope { lxm, aud })) 657 + } 658 + 659 + fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 660 + if suffix.is_some() { 661 + return Err(ParseError::InvalidResource( 662 + "atproto scope does not accept suffixes".to_string(), 663 + )); 664 + } 665 + Ok(Scope::Atproto) 666 + } 667 + 668 + fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 669 + let scope = match suffix { 670 + Some("generic") => TransitionScope::Generic, 671 + Some("email") => TransitionScope::Email, 672 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 673 + None => return Err(ParseError::MissingResource), 674 + }; 675 + 676 + Ok(Scope::Transition(scope)) 677 + } 678 + 679 + fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 680 + if suffix.is_some() { 681 + return Err(ParseError::InvalidResource( 682 + "openid scope does not accept suffixes".to_string(), 683 + )); 684 + } 685 + Ok(Scope::OpenId) 686 + } 687 + 688 + fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> { 689 + if suffix.is_some() { 690 + return Err(ParseError::InvalidResource( 691 + "profile scope does not accept suffixes".to_string(), 692 + )); 693 + } 694 + Ok(Scope::Profile) 695 + } 696 + 697 + fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> { 698 + if suffix.is_some() { 699 + return Err(ParseError::InvalidResource( 700 + "email scope does not accept suffixes".to_string(), 701 + )); 702 + } 703 + Ok(Scope::Email) 704 + } 705 + 706 + /// Convert the scope to its normalized string representation 707 + pub fn to_string_normalized(&self) -> String { 708 + match self { 709 + Scope::Account(scope) => { 710 + let resource = match scope.resource { 711 + AccountResource::Email => "email", 712 + AccountResource::Repo => "repo", 713 + AccountResource::Status => "status", 714 + }; 715 + 716 + match scope.action { 717 + AccountAction::Read => format!("account:{}", resource), 718 + AccountAction::Manage => format!("account:{}?action=manage", resource), 719 + } 720 + } 721 + Scope::Identity(scope) => match scope { 722 + IdentityScope::Handle => "identity:handle".to_string(), 723 + IdentityScope::All => "identity:*".to_string(), 724 + }, 725 + Scope::Blob(scope) => { 726 + if scope.accept.len() == 1 { 727 + if let Some(pattern) = scope.accept.iter().next() { 728 + match pattern { 729 + MimePattern::All => "blob:*/*".to_string(), 730 + MimePattern::TypeWildcard(t) => format!("blob:{}/*", t), 731 + MimePattern::Exact(mime) => format!("blob:{}", mime), 732 + } 733 + } else { 734 + "blob:*/*".to_string() 735 + } 736 + } else { 737 + let mut params = Vec::new(); 738 + for pattern in &scope.accept { 739 + match pattern { 740 + MimePattern::All => params.push("accept=*/*".to_string()), 741 + MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)), 742 + MimePattern::Exact(mime) => params.push(format!("accept={}", mime)), 743 + } 744 + } 745 + params.sort(); 746 + format!("blob?{}", params.join("&")) 747 + } 748 + } 749 + Scope::Repo(scope) => { 750 + let collection = match &scope.collection { 751 + RepoCollection::All => "*", 752 + RepoCollection::Nsid(nsid) => nsid, 753 + }; 754 + 755 + if scope.actions.len() == 3 { 756 + format!("repo:{}", collection) 757 + } else { 758 + let mut params = Vec::new(); 759 + for action in &scope.actions { 760 + match action { 761 + RepoAction::Create => params.push("action=create"), 762 + RepoAction::Update => params.push("action=update"), 763 + RepoAction::Delete => params.push("action=delete"), 764 + } 765 + } 766 + format!("repo:{}?{}", collection, params.join("&")) 767 + } 768 + } 769 + Scope::Rpc(scope) => { 770 + if scope.lxm.len() == 1 771 + && scope.lxm.contains(&RpcLexicon::All) 772 + && scope.aud.len() == 1 773 + && scope.aud.contains(&RpcAudience::All) 774 + { 775 + "rpc:*".to_string() 776 + } else if scope.lxm.len() == 1 777 + && scope.aud.len() == 1 778 + && scope.aud.contains(&RpcAudience::All) 779 + { 780 + if let Some(lxm) = scope.lxm.iter().next() { 781 + match lxm { 782 + RpcLexicon::All => "rpc:*".to_string(), 783 + RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 784 + } 785 + } else { 786 + "rpc:*".to_string() 787 + } 788 + } else { 789 + let mut params = Vec::new(); 790 + 791 + for lxm in &scope.lxm { 792 + match lxm { 793 + RpcLexicon::All => params.push("lxm=*".to_string()), 794 + RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)), 795 + } 796 + } 797 + 798 + for aud in &scope.aud { 799 + match aud { 800 + RpcAudience::All => params.push("aud=*".to_string()), 801 + RpcAudience::Did(did) => params.push(format!("aud={}", did)), 802 + } 803 + } 804 + 805 + params.sort(); 806 + 807 + if params.is_empty() { 808 + "rpc:*".to_string() 809 + } else { 810 + format!("rpc?{}", params.join("&")) 811 + } 812 + } 813 + } 814 + Scope::Atproto => "atproto".to_string(), 815 + Scope::Transition(scope) => match scope { 816 + TransitionScope::Generic => "transition:generic".to_string(), 817 + TransitionScope::Email => "transition:email".to_string(), 818 + }, 819 + Scope::OpenId => "openid".to_string(), 820 + Scope::Profile => "profile".to_string(), 821 + Scope::Email => "email".to_string(), 822 + } 823 + } 824 + 825 + /// Check if this scope grants the permissions of another scope 826 + pub fn grants(&self, other: &Scope) -> bool { 827 + match (self, other) { 828 + // Atproto only grants itself (it's a required scope, not a permission grant) 829 + (Scope::Atproto, Scope::Atproto) => true, 830 + (Scope::Atproto, _) => false, 831 + // Nothing else grants atproto 832 + (_, Scope::Atproto) => false, 833 + // Transition scopes only grant themselves 834 + (Scope::Transition(a), Scope::Transition(b)) => a == b, 835 + // Other scopes don't grant transition scopes 836 + (_, Scope::Transition(_)) => false, 837 + (Scope::Transition(_), _) => false, 838 + // OpenID Connect scopes only grant themselves 839 + (Scope::OpenId, Scope::OpenId) => true, 840 + (Scope::OpenId, _) => false, 841 + (_, Scope::OpenId) => false, 842 + (Scope::Profile, Scope::Profile) => true, 843 + (Scope::Profile, _) => false, 844 + (_, Scope::Profile) => false, 845 + (Scope::Email, Scope::Email) => true, 846 + (Scope::Email, _) => false, 847 + (_, Scope::Email) => false, 848 + (Scope::Account(a), Scope::Account(b)) => { 849 + a.resource == b.resource 850 + && matches!( 851 + (a.action, b.action), 852 + (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read) 853 + ) 854 + } 855 + (Scope::Identity(a), Scope::Identity(b)) => matches!( 856 + (a, b), 857 + (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle) 858 + ), 859 + (Scope::Blob(a), Scope::Blob(b)) => { 860 + for b_pattern in &b.accept { 861 + let mut granted = false; 862 + for a_pattern in &a.accept { 863 + if a_pattern.grants(b_pattern) { 864 + granted = true; 865 + break; 866 + } 867 + } 868 + if !granted { 869 + return false; 870 + } 871 + } 872 + true 873 + } 874 + (Scope::Repo(a), Scope::Repo(b)) => { 875 + let collection_match = match (&a.collection, &b.collection) { 876 + (RepoCollection::All, _) => true, 877 + (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { 878 + a_nsid == b_nsid 879 + } 880 + _ => false, 881 + }; 882 + 883 + if !collection_match { 884 + return false; 885 + } 886 + 887 + b.actions.is_subset(&a.actions) || a.actions.len() == 3 888 + } 889 + (Scope::Rpc(a), Scope::Rpc(b)) => { 890 + let lxm_match = if a.lxm.contains(&RpcLexicon::All) { 891 + true 892 + } else { 893 + b.lxm.iter().all(|b_lxm| match b_lxm { 894 + RpcLexicon::All => false, 895 + RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm), 896 + }) 897 + }; 898 + 899 + let aud_match = if a.aud.contains(&RpcAudience::All) { 900 + true 901 + } else { 902 + b.aud.iter().all(|b_aud| match b_aud { 903 + RpcAudience::All => false, 904 + RpcAudience::Did(_) => a.aud.contains(b_aud), 905 + }) 906 + }; 907 + 908 + lxm_match && aud_match 909 + } 910 + _ => false, 911 + } 912 + } 913 + } 914 + 915 + impl MimePattern<'_> { 916 + fn grants(&self, other: &MimePattern) -> bool { 917 + match (self, other) { 918 + (MimePattern::All, _) => true, 919 + (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { 920 + a_type == b_type 921 + } 922 + (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => { 923 + b_mime.starts_with(&format!("{}/", a_type)) 924 + } 925 + (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b, 926 + _ => false, 927 + } 928 + } 929 + } 930 + 931 + impl FromStr for MimePattern<'_> { 932 + type Err = ParseError; 933 + 934 + fn from_str(s: &str) -> Result<Self, Self::Err> { 935 + if s == "*/*" { 936 + Ok(MimePattern::All) 937 + } else if let Some(stripped) = s.strip_suffix("/*") { 938 + Ok(MimePattern::TypeWildcard(CowStr::Owned( 939 + stripped.to_smolstr(), 940 + ))) 941 + } else if s.contains('/') { 942 + Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr()))) 943 + } else { 944 + Err(ParseError::InvalidMimeType(s.to_string())) 945 + } 946 + } 947 + } 948 + 949 + impl FromStr for Scope<'_> { 950 + type Err = ParseError; 951 + 952 + fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> { 953 + match Scope::parse(s) { 954 + Ok(parsed) => Ok(parsed.into_static()), 955 + Err(e) => Err(e), 956 + } 957 + } 958 + } 959 + 960 + impl fmt::Display for Scope<'_> { 961 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 962 + write!(f, "{}", self.to_string_normalized()) 963 + } 964 + } 965 + 966 + /// Parse a query string into a map of keys to lists of values 967 + fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> { 968 + let mut params = BTreeMap::new(); 969 + 970 + for pair in query.split('&') { 971 + if let Some(pos) = pair.find('=') { 972 + let key = &pair[..pos]; 973 + let value = &pair[pos + 1..]; 974 + params 975 + .entry(key.to_smolstr()) 976 + .or_insert_with(Vec::new) 977 + .push(CowStr::Owned(value.to_smolstr())); 978 + } 979 + } 980 + 981 + params 982 + } 983 + 984 + /// Error type for scope parsing 985 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 986 + pub enum ParseError { 987 + /// Unknown scope prefix 988 + UnknownPrefix(String), 989 + /// Missing required resource 990 + MissingResource, 991 + /// Invalid resource type 992 + InvalidResource(String), 993 + /// Invalid action type 994 + InvalidAction(String), 995 + /// Invalid MIME type 996 + InvalidMimeType(String), 997 + ParseError(#[from] AtStrError), 998 + } 999 + 1000 + impl fmt::Display for ParseError { 1001 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1002 + match self { 1003 + ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), 1004 + ParseError::MissingResource => write!(f, "Missing required resource"), 1005 + ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), 1006 + ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), 1007 + ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), 1008 + ParseError::ParseError(err) => write!(f, "Parse error: {}", err), 1009 + } 1010 + } 1011 + } 1012 + 1013 + #[cfg(test)] 1014 + mod tests { 1015 + use super::*; 1016 + 1017 + #[test] 1018 + fn test_account_scope_parsing() { 1019 + let scope = Scope::parse("account:email").unwrap(); 1020 + assert_eq!( 1021 + scope, 1022 + Scope::Account(AccountScope { 1023 + resource: AccountResource::Email, 1024 + action: AccountAction::Read, 1025 + }) 1026 + ); 1027 + 1028 + let scope = Scope::parse("account:repo?action=manage").unwrap(); 1029 + assert_eq!( 1030 + scope, 1031 + Scope::Account(AccountScope { 1032 + resource: AccountResource::Repo, 1033 + action: AccountAction::Manage, 1034 + }) 1035 + ); 1036 + 1037 + let scope = Scope::parse("account:status?action=read").unwrap(); 1038 + assert_eq!( 1039 + scope, 1040 + Scope::Account(AccountScope { 1041 + resource: AccountResource::Status, 1042 + action: AccountAction::Read, 1043 + }) 1044 + ); 1045 + } 1046 + 1047 + #[test] 1048 + fn test_identity_scope_parsing() { 1049 + let scope = Scope::parse("identity:handle").unwrap(); 1050 + assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); 1051 + 1052 + let scope = Scope::parse("identity:*").unwrap(); 1053 + assert_eq!(scope, Scope::Identity(IdentityScope::All)); 1054 + } 1055 + 1056 + #[test] 1057 + fn test_blob_scope_parsing() { 1058 + let scope = Scope::parse("blob:*/*").unwrap(); 1059 + let mut accept = BTreeSet::new(); 1060 + accept.insert(MimePattern::All); 1061 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1062 + 1063 + let scope = Scope::parse("blob:image/png").unwrap(); 1064 + let mut accept = BTreeSet::new(); 1065 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1066 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1067 + 1068 + let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); 1069 + let mut accept = BTreeSet::new(); 1070 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1071 + accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg"))); 1072 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1073 + 1074 + let scope = Scope::parse("blob:image/*").unwrap(); 1075 + let mut accept = BTreeSet::new(); 1076 + accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image"))); 1077 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1078 + } 1079 + 1080 + #[test] 1081 + fn test_repo_scope_parsing() { 1082 + let scope = Scope::parse("repo:*?action=create").unwrap(); 1083 + let mut actions = BTreeSet::new(); 1084 + actions.insert(RepoAction::Create); 1085 + assert_eq!( 1086 + scope, 1087 + Scope::Repo(RepoScope { 1088 + collection: RepoCollection::All, 1089 + actions, 1090 + }) 1091 + ); 1092 + 1093 + let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(); 1094 + let mut actions = BTreeSet::new(); 1095 + actions.insert(RepoAction::Create); 1096 + actions.insert(RepoAction::Update); 1097 + assert_eq!( 1098 + scope, 1099 + Scope::Repo(RepoScope { 1100 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1101 + actions, 1102 + }) 1103 + ); 1104 + 1105 + let scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1106 + let mut actions = BTreeSet::new(); 1107 + actions.insert(RepoAction::Create); 1108 + actions.insert(RepoAction::Update); 1109 + actions.insert(RepoAction::Delete); 1110 + assert_eq!( 1111 + scope, 1112 + Scope::Repo(RepoScope { 1113 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1114 + actions, 1115 + }) 1116 + ); 1117 + } 1118 + 1119 + #[test] 1120 + fn test_rpc_scope_parsing() { 1121 + let scope = Scope::parse("rpc:*").unwrap(); 1122 + let mut lxm = BTreeSet::new(); 1123 + let mut aud = BTreeSet::new(); 1124 + lxm.insert(RpcLexicon::All); 1125 + aud.insert(RpcAudience::All); 1126 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1127 + 1128 + let scope = Scope::parse("rpc:com.example.service").unwrap(); 1129 + let mut lxm = BTreeSet::new(); 1130 + let mut aud = BTreeSet::new(); 1131 + lxm.insert(RpcLexicon::Nsid( 1132 + Nsid::new_static("com.example.service").unwrap(), 1133 + )); 1134 + aud.insert(RpcAudience::All); 1135 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1136 + 1137 + let scope = 1138 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(); 1139 + let mut lxm = BTreeSet::new(); 1140 + let mut aud = BTreeSet::new(); 1141 + lxm.insert(RpcLexicon::Nsid( 1142 + Nsid::new_static("com.example.service").unwrap(), 1143 + )); 1144 + aud.insert(RpcAudience::Did( 1145 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1146 + )); 1147 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1148 + 1149 + let scope = 1150 + Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g") 1151 + .unwrap(); 1152 + let mut lxm = BTreeSet::new(); 1153 + let mut aud = BTreeSet::new(); 1154 + lxm.insert(RpcLexicon::Nsid( 1155 + Nsid::new_static("com.example.method1").unwrap(), 1156 + )); 1157 + lxm.insert(RpcLexicon::Nsid( 1158 + Nsid::new_static("com.example.method2").unwrap(), 1159 + )); 1160 + aud.insert(RpcAudience::Did( 1161 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1162 + )); 1163 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1164 + } 1165 + 1166 + #[test] 1167 + fn test_scope_normalization() { 1168 + let tests = vec![ 1169 + ("account:email", "account:email"), 1170 + ("account:email?action=read", "account:email"), 1171 + ("account:email?action=manage", "account:email?action=manage"), 1172 + ("blob:image/png", "blob:image/png"), 1173 + ( 1174 + "blob?accept=image/jpeg&accept=image/png", 1175 + "blob?accept=image/jpeg&accept=image/png", 1176 + ), 1177 + ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"), 1178 + ( 1179 + "repo:app.bsky.feed.post?action=create", 1180 + "repo:app.bsky.feed.post?action=create", 1181 + ), 1182 + ("rpc:*", "rpc:*"), 1183 + ]; 1184 + 1185 + for (input, expected) in tests { 1186 + let scope = Scope::parse(input).unwrap(); 1187 + assert_eq!(scope.to_string_normalized(), expected); 1188 + } 1189 + } 1190 + 1191 + #[test] 1192 + fn test_account_scope_grants() { 1193 + let manage = Scope::parse("account:email?action=manage").unwrap(); 1194 + let read = Scope::parse("account:email?action=read").unwrap(); 1195 + let other_read = Scope::parse("account:repo?action=read").unwrap(); 1196 + 1197 + assert!(manage.grants(&read)); 1198 + assert!(manage.grants(&manage)); 1199 + assert!(!read.grants(&manage)); 1200 + assert!(read.grants(&read)); 1201 + assert!(!read.grants(&other_read)); 1202 + } 1203 + 1204 + #[test] 1205 + fn test_identity_scope_grants() { 1206 + let all = Scope::parse("identity:*").unwrap(); 1207 + let handle = Scope::parse("identity:handle").unwrap(); 1208 + 1209 + assert!(all.grants(&handle)); 1210 + assert!(all.grants(&all)); 1211 + assert!(!handle.grants(&all)); 1212 + assert!(handle.grants(&handle)); 1213 + } 1214 + 1215 + #[test] 1216 + fn test_blob_scope_grants() { 1217 + let all = Scope::parse("blob:*/*").unwrap(); 1218 + let image_all = Scope::parse("blob:image/*").unwrap(); 1219 + let image_png = Scope::parse("blob:image/png").unwrap(); 1220 + let text_plain = Scope::parse("blob:text/plain").unwrap(); 1221 + 1222 + assert!(all.grants(&image_all)); 1223 + assert!(all.grants(&image_png)); 1224 + assert!(all.grants(&text_plain)); 1225 + assert!(image_all.grants(&image_png)); 1226 + assert!(!image_all.grants(&text_plain)); 1227 + assert!(!image_png.grants(&image_all)); 1228 + } 1229 + 1230 + #[test] 1231 + fn test_repo_scope_grants() { 1232 + let all_all = Scope::parse("repo:*").unwrap(); 1233 + let all_create = Scope::parse("repo:*?action=create").unwrap(); 1234 + let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1235 + let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap(); 1236 + let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap(); 1237 + 1238 + assert!(all_all.grants(&all_create)); 1239 + assert!(all_all.grants(&specific_all)); 1240 + assert!(all_all.grants(&specific_create)); 1241 + assert!(all_create.grants(&all_create)); 1242 + assert!(!all_create.grants(&specific_all)); 1243 + assert!(specific_all.grants(&specific_create)); 1244 + assert!(!specific_create.grants(&specific_all)); 1245 + assert!(!specific_create.grants(&other_create)); 1246 + } 1247 + 1248 + #[test] 1249 + fn test_rpc_scope_grants() { 1250 + let all = Scope::parse("rpc:*").unwrap(); 1251 + let specific_lxm = Scope::parse("rpc:com.example.service").unwrap(); 1252 + let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 1253 + 1254 + assert!(all.grants(&specific_lxm)); 1255 + assert!(all.grants(&specific_both)); 1256 + assert!(specific_lxm.grants(&specific_both)); 1257 + assert!(!specific_both.grants(&specific_lxm)); 1258 + assert!(!specific_both.grants(&all)); 1259 + } 1260 + 1261 + #[test] 1262 + fn test_cross_scope_grants() { 1263 + let account = Scope::parse("account:email").unwrap(); 1264 + let identity = Scope::parse("identity:handle").unwrap(); 1265 + 1266 + assert!(!account.grants(&identity)); 1267 + assert!(!identity.grants(&account)); 1268 + } 1269 + 1270 + #[test] 1271 + fn test_parse_errors() { 1272 + assert!(matches!( 1273 + Scope::parse("unknown:test"), 1274 + Err(ParseError::UnknownPrefix(_)) 1275 + )); 1276 + 1277 + assert!(matches!( 1278 + Scope::parse("account"), 1279 + Err(ParseError::MissingResource) 1280 + )); 1281 + 1282 + assert!(matches!( 1283 + Scope::parse("account:invalid"), 1284 + Err(ParseError::InvalidResource(_)) 1285 + )); 1286 + 1287 + assert!(matches!( 1288 + Scope::parse("account:email?action=invalid"), 1289 + Err(ParseError::InvalidAction(_)) 1290 + )); 1291 + } 1292 + 1293 + #[test] 1294 + fn test_query_parameter_sorting() { 1295 + let scope = 1296 + Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap(); 1297 + let normalized = scope.to_string_normalized(); 1298 + assert!(normalized.contains("accept=application/pdf")); 1299 + assert!(normalized.contains("accept=image/jpeg")); 1300 + assert!(normalized.contains("accept=image/png")); 1301 + let pdf_pos = normalized.find("accept=application/pdf").unwrap(); 1302 + let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); 1303 + let png_pos = normalized.find("accept=image/png").unwrap(); 1304 + assert!(pdf_pos < jpeg_pos); 1305 + assert!(jpeg_pos < png_pos); 1306 + } 1307 + 1308 + #[test] 1309 + fn test_repo_action_wildcard() { 1310 + let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap(); 1311 + let mut actions = BTreeSet::new(); 1312 + actions.insert(RepoAction::Create); 1313 + actions.insert(RepoAction::Update); 1314 + actions.insert(RepoAction::Delete); 1315 + assert_eq!( 1316 + scope, 1317 + Scope::Repo(RepoScope { 1318 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1319 + actions, 1320 + }) 1321 + ); 1322 + } 1323 + 1324 + #[test] 1325 + fn test_multiple_blob_accepts() { 1326 + let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap(); 1327 + assert!(scope.grants(&Scope::parse("blob:image/png").unwrap())); 1328 + assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap())); 1329 + assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap())); 1330 + } 1331 + 1332 + #[test] 1333 + fn test_rpc_default_wildcards() { 1334 + let scope = Scope::parse("rpc").unwrap(); 1335 + let mut lxm = BTreeSet::new(); 1336 + let mut aud = BTreeSet::new(); 1337 + lxm.insert(RpcLexicon::All); 1338 + aud.insert(RpcAudience::All); 1339 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1340 + } 1341 + 1342 + #[test] 1343 + fn test_atproto_scope_parsing() { 1344 + let scope = Scope::parse("atproto").unwrap(); 1345 + assert_eq!(scope, Scope::Atproto); 1346 + 1347 + // Atproto should not accept suffixes 1348 + assert!(Scope::parse("atproto:something").is_err()); 1349 + assert!(Scope::parse("atproto?param=value").is_err()); 1350 + } 1351 + 1352 + #[test] 1353 + fn test_transition_scope_parsing() { 1354 + let scope = Scope::parse("transition:generic").unwrap(); 1355 + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 1356 + 1357 + let scope = Scope::parse("transition:email").unwrap(); 1358 + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 1359 + 1360 + // Test invalid transition types 1361 + assert!(matches!( 1362 + Scope::parse("transition:invalid"), 1363 + Err(ParseError::InvalidResource(_)) 1364 + )); 1365 + 1366 + // Test missing suffix 1367 + assert!(matches!( 1368 + Scope::parse("transition"), 1369 + Err(ParseError::MissingResource) 1370 + )); 1371 + 1372 + // Test transition doesn't accept query parameters 1373 + assert!(matches!( 1374 + Scope::parse("transition:generic?param=value"), 1375 + Err(ParseError::InvalidResource(_)) 1376 + )); 1377 + } 1378 + 1379 + #[test] 1380 + fn test_atproto_scope_normalization() { 1381 + let scope = Scope::parse("atproto").unwrap(); 1382 + assert_eq!(scope.to_string_normalized(), "atproto"); 1383 + } 1384 + 1385 + #[test] 1386 + fn test_transition_scope_normalization() { 1387 + let tests = vec![ 1388 + ("transition:generic", "transition:generic"), 1389 + ("transition:email", "transition:email"), 1390 + ]; 1391 + 1392 + for (input, expected) in tests { 1393 + let scope = Scope::parse(input).unwrap(); 1394 + assert_eq!(scope.to_string_normalized(), expected); 1395 + } 1396 + } 1397 + 1398 + #[test] 1399 + fn test_atproto_scope_grants() { 1400 + let atproto = Scope::parse("atproto").unwrap(); 1401 + let account = Scope::parse("account:email").unwrap(); 1402 + let identity = Scope::parse("identity:handle").unwrap(); 1403 + let blob = Scope::parse("blob:image/png").unwrap(); 1404 + let repo = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1405 + let rpc = Scope::parse("rpc:com.example.service").unwrap(); 1406 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1407 + let transition_email = Scope::parse("transition:email").unwrap(); 1408 + 1409 + // Atproto only grants itself (it's a required scope, not a permission grant) 1410 + assert!(atproto.grants(&atproto)); 1411 + assert!(!atproto.grants(&account)); 1412 + assert!(!atproto.grants(&identity)); 1413 + assert!(!atproto.grants(&blob)); 1414 + assert!(!atproto.grants(&repo)); 1415 + assert!(!atproto.grants(&rpc)); 1416 + assert!(!atproto.grants(&transition_generic)); 1417 + assert!(!atproto.grants(&transition_email)); 1418 + 1419 + // Nothing else grants atproto 1420 + assert!(!account.grants(&atproto)); 1421 + assert!(!identity.grants(&atproto)); 1422 + assert!(!blob.grants(&atproto)); 1423 + assert!(!repo.grants(&atproto)); 1424 + assert!(!rpc.grants(&atproto)); 1425 + assert!(!transition_generic.grants(&atproto)); 1426 + assert!(!transition_email.grants(&atproto)); 1427 + } 1428 + 1429 + #[test] 1430 + fn test_transition_scope_grants() { 1431 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1432 + let transition_email = Scope::parse("transition:email").unwrap(); 1433 + let account = Scope::parse("account:email").unwrap(); 1434 + 1435 + // Transition scopes only grant themselves 1436 + assert!(transition_generic.grants(&transition_generic)); 1437 + assert!(transition_email.grants(&transition_email)); 1438 + assert!(!transition_generic.grants(&transition_email)); 1439 + assert!(!transition_email.grants(&transition_generic)); 1440 + 1441 + // Transition scopes don't grant other scope types 1442 + assert!(!transition_generic.grants(&account)); 1443 + assert!(!transition_email.grants(&account)); 1444 + 1445 + // Other scopes don't grant transition scopes 1446 + assert!(!account.grants(&transition_generic)); 1447 + assert!(!account.grants(&transition_email)); 1448 + } 1449 + 1450 + #[test] 1451 + fn test_parse_multiple() { 1452 + // Test parsing multiple scopes 1453 + let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 1454 + assert_eq!(scopes.len(), 2); 1455 + assert_eq!(scopes[0], Scope::Atproto); 1456 + assert_eq!( 1457 + scopes[1], 1458 + Scope::Repo(RepoScope { 1459 + collection: RepoCollection::All, 1460 + actions: { 1461 + let mut actions = BTreeSet::new(); 1462 + actions.insert(RepoAction::Create); 1463 + actions.insert(RepoAction::Update); 1464 + actions.insert(RepoAction::Delete); 1465 + actions 1466 + } 1467 + }) 1468 + ); 1469 + 1470 + // Test with more scopes 1471 + let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap(); 1472 + assert_eq!(scopes.len(), 3); 1473 + assert!(matches!(scopes[0], Scope::Account(_))); 1474 + assert!(matches!(scopes[1], Scope::Identity(_))); 1475 + assert!(matches!(scopes[2], Scope::Blob(_))); 1476 + 1477 + // Test with complex scopes 1478 + let scopes = Scope::parse_multiple( 1479 + "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email", 1480 + ) 1481 + .unwrap(); 1482 + assert_eq!(scopes.len(), 3); 1483 + 1484 + // Test empty string 1485 + let scopes = Scope::parse_multiple("").unwrap(); 1486 + assert_eq!(scopes.len(), 0); 1487 + 1488 + // Test whitespace only 1489 + let scopes = Scope::parse_multiple(" ").unwrap(); 1490 + assert_eq!(scopes.len(), 0); 1491 + 1492 + // Test with extra whitespace 1493 + let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap(); 1494 + assert_eq!(scopes.len(), 2); 1495 + 1496 + // Test single scope 1497 + let scopes = Scope::parse_multiple("atproto").unwrap(); 1498 + assert_eq!(scopes.len(), 1); 1499 + assert_eq!(scopes[0], Scope::Atproto); 1500 + 1501 + // Test error propagation 1502 + assert!(Scope::parse_multiple("atproto invalid:scope").is_err()); 1503 + assert!(Scope::parse_multiple("account:invalid repo:*").is_err()); 1504 + } 1505 + 1506 + #[test] 1507 + fn test_parse_multiple_reduced() { 1508 + // Test repo scope reduction - wildcard grants specific 1509 + let scopes = 1510 + Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 1511 + assert_eq!(scopes.len(), 2); 1512 + assert!(scopes.contains(&Scope::Atproto)); 1513 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1514 + collection: RepoCollection::All, 1515 + actions: { 1516 + let mut actions = BTreeSet::new(); 1517 + actions.insert(RepoAction::Create); 1518 + actions.insert(RepoAction::Update); 1519 + actions.insert(RepoAction::Delete); 1520 + actions 1521 + } 1522 + }))); 1523 + 1524 + // Test reverse order - should get same result 1525 + let scopes = 1526 + Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap(); 1527 + assert_eq!(scopes.len(), 2); 1528 + assert!(scopes.contains(&Scope::Atproto)); 1529 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1530 + collection: RepoCollection::All, 1531 + actions: { 1532 + let mut actions = BTreeSet::new(); 1533 + actions.insert(RepoAction::Create); 1534 + actions.insert(RepoAction::Update); 1535 + actions.insert(RepoAction::Delete); 1536 + actions 1537 + } 1538 + }))); 1539 + 1540 + // Test account scope reduction - manage grants read 1541 + let scopes = 1542 + Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap(); 1543 + assert_eq!(scopes.len(), 1); 1544 + assert_eq!( 1545 + scopes[0], 1546 + Scope::Account(AccountScope { 1547 + resource: AccountResource::Email, 1548 + action: AccountAction::Manage, 1549 + }) 1550 + ); 1551 + 1552 + // Test identity scope reduction - wildcard grants specific 1553 + let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap(); 1554 + assert_eq!(scopes.len(), 1); 1555 + assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); 1556 + 1557 + // Test blob scope reduction - wildcard grants specific 1558 + let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap(); 1559 + assert_eq!(scopes.len(), 1); 1560 + let mut accept = BTreeSet::new(); 1561 + accept.insert(MimePattern::All); 1562 + assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); 1563 + 1564 + // Test no reduction needed - different scope types 1565 + let scopes = 1566 + Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap(); 1567 + assert_eq!(scopes.len(), 3); 1568 + 1569 + // Test repo action reduction 1570 + let scopes = Scope::parse_multiple_reduced( 1571 + "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post", 1572 + ) 1573 + .unwrap(); 1574 + assert_eq!(scopes.len(), 1); 1575 + assert_eq!( 1576 + scopes[0], 1577 + Scope::Repo(RepoScope { 1578 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1579 + actions: { 1580 + let mut actions = BTreeSet::new(); 1581 + actions.insert(RepoAction::Create); 1582 + actions.insert(RepoAction::Update); 1583 + actions.insert(RepoAction::Delete); 1584 + actions 1585 + } 1586 + }) 1587 + ); 1588 + 1589 + // Test RPC scope reduction 1590 + let scopes = Scope::parse_multiple_reduced( 1591 + "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", 1592 + ) 1593 + .unwrap(); 1594 + assert_eq!(scopes.len(), 1); 1595 + assert_eq!( 1596 + scopes[0], 1597 + Scope::Rpc(RpcScope { 1598 + lxm: { 1599 + let mut lxm = BTreeSet::new(); 1600 + lxm.insert(RpcLexicon::All); 1601 + lxm 1602 + }, 1603 + aud: { 1604 + let mut aud = BTreeSet::new(); 1605 + aud.insert(RpcAudience::All); 1606 + aud 1607 + } 1608 + }) 1609 + ); 1610 + 1611 + // Test duplicate removal 1612 + let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap(); 1613 + assert_eq!(scopes.len(), 1); 1614 + assert_eq!(scopes[0], Scope::Atproto); 1615 + 1616 + // Test transition scopes - only grant themselves 1617 + let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap(); 1618 + assert_eq!(scopes.len(), 2); 1619 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); 1620 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); 1621 + 1622 + // Test empty input 1623 + let scopes = Scope::parse_multiple_reduced("").unwrap(); 1624 + assert_eq!(scopes.len(), 0); 1625 + 1626 + // Test complex scenario with multiple reductions 1627 + let scopes = Scope::parse_multiple_reduced( 1628 + "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" 1629 + ).unwrap(); 1630 + assert_eq!(scopes.len(), 3); 1631 + // Should have: account:email?action=manage, account:repo, identity:* 1632 + assert!(scopes.contains(&Scope::Account(AccountScope { 1633 + resource: AccountResource::Email, 1634 + action: AccountAction::Manage, 1635 + }))); 1636 + assert!(scopes.contains(&Scope::Account(AccountScope { 1637 + resource: AccountResource::Repo, 1638 + action: AccountAction::Read, 1639 + }))); 1640 + assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); 1641 + 1642 + // Test that atproto doesn't grant other scopes (per recent change) 1643 + let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap(); 1644 + assert_eq!(scopes.len(), 3); 1645 + assert!(scopes.contains(&Scope::Atproto)); 1646 + assert!(scopes.contains(&Scope::Account(AccountScope { 1647 + resource: AccountResource::Email, 1648 + action: AccountAction::Read, 1649 + }))); 1650 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1651 + collection: RepoCollection::All, 1652 + actions: { 1653 + let mut actions = BTreeSet::new(); 1654 + actions.insert(RepoAction::Create); 1655 + actions.insert(RepoAction::Update); 1656 + actions.insert(RepoAction::Delete); 1657 + actions 1658 + } 1659 + }))); 1660 + } 1661 + 1662 + #[test] 1663 + fn test_openid_connect_scope_parsing() { 1664 + // Test OpenID scope 1665 + let scope = Scope::parse("openid").unwrap(); 1666 + assert_eq!(scope, Scope::OpenId); 1667 + 1668 + // Test Profile scope 1669 + let scope = Scope::parse("profile").unwrap(); 1670 + assert_eq!(scope, Scope::Profile); 1671 + 1672 + // Test Email scope 1673 + let scope = Scope::parse("email").unwrap(); 1674 + assert_eq!(scope, Scope::Email); 1675 + 1676 + // Test that they don't accept suffixes 1677 + assert!(Scope::parse("openid:something").is_err()); 1678 + assert!(Scope::parse("profile:something").is_err()); 1679 + assert!(Scope::parse("email:something").is_err()); 1680 + 1681 + // Test that they don't accept query parameters 1682 + assert!(Scope::parse("openid?param=value").is_err()); 1683 + assert!(Scope::parse("profile?param=value").is_err()); 1684 + assert!(Scope::parse("email?param=value").is_err()); 1685 + } 1686 + 1687 + #[test] 1688 + fn test_openid_connect_scope_normalization() { 1689 + let scope = Scope::parse("openid").unwrap(); 1690 + assert_eq!(scope.to_string_normalized(), "openid"); 1691 + 1692 + let scope = Scope::parse("profile").unwrap(); 1693 + assert_eq!(scope.to_string_normalized(), "profile"); 1694 + 1695 + let scope = Scope::parse("email").unwrap(); 1696 + assert_eq!(scope.to_string_normalized(), "email"); 1697 + } 1698 + 1699 + #[test] 1700 + fn test_openid_connect_scope_grants() { 1701 + let openid = Scope::parse("openid").unwrap(); 1702 + let profile = Scope::parse("profile").unwrap(); 1703 + let email = Scope::parse("email").unwrap(); 1704 + let account = Scope::parse("account:email").unwrap(); 1705 + 1706 + // OpenID Connect scopes only grant themselves 1707 + assert!(openid.grants(&openid)); 1708 + assert!(!openid.grants(&profile)); 1709 + assert!(!openid.grants(&email)); 1710 + assert!(!openid.grants(&account)); 1711 + 1712 + assert!(profile.grants(&profile)); 1713 + assert!(!profile.grants(&openid)); 1714 + assert!(!profile.grants(&email)); 1715 + assert!(!profile.grants(&account)); 1716 + 1717 + assert!(email.grants(&email)); 1718 + assert!(!email.grants(&openid)); 1719 + assert!(!email.grants(&profile)); 1720 + assert!(!email.grants(&account)); 1721 + 1722 + // Other scopes don't grant OpenID Connect scopes 1723 + assert!(!account.grants(&openid)); 1724 + assert!(!account.grants(&profile)); 1725 + assert!(!account.grants(&email)); 1726 + } 1727 + 1728 + #[test] 1729 + fn test_parse_multiple_with_openid_connect() { 1730 + let scopes = Scope::parse_multiple("openid profile email atproto").unwrap(); 1731 + assert_eq!(scopes.len(), 4); 1732 + assert_eq!(scopes[0], Scope::OpenId); 1733 + assert_eq!(scopes[1], Scope::Profile); 1734 + assert_eq!(scopes[2], Scope::Email); 1735 + assert_eq!(scopes[3], Scope::Atproto); 1736 + 1737 + // Test with mixed scopes 1738 + let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap(); 1739 + assert_eq!(scopes.len(), 4); 1740 + assert!(scopes.contains(&Scope::OpenId)); 1741 + assert!(scopes.contains(&Scope::Profile)); 1742 + } 1743 + 1744 + #[test] 1745 + fn test_parse_multiple_reduced_with_openid_connect() { 1746 + // OpenID Connect scopes don't grant each other, so no reduction 1747 + let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap(); 1748 + assert_eq!(scopes.len(), 3); 1749 + assert!(scopes.contains(&Scope::OpenId)); 1750 + assert!(scopes.contains(&Scope::Profile)); 1751 + assert!(scopes.contains(&Scope::Email)); 1752 + 1753 + // Mixed with other scopes 1754 + let scopes = Scope::parse_multiple_reduced( 1755 + "openid account:email account:email?action=manage profile", 1756 + ) 1757 + .unwrap(); 1758 + assert_eq!(scopes.len(), 3); 1759 + assert!(scopes.contains(&Scope::OpenId)); 1760 + assert!(scopes.contains(&Scope::Profile)); 1761 + assert!(scopes.contains(&Scope::Account(AccountScope { 1762 + resource: AccountResource::Email, 1763 + action: AccountAction::Manage, 1764 + }))); 1765 + } 1766 + 1767 + #[test] 1768 + fn test_serialize_multiple() { 1769 + // Test empty list 1770 + let scopes: Vec<Scope> = vec![]; 1771 + assert_eq!(Scope::serialize_multiple(&scopes), ""); 1772 + 1773 + // Test single scope 1774 + let scopes = vec![Scope::Atproto]; 1775 + assert_eq!(Scope::serialize_multiple(&scopes), "atproto"); 1776 + 1777 + // Test multiple scopes - should be sorted alphabetically 1778 + let scopes = vec![ 1779 + Scope::parse("repo:*").unwrap(), 1780 + Scope::Atproto, 1781 + Scope::parse("account:email").unwrap(), 1782 + ]; 1783 + assert_eq!( 1784 + Scope::serialize_multiple(&scopes), 1785 + "account:email atproto repo:*" 1786 + ); 1787 + 1788 + // Test that sorting is consistent regardless of input order 1789 + let scopes = vec![ 1790 + Scope::parse("identity:handle").unwrap(), 1791 + Scope::parse("blob:image/png").unwrap(), 1792 + Scope::parse("account:repo?action=manage").unwrap(), 1793 + ]; 1794 + assert_eq!( 1795 + Scope::serialize_multiple(&scopes), 1796 + "account:repo?action=manage blob:image/png identity:handle" 1797 + ); 1798 + 1799 + // Test with OpenID Connect scopes 1800 + let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; 1801 + assert_eq!( 1802 + Scope::serialize_multiple(&scopes), 1803 + "atproto email openid profile" 1804 + ); 1805 + 1806 + // Test with complex scopes including query parameters 1807 + let scopes = vec![ 1808 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method") 1809 + .unwrap(), 1810 + Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(), 1811 + Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1812 + ]; 1813 + let result = Scope::serialize_multiple(&scopes); 1814 + // The result should be sorted alphabetically 1815 + // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1816 + assert!(result.starts_with("blob:")); 1817 + assert!(result.contains(" repo:")); 1818 + assert!( 1819 + result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service") 1820 + ); 1821 + 1822 + // Test with transition scopes 1823 + let scopes = vec![ 1824 + Scope::Transition(TransitionScope::Email), 1825 + Scope::Transition(TransitionScope::Generic), 1826 + Scope::Atproto, 1827 + ]; 1828 + assert_eq!( 1829 + Scope::serialize_multiple(&scopes), 1830 + "atproto transition:email transition:generic" 1831 + ); 1832 + 1833 + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) 1834 + let scopes = vec![ 1835 + Scope::Atproto, 1836 + Scope::Atproto, 1837 + Scope::parse("account:email").unwrap(), 1838 + ]; 1839 + assert_eq!( 1840 + Scope::serialize_multiple(&scopes), 1841 + "account:email atproto atproto" 1842 + ); 1843 + 1844 + // Test normalization is preserved in serialization 1845 + let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; 1846 + // Should normalize query parameters alphabetically 1847 + assert_eq!( 1848 + Scope::serialize_multiple(&scopes), 1849 + "blob?accept=image/jpeg&accept=image/png" 1850 + ); 1851 + } 1852 + 1853 + #[test] 1854 + fn test_serialize_multiple_roundtrip() { 1855 + // Test that parse_multiple and serialize_multiple are inverses (when sorted) 1856 + let original = "account:email atproto blob:image/png identity:handle repo:*"; 1857 + let scopes = Scope::parse_multiple(original).unwrap(); 1858 + let serialized = Scope::serialize_multiple(&scopes); 1859 + assert_eq!(serialized, original); 1860 + 1861 + // Test with complex scopes 1862 + let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; 1863 + let scopes = Scope::parse_multiple(original).unwrap(); 1864 + let serialized = Scope::serialize_multiple(&scopes); 1865 + // Parse again to verify it's valid 1866 + let reparsed = Scope::parse_multiple(&serialized).unwrap(); 1867 + assert_eq!(scopes, reparsed); 1868 + 1869 + // Test with OpenID Connect scopes 1870 + let original = "email openid profile"; 1871 + let scopes = Scope::parse_multiple(original).unwrap(); 1872 + let serialized = Scope::serialize_multiple(&scopes); 1873 + assert_eq!(serialized, original); 1874 + } 1875 + 1876 + #[test] 1877 + fn test_remove_scope() { 1878 + // Test removing a scope that exists 1879 + let scopes = vec![ 1880 + Scope::parse("repo:*").unwrap(), 1881 + Scope::Atproto, 1882 + Scope::parse("account:email").unwrap(), 1883 + ]; 1884 + let to_remove = Scope::Atproto; 1885 + let result = Scope::remove_scope(&scopes, &to_remove); 1886 + assert_eq!(result.len(), 2); 1887 + assert!(!result.contains(&to_remove)); 1888 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1889 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1890 + 1891 + // Test removing a scope that doesn't exist 1892 + let scopes = vec![ 1893 + Scope::parse("repo:*").unwrap(), 1894 + Scope::parse("account:email").unwrap(), 1895 + ]; 1896 + let to_remove = Scope::parse("identity:handle").unwrap(); 1897 + let result = Scope::remove_scope(&scopes, &to_remove); 1898 + assert_eq!(result.len(), 2); 1899 + assert_eq!(result, scopes); 1900 + 1901 + // Test removing from empty list 1902 + let scopes: Vec<Scope> = vec![]; 1903 + let to_remove = Scope::Atproto; 1904 + let result = Scope::remove_scope(&scopes, &to_remove); 1905 + assert_eq!(result.len(), 0); 1906 + 1907 + // Test removing all instances of a duplicate scope 1908 + let scopes = vec![ 1909 + Scope::Atproto, 1910 + Scope::parse("account:email").unwrap(), 1911 + Scope::Atproto, 1912 + Scope::parse("repo:*").unwrap(), 1913 + Scope::Atproto, 1914 + ]; 1915 + let to_remove = Scope::Atproto; 1916 + let result = Scope::remove_scope(&scopes, &to_remove); 1917 + assert_eq!(result.len(), 2); 1918 + assert!(!result.contains(&to_remove)); 1919 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1920 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1921 + 1922 + // Test removing complex scopes with query parameters 1923 + let scopes = vec![ 1924 + Scope::parse("account:email?action=manage").unwrap(), 1925 + Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), 1926 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1927 + ]; 1928 + let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order 1929 + let result = Scope::remove_scope(&scopes, &to_remove); 1930 + assert_eq!(result.len(), 2); 1931 + assert!(!result.contains(&to_remove)); 1932 + 1933 + // Test with OpenID Connect scopes 1934 + let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; 1935 + let to_remove = Scope::Profile; 1936 + let result = Scope::remove_scope(&scopes, &to_remove); 1937 + assert_eq!(result.len(), 3); 1938 + assert!(!result.contains(&to_remove)); 1939 + assert!(result.contains(&Scope::OpenId)); 1940 + assert!(result.contains(&Scope::Email)); 1941 + assert!(result.contains(&Scope::Atproto)); 1942 + 1943 + // Test with transition scopes 1944 + let scopes = vec![ 1945 + Scope::Transition(TransitionScope::Generic), 1946 + Scope::Transition(TransitionScope::Email), 1947 + Scope::Atproto, 1948 + ]; 1949 + let to_remove = Scope::Transition(TransitionScope::Email); 1950 + let result = Scope::remove_scope(&scopes, &to_remove); 1951 + assert_eq!(result.len(), 2); 1952 + assert!(!result.contains(&to_remove)); 1953 + assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); 1954 + assert!(result.contains(&Scope::Atproto)); 1955 + 1956 + // Test that only exact matches are removed 1957 + let scopes = vec![ 1958 + Scope::parse("account:email").unwrap(), 1959 + Scope::parse("account:email?action=manage").unwrap(), 1960 + Scope::parse("account:repo").unwrap(), 1961 + ]; 1962 + let to_remove = Scope::parse("account:email").unwrap(); 1963 + let result = Scope::remove_scope(&scopes, &to_remove); 1964 + assert_eq!(result.len(), 2); 1965 + assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1966 + assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1967 + assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1968 + } 1969 + }
+23
crates/jacquard-oauth/src/session.rs
···
··· 1 + use crate::types::TokenSet; 2 + 3 + use jacquard_common::IntoStatic; 4 + use jose_jwk::Key; 5 + use serde::{Deserialize, Serialize}; 6 + 7 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 8 + pub struct OauthSession<'s> { 9 + pub dpop_key: Key, 10 + #[serde(borrow)] 11 + pub token_set: TokenSet<'s>, 12 + } 13 + 14 + impl IntoStatic for OauthSession<'_> { 15 + type Output = OauthSession<'static>; 16 + 17 + fn into_static(self) -> Self::Output { 18 + OauthSession { 19 + dpop_key: self.dpop_key, 20 + token_set: self.token_set.into_static(), 21 + } 22 + } 23 + }
+61
crates/jacquard-oauth/src/types.rs
···
··· 1 + mod client_metadata; 2 + mod metadata; 3 + mod request; 4 + mod response; 5 + mod token; 6 + 7 + use crate::scopes::Scope; 8 + 9 + pub use self::client_metadata::*; 10 + pub use self::metadata::*; 11 + pub use self::request::*; 12 + pub use self::response::*; 13 + pub use self::token::*; 14 + use jacquard_common::CowStr; 15 + use serde::Deserialize; 16 + 17 + #[derive(Debug, Deserialize)] 18 + pub enum AuthorizeOptionPrompt { 19 + Login, 20 + None, 21 + Consent, 22 + SelectAccount, 23 + } 24 + 25 + impl From<AuthorizeOptionPrompt> for CowStr<'static> { 26 + fn from(value: AuthorizeOptionPrompt) -> Self { 27 + match value { 28 + AuthorizeOptionPrompt::Login => CowStr::new_static("login"), 29 + AuthorizeOptionPrompt::None => CowStr::new_static("none"), 30 + AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"), 31 + AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"), 32 + } 33 + } 34 + } 35 + 36 + #[derive(Debug)] 37 + pub struct AuthorizeOptions<'s> { 38 + pub redirect_uri: Option<CowStr<'s>>, 39 + pub scopes: Vec<Scope<'s>>, 40 + pub prompt: Option<AuthorizeOptionPrompt>, 41 + pub state: Option<CowStr<'s>>, 42 + } 43 + 44 + impl Default for AuthorizeOptions<'_> { 45 + fn default() -> Self { 46 + Self { 47 + redirect_uri: None, 48 + scopes: vec![Scope::Atproto], 49 + prompt: None, 50 + state: None, 51 + } 52 + } 53 + } 54 + 55 + #[derive(Debug, Deserialize)] 56 + pub struct CallbackParams<'s> { 57 + #[serde(borrow)] 58 + pub code: CowStr<'s>, 59 + pub state: Option<CowStr<'s>>, 60 + pub iss: Option<CowStr<'s>>, 61 + }
+55
crates/jacquard-oauth/src/types/client_metadata.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use jose_jwk::JwkSet; 3 + use serde::{Deserialize, Serialize}; 4 + use url::Url; 5 + 6 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 7 + pub struct OAuthClientMetadata<'c> { 8 + pub client_id: Url, 9 + #[serde(skip_serializing_if = "Option::is_none")] 10 + pub client_uri: Option<Url>, 11 + pub redirect_uris: Vec<Url>, 12 + #[serde(skip_serializing_if = "Option::is_none")] 13 + #[serde(borrow)] 14 + pub scope: Option<CowStr<'c>>, 15 + #[serde(skip_serializing_if = "Option::is_none")] 16 + pub grant_types: Option<Vec<CowStr<'c>>>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + pub token_endpoint_auth_method: Option<CowStr<'c>>, 19 + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub dpop_bound_access_tokens: Option<bool>, 22 + // https://datatracker.ietf.org/doc/html/rfc7591#section-2 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub jwks_uri: Option<Url>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub jwks: Option<JwkSet>, 27 + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub token_endpoint_auth_signing_alg: Option<CowStr<'c>>, 30 + } 31 + 32 + impl OAuthClientMetadata<'_> {} 33 + 34 + impl IntoStatic for OAuthClientMetadata<'_> { 35 + type Output = OAuthClientMetadata<'static>; 36 + 37 + fn into_static(self) -> Self::Output { 38 + OAuthClientMetadata { 39 + client_id: self.client_id, 40 + client_uri: self.client_uri, 41 + redirect_uris: self.redirect_uris, 42 + scope: self.scope.map(|scope| scope.into_static()), 43 + grant_types: self.grant_types.map(|types| types.into_static()), 44 + token_endpoint_auth_method: self 45 + .token_endpoint_auth_method 46 + .map(|method| method.into_static()), 47 + dpop_bound_access_tokens: self.dpop_bound_access_tokens, 48 + jwks_uri: self.jwks_uri, 49 + jwks: self.jwks, 50 + token_endpoint_auth_signing_alg: self 51 + .token_endpoint_auth_signing_alg 52 + .map(|alg| alg.into_static()), 53 + } 54 + } 55 + }
+144
crates/jacquard-oauth/src/types/metadata.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic, types::string::Language}; 2 + use serde::{Deserialize, Serialize}; 3 + use url::Url; 4 + 5 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] 6 + pub struct OAuthAuthorizationServerMetadata<'s> { 7 + // https://datatracker.ietf.org/doc/html/rfc8414#section-2 8 + #[serde(borrow)] 9 + pub issuer: CowStr<'s>, 10 + pub authorization_endpoint: CowStr<'s>, // optional? 11 + pub token_endpoint: CowStr<'s>, // optional? 12 + pub jwks_uri: Option<CowStr<'s>>, 13 + pub registration_endpoint: Option<CowStr<'s>>, 14 + pub scopes_supported: Vec<CowStr<'s>>, 15 + pub response_types_supported: Vec<CowStr<'s>>, 16 + pub response_modes_supported: Option<Vec<CowStr<'s>>>, 17 + pub grant_types_supported: Option<Vec<CowStr<'s>>>, 18 + pub token_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 19 + pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 20 + pub service_documentation: Option<CowStr<'s>>, 21 + pub ui_locales_supported: Option<Vec<Language>>, 22 + pub op_policy_uri: Option<CowStr<'s>>, 23 + pub op_tos_uri: Option<CowStr<'s>>, 24 + pub revocation_endpoint: Option<CowStr<'s>>, 25 + pub revocation_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 26 + pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 27 + pub introspection_endpoint: Option<CowStr<'s>>, 28 + pub introspection_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 29 + pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 30 + pub code_challenge_methods_supported: Option<Vec<CowStr<'s>>>, 31 + 32 + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata 33 + pub subject_types_supported: Option<Vec<CowStr<'s>>>, 34 + pub require_request_uri_registration: Option<bool>, 35 + 36 + // https://datatracker.ietf.org/doc/html/rfc9126#section-5 37 + pub pushed_authorization_request_endpoint: Option<CowStr<'s>>, 38 + pub require_pushed_authorization_requests: Option<bool>, 39 + 40 + // https://datatracker.ietf.org/doc/html/rfc9207#section-3 41 + pub authorization_response_iss_parameter_supported: Option<bool>, 42 + 43 + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 44 + pub dpop_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 45 + 46 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5 47 + pub client_id_metadata_document_supported: Option<bool>, 48 + 49 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 50 + pub protected_resources: Option<Vec<CowStr<'s>>>, 51 + } 52 + 53 + // https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/ 54 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-2 55 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] 56 + pub struct OAuthProtectedResourceMetadata<'s> { 57 + #[serde(borrow)] 58 + pub resource: CowStr<'s>, 59 + pub authorization_servers: Option<Vec<Url>>, 60 + pub jwks_uri: Option<CowStr<'s>>, 61 + pub scopes_supported: Vec<CowStr<'s>>, 62 + pub bearer_methods_supported: Option<Vec<CowStr<'s>>>, 63 + pub resource_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 64 + pub resource_documentation: Option<CowStr<'s>>, 65 + pub resource_policy_uri: Option<CowStr<'s>>, 66 + pub resource_tos_uri: Option<CowStr<'s>>, 67 + } 68 + 69 + impl IntoStatic for OAuthProtectedResourceMetadata<'_> { 70 + type Output = OAuthProtectedResourceMetadata<'static>; 71 + fn into_static(self) -> Self::Output { 72 + OAuthProtectedResourceMetadata { 73 + resource: self.resource.into_static(), 74 + authorization_servers: self.authorization_servers, 75 + jwks_uri: self.jwks_uri.map(|v| v.into_static()), 76 + scopes_supported: self.scopes_supported.into_static(), 77 + bearer_methods_supported: self.bearer_methods_supported.map(|v| v.into_static()), 78 + resource_signing_alg_values_supported: self 79 + .resource_signing_alg_values_supported 80 + .map(|v| v.into_static()), 81 + resource_documentation: self.resource_documentation.map(|v| v.into_static()), 82 + resource_policy_uri: self.resource_policy_uri.map(|v| v.into_static()), 83 + resource_tos_uri: self.resource_tos_uri.map(|v| v.into_static()), 84 + } 85 + } 86 + } 87 + 88 + impl IntoStatic for OAuthAuthorizationServerMetadata<'_> { 89 + type Output = OAuthAuthorizationServerMetadata<'static>; 90 + fn into_static(self) -> Self::Output { 91 + OAuthAuthorizationServerMetadata { 92 + issuer: self.issuer.into_static(), 93 + authorization_endpoint: self.authorization_endpoint.into_static(), 94 + token_endpoint: self.token_endpoint.into_static(), 95 + jwks_uri: self.jwks_uri.into_static(), 96 + registration_endpoint: self.registration_endpoint.into_static(), 97 + scopes_supported: self.scopes_supported.into_static(), 98 + response_types_supported: self.response_types_supported.into_static(), 99 + response_modes_supported: self.response_modes_supported.into_static(), 100 + grant_types_supported: self.grant_types_supported.into_static(), 101 + token_endpoint_auth_methods_supported: self 102 + .token_endpoint_auth_methods_supported 103 + .into_static(), 104 + token_endpoint_auth_signing_alg_values_supported: self 105 + .token_endpoint_auth_signing_alg_values_supported 106 + .into_static(), 107 + service_documentation: self.service_documentation.into_static(), 108 + ui_locales_supported: self.ui_locales_supported.into_static(), 109 + op_policy_uri: self.op_policy_uri.into_static(), 110 + op_tos_uri: self.op_tos_uri.into_static(), 111 + revocation_endpoint: self.revocation_endpoint.into_static(), 112 + revocation_endpoint_auth_methods_supported: self 113 + .revocation_endpoint_auth_methods_supported 114 + .into_static(), 115 + revocation_endpoint_auth_signing_alg_values_supported: self 116 + .revocation_endpoint_auth_signing_alg_values_supported 117 + .into_static(), 118 + introspection_endpoint: self.introspection_endpoint.into_static(), 119 + introspection_endpoint_auth_methods_supported: self 120 + .introspection_endpoint_auth_methods_supported 121 + .into_static(), 122 + introspection_endpoint_auth_signing_alg_values_supported: self 123 + .introspection_endpoint_auth_signing_alg_values_supported 124 + .into_static(), 125 + code_challenge_methods_supported: self.code_challenge_methods_supported.into_static(), 126 + subject_types_supported: self.subject_types_supported.into_static(), 127 + require_request_uri_registration: self.require_request_uri_registration.into_static(), 128 + pushed_authorization_request_endpoint: self 129 + .pushed_authorization_request_endpoint 130 + .into_static(), 131 + require_pushed_authorization_requests: self 132 + .require_pushed_authorization_requests 133 + .into_static(), 134 + authorization_response_iss_parameter_supported: self 135 + .authorization_response_iss_parameter_supported 136 + .into_static(), 137 + dpop_signing_alg_values_supported: self.dpop_signing_alg_values_supported.into_static(), 138 + client_id_metadata_document_supported: self 139 + .client_id_metadata_document_supported 140 + .into_static(), 141 + protected_resources: self.protected_resources.into_static(), 142 + } 143 + } 144 + }
+134
crates/jacquard-oauth/src/types/request.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Serialize, Deserialize)] 5 + #[serde(rename_all = "snake_case")] 6 + pub enum AuthorizationResponseType { 7 + Code, 8 + Token, 9 + // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) 10 + IdToken, 11 + } 12 + 13 + #[derive(Serialize, Deserialize)] 14 + #[serde(rename_all = "snake_case")] 15 + pub enum AuthorizationResponseMode { 16 + Query, 17 + Fragment, 18 + // https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode 19 + FormPost, 20 + } 21 + 22 + #[derive(Serialize, Deserialize)] 23 + pub enum AuthorizationCodeChallengeMethod { 24 + S256, 25 + #[serde(rename = "plain")] 26 + Plain, 27 + } 28 + 29 + #[derive(Serialize, Deserialize)] 30 + pub struct PushedAuthorizationRequestParameters<'a> { 31 + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 32 + pub response_type: AuthorizationResponseType, 33 + #[serde(borrow)] 34 + pub redirect_uri: CowStr<'a>, 35 + pub state: CowStr<'a>, 36 + pub scope: Option<CowStr<'a>>, 37 + // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes 38 + pub response_mode: Option<AuthorizationResponseMode>, 39 + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 40 + pub code_challenge: CowStr<'a>, 41 + pub code_challenge_method: AuthorizationCodeChallengeMethod, 42 + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 43 + pub login_hint: Option<CowStr<'a>>, 44 + pub prompt: Option<CowStr<'a>>, 45 + } 46 + 47 + #[derive(Serialize, Deserialize)] 48 + #[serde(rename_all = "snake_case")] 49 + pub enum TokenGrantType { 50 + AuthorizationCode, 51 + RefreshToken, 52 + } 53 + 54 + #[derive(Serialize, Deserialize)] 55 + pub struct TokenRequestParameters<'a> { 56 + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 57 + pub grant_type: TokenGrantType, 58 + #[serde(borrow)] 59 + pub code: CowStr<'a>, 60 + pub redirect_uri: CowStr<'a>, 61 + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 62 + pub code_verifier: CowStr<'a>, 63 + } 64 + 65 + #[derive(Serialize, Deserialize)] 66 + pub struct RefreshRequestParameters<'a> { 67 + // https://datatracker.ietf.org/doc/html/rfc6749#section-6 68 + pub grant_type: TokenGrantType, 69 + #[serde(borrow)] 70 + pub refresh_token: CowStr<'a>, 71 + pub scope: Option<CowStr<'a>>, 72 + } 73 + 74 + // https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 75 + #[derive(Serialize, Deserialize)] 76 + pub struct RevocationRequestParameters<'a> { 77 + #[serde(borrow)] 78 + pub token: CowStr<'a>, 79 + // ? 80 + // pub token_type_hint: Option<String>, 81 + } 82 + 83 + impl IntoStatic for RevocationRequestParameters<'_> { 84 + type Output = RevocationRequestParameters<'static>; 85 + 86 + fn into_static(self) -> Self::Output { 87 + Self::Output { 88 + token: self.token.into_static(), 89 + } 90 + } 91 + } 92 + 93 + impl IntoStatic for TokenRequestParameters<'_> { 94 + type Output = TokenRequestParameters<'static>; 95 + 96 + fn into_static(self) -> Self::Output { 97 + Self::Output { 98 + grant_type: self.grant_type, 99 + code: self.code.into_static(), 100 + redirect_uri: self.redirect_uri.into_static(), 101 + code_verifier: self.code_verifier.into_static(), 102 + } 103 + } 104 + } 105 + 106 + impl IntoStatic for RefreshRequestParameters<'_> { 107 + type Output = RefreshRequestParameters<'static>; 108 + 109 + fn into_static(self) -> Self::Output { 110 + Self::Output { 111 + grant_type: self.grant_type, 112 + refresh_token: self.refresh_token.into_static(), 113 + scope: self.scope.map(CowStr::into_static), 114 + } 115 + } 116 + } 117 + 118 + impl IntoStatic for PushedAuthorizationRequestParameters<'_> { 119 + type Output = PushedAuthorizationRequestParameters<'static>; 120 + 121 + fn into_static(self) -> Self::Output { 122 + Self::Output { 123 + redirect_uri: self.redirect_uri.into_static(), 124 + response_type: self.response_type, 125 + scope: self.scope.into_static(), 126 + code_challenge: self.code_challenge.into_static(), 127 + code_challenge_method: self.code_challenge_method, 128 + state: self.state.into_static(), 129 + response_mode: self.response_mode, 130 + login_hint: self.login_hint.into_static(), 131 + prompt: self.prompt.into_static(), 132 + } 133 + } 134 + }
+56
crates/jacquard-oauth/src/types/response.rs
···
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 5 + pub struct OAuthParResponse<'r> { 6 + #[serde(borrow)] 7 + pub request_uri: CowStr<'r>, 8 + pub expires_in: Option<u32>, 9 + } 10 + 11 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 12 + pub enum OAuthTokenType { 13 + DPoP, 14 + Bearer, 15 + } 16 + 17 + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 18 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 19 + pub struct OAuthTokenResponse<'r> { 20 + #[serde(borrow)] 21 + pub access_token: CowStr<'r>, 22 + pub token_type: OAuthTokenType, 23 + pub expires_in: Option<i64>, 24 + pub refresh_token: Option<CowStr<'r>>, 25 + pub scope: Option<CowStr<'r>>, 26 + // ATPROTO extension: add the sub claim to the token response to allow 27 + // clients to resolve the PDS url (audience) using the did resolution 28 + // mechanism. 29 + pub sub: Option<CowStr<'r>>, 30 + } 31 + 32 + impl IntoStatic for OAuthTokenResponse<'_> { 33 + type Output = OAuthTokenResponse<'static>; 34 + 35 + fn into_static(self) -> Self::Output { 36 + OAuthTokenResponse { 37 + access_token: self.access_token.into_static(), 38 + token_type: self.token_type, 39 + expires_in: self.expires_in, 40 + refresh_token: self.refresh_token.map(|s| s.into_static()), 41 + scope: self.scope.map(|s| s.into_static()), 42 + sub: self.sub.map(|s| s.into_static()), 43 + } 44 + } 45 + } 46 + 47 + impl IntoStatic for OAuthParResponse<'_> { 48 + type Output = OAuthParResponse<'static>; 49 + 50 + fn into_static(self) -> Self::Output { 51 + OAuthParResponse { 52 + request_uri: self.request_uri.into_static(), 53 + expires_in: self.expires_in, 54 + } 55 + } 56 + }
+36
crates/jacquard-oauth/src/types/token.rs
···
··· 1 + use super::response::OAuthTokenType; 2 + use jacquard_common::types::string::{Datetime, Did}; 3 + use jacquard_common::{CowStr, IntoStatic}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 7 + pub struct TokenSet<'s> { 8 + #[serde(borrow)] 9 + pub iss: CowStr<'s>, 10 + pub sub: Did<'s>, 11 + pub aud: CowStr<'s>, 12 + pub scope: Option<CowStr<'s>>, 13 + 14 + pub refresh_token: Option<CowStr<'s>>, 15 + pub access_token: CowStr<'s>, 16 + pub token_type: OAuthTokenType, 17 + 18 + pub expires_at: Option<Datetime>, 19 + } 20 + 21 + impl IntoStatic for TokenSet<'_> { 22 + type Output = TokenSet<'static>; 23 + 24 + fn into_static(self) -> Self::Output { 25 + TokenSet { 26 + iss: self.iss.into_static(), 27 + sub: self.sub.into_static(), 28 + aud: self.aud.into_static(), 29 + scope: self.scope.map(|s| s.into_static()), 30 + refresh_token: self.refresh_token.map(|s| s.into_static()), 31 + access_token: self.access_token.into_static(), 32 + token_type: self.token_type, 33 + expires_at: self.expires_at.map(|s| s.into_static()), 34 + } 35 + } 36 + }
+9 -3
crates/jacquard/Cargo.toml
··· 12 license.workspace = true 13 14 [features] 15 - default = ["api_all", "dns", "fancy"] 16 derive = ["dep:jacquard-derive"] 17 api = ["jacquard-api/com_atproto"] 18 api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"] 19 - dns = ["dep:hickory-resolver"] 20 fancy = ["miette/fancy"] 21 22 [lib] 23 name = "jacquard" ··· 35 clap.workspace = true 36 http.workspace = true 37 jacquard-api = { version = "0.2.0", path = "../jacquard-api" } 38 - jacquard-common = { version = "0.2.0", path = "../jacquard-common" } 39 jacquard-derive = { version = "0.2.0", path = "../jacquard-derive", optional = true } 40 miette = { workspace = true } 41 reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } ··· 50 smol_str.workspace = true 51 percent-encoding = "2" 52 urlencoding = "2"
··· 12 license.workspace = true 13 14 [features] 15 + default = ["api_all", "dns", "fancy", "loopback"] 16 derive = ["dep:jacquard-derive"] 17 api = ["jacquard-api/com_atproto"] 18 api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"] 19 + dns = ["dep:hickory-resolver", "jacquard-common/dns"] 20 fancy = ["miette/fancy"] 21 + loopback = ["dep:rouille"] 22 23 [lib] 24 name = "jacquard" ··· 36 clap.workspace = true 37 http.workspace = true 38 jacquard-api = { version = "0.2.0", path = "../jacquard-api" } 39 + jacquard-common = { version = "0.2.0", path = "../jacquard-common", features = ["reqwest-client"] } 40 + jacquard-oauth = { version = "0.1.0", path = "../jacquard-oauth" } 41 jacquard-derive = { version = "0.2.0", path = "../jacquard-derive", optional = true } 42 miette = { workspace = true } 43 reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } ··· 52 smol_str.workspace = true 53 percent-encoding = "2" 54 urlencoding = "2" 55 + jose-jwk = { version = "0.1", features = ["p256"] } 56 + p256 = { version = "0.13", features = ["ecdsa"] } 57 + rand_core = "0.6" 58 + rouille = { version = "3.6.2", optional = true }
+90 -184
crates/jacquard/src/client.rs
··· 4 //! client implementation that manages session tokens. 5 6 mod at_client; 7 - mod error; 8 - mod response; 9 mod token; 10 - mod xrpc_call; 11 - 12 - use std::fmt::Display; 13 - use std::future::Future; 14 15 pub use at_client::{AtClient, SendOverrides}; 16 - pub use error::{ClientError, Result}; 17 - use http::{ 18 - HeaderName, HeaderValue, Request, 19 - header::{AUTHORIZATION, CONTENT_TYPE}, 20 - }; 21 - pub use response::Response; 22 - pub use token::{FileTokenStore, MemoryTokenStore, TokenStore, TokenStoreError}; 23 - pub use xrpc_call::{CallOptions, XrpcCall, XrpcExt}; 24 25 use jacquard_common::{ 26 CowStr, IntoStatic, 27 types::{ 28 string::{Did, Handle}, 29 - xrpc::{XrpcMethod, XrpcRequest}, 30 }, 31 }; 32 use url::Url; 33 34 - /// Implement HttpClient for reqwest::Client 35 - impl HttpClient for reqwest::Client { 36 - type Error = reqwest::Error; 37 38 - async fn send_http( 39 - &self, 40 - request: Request<Vec<u8>>, 41 - ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 42 - // Convert http::Request to reqwest::Request 43 - let (parts, body) = request.into_parts(); 44 - 45 - let mut req = self.request(parts.method, parts.uri.to_string()).body(body); 46 - 47 - // Copy headers 48 - for (name, value) in parts.headers.iter() { 49 - req = req.header(name.as_str(), value.as_bytes()); 50 - } 51 - 52 - // Send request 53 - let resp = req.send().await?; 54 - 55 - // Convert reqwest::Response to http::Response 56 - let mut builder = http::Response::builder().status(resp.status()); 57 - 58 - // Copy headers 59 - for (name, value) in resp.headers().iter() { 60 - builder = builder.header(name.as_str(), value.as_bytes()); 61 - } 62 - 63 - // Read body 64 - let body = resp.bytes().await?.to_vec(); 65 - 66 - Ok(builder.body(body).expect("Failed to build response")) 67 - } 68 - } 69 - 70 - /// HTTP client trait for sending raw HTTP requests. 71 - pub trait HttpClient { 72 - /// Error type returned by the HTTP client 73 - type Error: std::error::Error + Display + Send + Sync + 'static; 74 - /// Send an HTTP request and return the response. 75 - fn send_http( 76 - &self, 77 - request: Request<Vec<u8>>, 78 - ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send; 79 - } 80 // Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs 81 82 pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 83 84 - /// Authorization token types for XRPC requests. 85 - #[derive(Debug, Clone)] 86 - pub enum AuthorizationToken<'s> { 87 - /// Bearer token (access JWT, refresh JWT to refresh the session) 88 - Bearer(CowStr<'s>), 89 - /// DPoP token (proof-of-possession) for OAuth 90 - Dpop(CowStr<'s>), 91 - } 92 - 93 - /// Basic client wrapper: reqwest transport + in-memory token store. 94 - pub struct BasicClient(AtClient<reqwest::Client, MemoryTokenStore>); 95 96 impl BasicClient { 97 /// Construct a basic client with minimal inputs. ··· 99 Self(AtClient::new( 100 reqwest::Client::new(), 101 base, 102 - MemoryTokenStore::default(), 103 )) 104 } 105 106 /// Access the inner stateful client. 107 - pub fn inner(&self) -> &AtClient<reqwest::Client, MemoryTokenStore> { 108 &self.0 109 } 110 111 /// Send an XRPC request. 112 - pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> Result<Response<R>> { 113 self.0.send(req).await 114 } 115 ··· 118 &self, 119 req: R, 120 overrides: SendOverrides<'_>, 121 - ) -> Result<Response<R>> { 122 self.0.send_with(req, overrides).await 123 } 124 125 /// Get current session. 126 - pub async fn session(&self) -> Option<Session> { 127 - self.0.session().await 128 } 129 130 /// Set the session. 131 - pub async fn set_session(&self, session: Session) -> core::result::Result<(), TokenStoreError> { 132 self.0.set_session(session).await 133 } 134 135 /// Clear session. 136 - pub async fn clear_session(&self) -> core::result::Result<(), TokenStoreError> { 137 self.0.clear_session().await 138 } 139 ··· 143 } 144 } 145 146 - /// HTTP headers commonly used in XRPC requests 147 - pub enum Header { 148 - /// Content-Type header 149 - ContentType, 150 - /// Authorization header 151 - Authorization, 152 - /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 153 - /// 154 - /// See: <https://atproto.com/specs/xrpc#service-proxying> 155 - AtprotoProxy, 156 - /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details. 157 - AtprotoAcceptLabelers, 158 - } 159 - 160 - impl From<Header> for HeaderName { 161 - fn from(value: Header) -> Self { 162 - match value { 163 - Header::ContentType => CONTENT_TYPE, 164 - Header::Authorization => AUTHORIZATION, 165 - Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 166 - Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 167 - } 168 - } 169 - } 170 - 171 - /// Build an HTTP request for an XRPC call given base URL and options 172 - pub(crate) fn build_http_request<R: XrpcRequest>( 173 - base: &Url, 174 - req: &R, 175 - opts: &xrpc_call::CallOptions<'_>, 176 - ) -> core::result::Result<Request<Vec<u8>>, error::TransportError> { 177 - let mut url = base.clone(); 178 - let mut path = url.path().trim_end_matches('/').to_owned(); 179 - path.push_str("/xrpc/"); 180 - path.push_str(R::NSID); 181 - url.set_path(&path); 182 - 183 - if let XrpcMethod::Query = R::METHOD { 184 - let qs = serde_html_form::to_string(&req) 185 - .map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?; 186 - if !qs.is_empty() { 187 - url.set_query(Some(&qs)); 188 - } else { 189 - url.set_query(None); 190 - } 191 - } 192 - 193 - let method = match R::METHOD { 194 - XrpcMethod::Query => http::Method::GET, 195 - XrpcMethod::Procedure(_) => http::Method::POST, 196 - }; 197 - 198 - let mut builder = Request::builder().method(method).uri(url.as_str()); 199 - 200 - if let XrpcMethod::Procedure(encoding) = R::METHOD { 201 - builder = builder.header(Header::ContentType, encoding); 202 - } 203 - builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING); 204 - 205 - if let Some(token) = &opts.auth { 206 - let hv = match token { 207 - AuthorizationToken::Bearer(t) => { 208 - HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 209 - } 210 - AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 211 - } 212 - .map_err(|e| { 213 - error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 214 - })?; 215 - builder = builder.header(Header::Authorization, hv); 216 - } 217 - 218 - if let Some(proxy) = &opts.atproto_proxy { 219 - builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); 220 - } 221 - if let Some(labelers) = &opts.atproto_accept_labelers { 222 - if !labelers.is_empty() { 223 - let joined = labelers 224 - .iter() 225 - .map(|s| s.as_ref()) 226 - .collect::<Vec<_>>() 227 - .join(", "); 228 - builder = builder.header(Header::AtprotoAcceptLabelers, joined); 229 - } 230 - } 231 - for (name, value) in &opts.extra_headers { 232 - builder = builder.header(name, value); 233 - } 234 - 235 - let body = if let XrpcMethod::Procedure(_) = R::METHOD { 236 - req.encode_body() 237 - .map_err(|e| error::TransportError::InvalidRequest(e.to_string()))? 238 - } else { 239 - vec![] 240 - }; 241 - 242 - builder 243 - .body(body) 244 - .map_err(|e| error::TransportError::InvalidRequest(e.to_string())) 245 - } 246 - 247 - /// Session information from `com.atproto.server.createSession` 248 /// 249 /// Contains the access and refresh tokens along with user identity information. 250 #[derive(Debug, Clone)] 251 - pub struct Session { 252 /// Access token (JWT) used for authenticated requests 253 pub access_jwt: CowStr<'static>, 254 /// Refresh token (JWT) used to obtain new access tokens ··· 259 pub handle: Handle<'static>, 260 } 261 262 - impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> for Session { 263 fn from( 264 output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>, 265 ) -> Self { ··· 273 } 274 275 impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>> 276 - for Session 277 { 278 fn from( 279 output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>, ··· 286 } 287 } 288 }
··· 4 //! client implementation that manages session tokens. 5 6 mod at_client; 7 + 8 mod token; 9 10 pub use at_client::{AtClient, SendOverrides}; 11 12 + pub use jacquard_common::error::{ClientError, XrpcResult}; 13 + pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; 14 use jacquard_common::{ 15 CowStr, IntoStatic, 16 types::{ 17 string::{Did, Handle}, 18 + xrpc::{Response, XrpcRequest}, 19 }, 20 }; 21 + pub use token::FileTokenStore; 22 use url::Url; 23 24 + use p256::SecretKey; 25 26 // Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs 27 28 pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 29 30 + /// Basic client wrapper: reqwest transport + in-memory session store. 31 + pub struct BasicClient(AtClient<reqwest::Client, MemorySessionStore<Did<'static>, AuthSession>>); 32 33 impl BasicClient { 34 /// Construct a basic client with minimal inputs. ··· 36 Self(AtClient::new( 37 reqwest::Client::new(), 38 base, 39 + MemorySessionStore::default(), 40 )) 41 } 42 43 /// Access the inner stateful client. 44 + pub fn inner( 45 + &self, 46 + ) -> &AtClient<reqwest::Client, MemorySessionStore<Did<'static>, AuthSession>> { 47 &self.0 48 } 49 50 /// Send an XRPC request. 51 + pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> XrpcResult<Response<R>> { 52 self.0.send(req).await 53 } 54 ··· 57 &self, 58 req: R, 59 overrides: SendOverrides<'_>, 60 + ) -> XrpcResult<Response<R>> { 61 self.0.send_with(req, overrides).await 62 } 63 64 /// Get current session. 65 + pub async fn session(&self, did: &Did<'static>) -> Option<AuthSession> { 66 + self.0.session(did).await 67 } 68 69 /// Set the session. 70 + pub async fn set_session( 71 + &self, 72 + session: AuthSession, 73 + ) -> core::result::Result<(), SessionStoreError> { 74 self.0.set_session(session).await 75 } 76 77 /// Clear session. 78 + pub async fn clear_session(&self) -> core::result::Result<(), SessionStoreError> { 79 self.0.clear_session().await 80 } 81 ··· 85 } 86 } 87 88 + /// App password session information from `com.atproto.server.createSession` 89 /// 90 /// Contains the access and refresh tokens along with user identity information. 91 #[derive(Debug, Clone)] 92 + pub struct AtpSession { 93 /// Access token (JWT) used for authenticated requests 94 pub access_jwt: CowStr<'static>, 95 /// Refresh token (JWT) used to obtain new access tokens ··· 100 pub handle: Handle<'static>, 101 } 102 103 + impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> 104 + for AtpSession 105 + { 106 fn from( 107 output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>, 108 ) -> Self { ··· 116 } 117 118 impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>> 119 + for AtpSession 120 { 121 fn from( 122 output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>, ··· 129 } 130 } 131 } 132 + 133 + #[derive(Debug, Clone)] 134 + pub enum AuthSession { 135 + AppPassword(AtpSession), 136 + OAuth(jacquard_oauth::session::OauthSession<'static>), 137 + } 138 + 139 + impl AuthSession { 140 + pub fn did(&self) -> &Did<'static> { 141 + match self { 142 + AuthSession::AppPassword(session) => &session.did, 143 + AuthSession::OAuth(session) => &session.token_set.sub, 144 + } 145 + } 146 + 147 + pub fn refresh_token(&self) -> Option<&CowStr<'static>> { 148 + match self { 149 + AuthSession::AppPassword(session) => Some(&session.refresh_jwt), 150 + AuthSession::OAuth(session) => session.token_set.refresh_token.as_ref(), 151 + } 152 + } 153 + 154 + pub fn access_token(&self) -> &CowStr<'static> { 155 + match self { 156 + AuthSession::AppPassword(session) => &session.access_jwt, 157 + AuthSession::OAuth(session) => &session.token_set.access_token, 158 + } 159 + } 160 + 161 + pub fn set_refresh_token(&mut self, token: CowStr<'_>) { 162 + match self { 163 + AuthSession::AppPassword(session) => { 164 + session.refresh_jwt = token.into_static(); 165 + } 166 + AuthSession::OAuth(session) => { 167 + session.token_set.refresh_token = Some(token.into_static()); 168 + } 169 + } 170 + } 171 + 172 + pub fn set_access_token(&mut self, token: CowStr<'_>) { 173 + match self { 174 + AuthSession::AppPassword(session) => { 175 + session.access_jwt = token.into_static(); 176 + } 177 + AuthSession::OAuth(session) => { 178 + session.token_set.access_token = token.into_static(); 179 + } 180 + } 181 + } 182 + } 183 + 184 + impl From<AtpSession> for AuthSession { 185 + fn from(session: AtpSession) -> Self { 186 + AuthSession::AppPassword(session) 187 + } 188 + } 189 + 190 + impl From<jacquard_oauth::session::OauthSession<'static>> for AuthSession { 191 + fn from(session: jacquard_oauth::session::OauthSession<'static>) -> Self { 192 + AuthSession::OAuth(session) 193 + } 194 + }
+99 -53
crates/jacquard/src/client/at_client.rs
··· 1 use bytes::Bytes; 2 use url::Url; 3 4 - use crate::client::xrpc_call::{CallOptions, XrpcExt}; 5 - use crate::client::{self as super_mod, AuthorizationToken, HttpClient, Response, Session, error}; 6 - use jacquard_common::types::xrpc::XrpcRequest; 7 8 - use super::token::TokenStore; 9 10 /// Per-call overrides when sending via `AtClient`. 11 #[derive(Debug, Default, Clone)] 12 pub struct SendOverrides<'a> { 13 /// Optional base URI override for this call. 14 pub base_uri: Option<Url>, 15 /// Per-request options such as auth, proxy, labelers, extra headers. ··· 22 /// Construct default overrides (no base override, auto-refresh enabled). 23 pub fn new() -> Self { 24 Self { 25 base_uri: None, 26 options: CallOptions::default(), 27 auto_refresh: true, ··· 37 self.options = opts; 38 self 39 } 40 /// Enable or disable one-shot auto-refresh + retry behavior. 41 pub fn auto_refresh(mut self, enable: bool) -> Self { 42 self.auto_refresh = enable; ··· 71 /// Ok(()) 72 /// } 73 /// ``` 74 - pub struct AtClient<C: HttpClient, S: TokenStore> { 75 transport: C, 76 base: Url, 77 tokens: S, 78 - refresh_lock: tokio::sync::Mutex<()>, 79 } 80 81 - impl<C: HttpClient, S: TokenStore> AtClient<C, S> { 82 /// Create a new client with a transport, base URL, and token store. 83 pub fn new(transport: C, base: Url, tokens: S) -> Self { 84 Self { 85 transport, 86 base, 87 tokens, 88 - refresh_lock: tokio::sync::Mutex::new(()), 89 } 90 } 91 ··· 100 } 101 102 /// Get the current session, if any. 103 - pub async fn session(&self) -> Option<Session> { 104 - self.tokens.get().await 105 } 106 107 /// Set the current session in the token store. 108 - pub async fn set_session(&self, session: Session) -> Result<(), super_mod::TokenStoreError> { 109 - self.tokens.set(session).await 110 } 111 112 /// Clear the current session from the token store. 113 - pub async fn clear_session(&self) -> Result<(), super_mod::TokenStoreError> { 114 self.tokens.clear().await 115 } 116 117 /// Send an XRPC request using the client's base URL and default behavior. 118 - pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> super_mod::Result<Response<R>> { 119 self.send_with(req, SendOverrides::new()).await 120 } 121 ··· 124 &self, 125 req: R, 126 mut overrides: SendOverrides<'_>, 127 - ) -> super_mod::Result<Response<R>> { 128 let base = overrides 129 .base_uri 130 .clone() 131 .unwrap_or_else(|| self.base.clone()); 132 - let is_refresh = R::NSID == super_mod::NSID_REFRESH_SESSION; 133 134 if overrides.options.auth.is_none() { 135 - if let Some(s) = self.tokens.get().await { 136 - overrides.options.auth = Some(if is_refresh { 137 - AuthorizationToken::Bearer(s.refresh_jwt) 138 - } else { 139 - AuthorizationToken::Bearer(s.access_jwt) 140 - }); 141 } 142 } 143 144 - let http_request = super_mod::build_http_request(&base, &req, &overrides.options) 145 - .map_err(error::TransportError::from)?; 146 let http_response = self 147 .transport 148 .send_http(http_request) 149 .await 150 - .map_err(|e| error::TransportError::Other(Box::new(e)))?; 151 let status = http_response.status(); 152 let buffer = Bytes::from(http_response.into_body()); 153 154 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 155 - return Err(error::HttpError { 156 status, 157 body: Some(buffer), 158 } ··· 167 self.refresh_once().await?; 168 169 let mut retry_opts = overrides.options.clone(); 170 - if let Some(s) = self.tokens.get().await { 171 - retry_opts.auth = Some(AuthorizationToken::Bearer(s.access_jwt)); 172 } 173 - let http_request = super_mod::build_http_request(&base, &req, &retry_opts) 174 - .map_err(error::TransportError::from)?; 175 let http_response = self 176 .transport 177 .send_http(http_request) 178 .await 179 - .map_err(|e| error::TransportError::Other(Box::new(e)))?; 180 let status = http_response.status(); 181 let buffer = Bytes::from(http_response.into_body()); 182 183 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 184 - return Err(error::HttpError { 185 status, 186 body: Some(buffer), 187 } ··· 193 Ok(Response::new(buffer, status)) 194 } 195 196 - async fn refresh_once(&self) -> super_mod::Result<()> { 197 - let _guard = self.refresh_lock.lock().await; 198 - let Some(s) = self.tokens.get().await else { 199 - return Err(error::ClientError::Auth(error::AuthError::NotAuthenticated)); 200 - }; 201 - let refresh_token = s.refresh_jwt.clone(); 202 - let refresh_resp = self 203 - .transport 204 - .xrpc(self.base.clone()) 205 - .auth(AuthorizationToken::Bearer(refresh_token)) 206 - .send(jacquard_api::com_atproto::server::refresh_session::RefreshSession) 207 - .await?; 208 - let refreshed = match refresh_resp.into_output() { 209 - Ok(o) => Session::from(o), 210 - Err(_) => return Err(error::ClientError::Auth(error::AuthError::RefreshFailed)), 211 - }; 212 - self.tokens 213 - .set(refreshed) 214 - .await 215 - .map_err(|_| error::ClientError::Auth(error::AuthError::RefreshFailed))?; 216 - Ok(()) 217 } 218 219 fn is_auth_expired(status: http::StatusCode, buffer: &Bytes) -> bool {
··· 1 use bytes::Bytes; 2 + use jacquard_common::{ 3 + AuthorizationToken, IntoStatic, 4 + error::{AuthError, ClientError, HttpError, TransportError, XrpcResult}, 5 + http_client::HttpClient, 6 + session::{SessionStore, SessionStoreError}, 7 + types::{ 8 + did::Did, 9 + xrpc::{CallOptions, Response, XrpcExt}, 10 + }, 11 + }; 12 use url::Url; 13 14 + use jacquard_common::types::xrpc::{XrpcRequest, build_http_request}; 15 16 + use crate::client::{AtpSession, AuthSession, FileTokenStore, NSID_REFRESH_SESSION}; 17 18 /// Per-call overrides when sending via `AtClient`. 19 #[derive(Debug, Default, Clone)] 20 pub struct SendOverrides<'a> { 21 + pub did: Option<Did<'a>>, 22 /// Optional base URI override for this call. 23 pub base_uri: Option<Url>, 24 /// Per-request options such as auth, proxy, labelers, extra headers. ··· 31 /// Construct default overrides (no base override, auto-refresh enabled). 32 pub fn new() -> Self { 33 Self { 34 + did: None, 35 base_uri: None, 36 options: CallOptions::default(), 37 auto_refresh: true, ··· 47 self.options = opts; 48 self 49 } 50 + 51 + /// Provide a full set of call options (auth/headers/etc.). 52 + pub fn did(mut self, did: Did<'a>) -> Self { 53 + self.did = Some(did); 54 + self 55 + } 56 /// Enable or disable one-shot auto-refresh + retry behavior. 57 pub fn auto_refresh(mut self, enable: bool) -> Self { 58 self.auto_refresh = enable; ··· 87 /// Ok(()) 88 /// } 89 /// ``` 90 + pub struct AtClient<C: HttpClient, S> { 91 transport: C, 92 base: Url, 93 tokens: S, 94 + refresh_lock: tokio::sync::Mutex<Option<Did<'static>>>, 95 } 96 97 + impl<C: HttpClient, S: SessionStore<Did<'static>, AuthSession>> AtClient<C, S> { 98 /// Create a new client with a transport, base URL, and token store. 99 pub fn new(transport: C, base: Url, tokens: S) -> Self { 100 Self { 101 transport, 102 base, 103 tokens, 104 + refresh_lock: tokio::sync::Mutex::new(None), 105 } 106 } 107 ··· 116 } 117 118 /// Get the current session, if any. 119 + pub async fn session(&self, did: &Did<'static>) -> Option<AuthSession> { 120 + self.tokens.get(did).await 121 } 122 123 /// Set the current session in the token store. 124 + pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> { 125 + let s = session.clone(); 126 + let did = s.did().clone().into_static(); 127 + self.tokens.set(did, session).await 128 } 129 130 /// Clear the current session from the token store. 131 + pub async fn clear_session(&self) -> Result<(), SessionStoreError> { 132 self.tokens.clear().await 133 } 134 135 /// Send an XRPC request using the client's base URL and default behavior. 136 + pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> XrpcResult<Response<R>> { 137 self.send_with(req, SendOverrides::new()).await 138 } 139 ··· 142 &self, 143 req: R, 144 mut overrides: SendOverrides<'_>, 145 + ) -> XrpcResult<Response<R>> { 146 let base = overrides 147 .base_uri 148 .clone() 149 .unwrap_or_else(|| self.base.clone()); 150 + let is_refresh = R::NSID == NSID_REFRESH_SESSION; 151 152 + let mut current_did = None; 153 if overrides.options.auth.is_none() { 154 + if let Ok(guard) = self.refresh_lock.try_lock() { 155 + if let Some(ref did) = *guard { 156 + current_did = Some(did.clone()); 157 + if let Some(s) = self.tokens.get(&did).await { 158 + overrides.options.auth = Some( 159 + if let Some(refresh_tok) = s.refresh_token() 160 + && is_refresh 161 + { 162 + AuthorizationToken::Bearer(refresh_tok.clone().into_static()) 163 + } else { 164 + AuthorizationToken::Bearer(s.access_token().clone().into_static()) 165 + }, 166 + ); 167 + } 168 + } 169 } 170 } 171 172 + let http_request = 173 + build_http_request(&base, &req, &overrides.options).map_err(TransportError::from)?; 174 let http_response = self 175 .transport 176 .send_http(http_request) 177 .await 178 + .map_err(|e| TransportError::Other(Box::new(e)))?; 179 let status = http_response.status(); 180 let buffer = Bytes::from(http_response.into_body()); 181 182 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 183 + return Err(HttpError { 184 status, 185 body: Some(buffer), 186 } ··· 195 self.refresh_once().await?; 196 197 let mut retry_opts = overrides.options.clone(); 198 + if let Some(curr_did) = current_did { 199 + if let Some(s) = self.tokens.get(&curr_did).await { 200 + retry_opts.auth = Some(AuthorizationToken::Bearer( 201 + s.access_token().clone().into_static(), 202 + )); 203 + } 204 } 205 + let http_request = 206 + build_http_request(&base, &req, &retry_opts).map_err(TransportError::from)?; 207 let http_response = self 208 .transport 209 .send_http(http_request) 210 .await 211 + .map_err(|e| TransportError::Other(Box::new(e)))?; 212 let status = http_response.status(); 213 let buffer = Bytes::from(http_response.into_body()); 214 215 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 216 + return Err(HttpError { 217 status, 218 body: Some(buffer), 219 } ··· 225 Ok(Response::new(buffer, status)) 226 } 227 228 + async fn refresh_once(&self) -> XrpcResult<()> { 229 + let guard = self.refresh_lock.lock().await; 230 + if let Some(ref did) = *guard { 231 + if let Some(s) = self.tokens.get(did).await { 232 + if let Some(refresh_tok) = s.refresh_token() { 233 + let refresh_resp = self 234 + .transport 235 + .xrpc(self.base.clone()) 236 + .auth(AuthorizationToken::Bearer( 237 + refresh_tok.clone().into_static(), 238 + )) 239 + .send(jacquard_api::com_atproto::server::refresh_session::RefreshSession) 240 + .await?; 241 + let refreshed = match refresh_resp.into_output() { 242 + Ok(o) => AtpSession::from(o), 243 + Err(_) => return Err(ClientError::Auth(AuthError::RefreshFailed)), 244 + }; 245 + 246 + let mut session = s.clone(); 247 + session.set_access_token(refreshed.access_jwt); 248 + session.set_refresh_token(refreshed.refresh_jwt); 249 + 250 + self.set_session(session) 251 + .await 252 + .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; 253 + Ok(()) 254 + } else { 255 + Err(ClientError::Auth(AuthError::RefreshFailed)) 256 + } 257 + } else { 258 + Err(ClientError::Auth(AuthError::NotAuthenticated)) 259 + } 260 + } else { 261 + Err(ClientError::Auth(AuthError::NotAuthenticated)) 262 + } 263 } 264 265 fn is_auth_expired(status: http::StatusCode, buffer: &Bytes) -> bool {
+21 -21
crates/jacquard/src/client/error.rs crates/jacquard-common/src/error.rs
··· 2 3 use bytes::Bytes; 4 5 /// Client error type wrapping all possible error conditions 6 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 7 pub enum ClientError { ··· 66 Other(Box<dyn std::error::Error + Send + Sync>), 67 } 68 69 - // Re-export EncodeError from common 70 - pub use jacquard_common::types::xrpc::EncodeError; 71 - 72 /// Response deserialization errors 73 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 74 pub enum DecodeError { ··· 91 CborRemote( 92 #[from] 93 #[source] 94 - serde_ipld_dagcbor::DecodeError<reqwest::Error>, 95 ), 96 } 97 ··· 116 } 117 } 118 119 /// Authentication and authorization errors 120 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 121 pub enum AuthError { ··· 139 #[error("Authentication error: {0:?}")] 140 Other(http::HeaderValue), 141 } 142 - 143 - /// Result type for client operations 144 - pub type Result<T> = std::result::Result<T, ClientError>; 145 - 146 - impl From<reqwest::Error> for TransportError { 147 - fn from(e: reqwest::Error) -> Self { 148 - if e.is_timeout() { 149 - Self::Timeout 150 - } else if e.is_connect() { 151 - Self::Connect(e.to_string()) 152 - } else if e.is_builder() || e.is_request() { 153 - Self::InvalidRequest(e.to_string()) 154 - } else { 155 - Self::Other(Box::new(e)) 156 - } 157 - } 158 - }
··· 2 3 use bytes::Bytes; 4 5 + use crate::types::xrpc::EncodeError; 6 + 7 /// Client error type wrapping all possible error conditions 8 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 9 pub enum ClientError { ··· 68 Other(Box<dyn std::error::Error + Send + Sync>), 69 } 70 71 /// Response deserialization errors 72 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 73 pub enum DecodeError { ··· 90 CborRemote( 91 #[from] 92 #[source] 93 + serde_ipld_dagcbor::DecodeError<HttpError>, 94 ), 95 } 96 ··· 115 } 116 } 117 118 + /// Result type for client operations 119 + pub type XrpcResult<T> = std::result::Result<T, ClientError>; 120 + 121 + #[cfg(feature = "reqwest-client")] 122 + impl From<reqwest::Error> for TransportError { 123 + fn from(e: reqwest::Error) -> Self { 124 + if e.is_timeout() { 125 + Self::Timeout 126 + } else if e.is_connect() { 127 + Self::Connect(e.to_string()) 128 + } else if e.is_builder() || e.is_request() { 129 + Self::InvalidRequest(e.to_string()) 130 + } else { 131 + Self::Other(Box::new(e)) 132 + } 133 + } 134 + } 135 + 136 /// Authentication and authorization errors 137 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 138 pub enum AuthError { ··· 156 #[error("Authentication error: {0:?}")] 157 Other(http::HeaderValue), 158 }
-198
crates/jacquard/src/client/response.rs
··· 1 - //! XRPC response parsing and error handling 2 - 3 - use bytes::Bytes; 4 - use http::StatusCode; 5 - use jacquard_common::IntoStatic; 6 - use jacquard_common::smol_str::SmolStr; 7 - use jacquard_common::types::xrpc::XrpcRequest; 8 - use serde::Deserialize; 9 - use std::marker::PhantomData; 10 - 11 - use super::error::AuthError; 12 - 13 - /// XRPC response wrapper that owns the response buffer 14 - /// 15 - /// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 16 - /// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). 17 - pub struct Response<R: XrpcRequest> { 18 - buffer: Bytes, 19 - status: StatusCode, 20 - _marker: PhantomData<R>, 21 - } 22 - 23 - impl<R: XrpcRequest> Response<R> { 24 - /// Create a new response from a buffer and status code 25 - pub fn new(buffer: Bytes, status: StatusCode) -> Self { 26 - Self { 27 - buffer, 28 - status, 29 - _marker: PhantomData, 30 - } 31 - } 32 - 33 - /// Get the HTTP status code 34 - pub fn status(&self) -> StatusCode { 35 - self.status 36 - } 37 - 38 - /// Parse the response, borrowing from the internal buffer 39 - pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> { 40 - // Use a helper to make lifetime inference work 41 - fn parse_output<'b, R: XrpcRequest>( 42 - buffer: &'b [u8], 43 - ) -> Result<R::Output<'b>, serde_json::Error> { 44 - serde_json::from_slice(buffer) 45 - } 46 - 47 - fn parse_error<'b, R: XrpcRequest>( 48 - buffer: &'b [u8], 49 - ) -> Result<R::Err<'b>, serde_json::Error> { 50 - serde_json::from_slice(buffer) 51 - } 52 - 53 - // 200: parse as output 54 - if self.status.is_success() { 55 - match parse_output::<R>(&self.buffer) { 56 - Ok(output) => Ok(output), 57 - Err(e) => Err(XrpcError::Decode(e)), 58 - } 59 - // 400: try typed XRPC error, fallback to generic error 60 - } else if self.status.as_u16() == 400 { 61 - match parse_error::<R>(&self.buffer) { 62 - Ok(error) => Err(XrpcError::Xrpc(error)), 63 - Err(_) => { 64 - // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 65 - match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 66 - Ok(generic) => { 67 - // Map auth-related errors to AuthError 68 - match generic.error.as_str() { 69 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 70 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 71 - _ => Err(XrpcError::Generic(generic)), 72 - } 73 - } 74 - Err(e) => Err(XrpcError::Decode(e)), 75 - } 76 - } 77 - } 78 - // 401: always auth error 79 - } else { 80 - match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 81 - Ok(generic) => match generic.error.as_str() { 82 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 83 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 84 - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 85 - }, 86 - Err(e) => Err(XrpcError::Decode(e)), 87 - } 88 - } 89 - } 90 - 91 - /// Parse the response into an owned output 92 - pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>> 93 - where 94 - for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>, 95 - for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>, 96 - { 97 - // Use a helper to make lifetime inference work 98 - fn parse_output<'b, R: XrpcRequest>( 99 - buffer: &'b [u8], 100 - ) -> Result<R::Output<'b>, serde_json::Error> { 101 - serde_json::from_slice(buffer) 102 - } 103 - 104 - fn parse_error<'b, R: XrpcRequest>( 105 - buffer: &'b [u8], 106 - ) -> Result<R::Err<'b>, serde_json::Error> { 107 - serde_json::from_slice(buffer) 108 - } 109 - 110 - // 200: parse as output 111 - if self.status.is_success() { 112 - match parse_output::<R>(&self.buffer) { 113 - Ok(output) => Ok(output.into_static()), 114 - Err(e) => Err(XrpcError::Decode(e)), 115 - } 116 - // 400: try typed XRPC error, fallback to generic error 117 - } else if self.status.as_u16() == 400 { 118 - match parse_error::<R>(&self.buffer) { 119 - Ok(error) => Err(XrpcError::Xrpc(error.into_static())), 120 - Err(_) => { 121 - // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 122 - match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 123 - Ok(generic) => { 124 - // Map auth-related errors to AuthError 125 - match generic.error.as_ref() { 126 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 127 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 128 - _ => Err(XrpcError::Generic(generic)), 129 - } 130 - } 131 - Err(e) => Err(XrpcError::Decode(e)), 132 - } 133 - } 134 - } 135 - // 401: always auth error 136 - } else { 137 - match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 138 - Ok(generic) => match generic.error.as_ref() { 139 - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 140 - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 141 - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 142 - }, 143 - Err(e) => Err(XrpcError::Decode(e)), 144 - } 145 - } 146 - } 147 - 148 - /// Get the raw buffer 149 - pub fn buffer(&self) -> &Bytes { 150 - &self.buffer 151 - } 152 - } 153 - 154 - /// Generic XRPC error format for untyped errors like InvalidRequest 155 - /// 156 - /// Used when the error doesn't match the endpoint's specific error enum 157 - #[derive(Debug, Clone, Deserialize)] 158 - pub struct GenericXrpcError { 159 - /// Error code (e.g., "InvalidRequest") 160 - pub error: SmolStr, 161 - /// Optional error message with details 162 - pub message: Option<SmolStr>, 163 - } 164 - 165 - impl std::fmt::Display for GenericXrpcError { 166 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 167 - if let Some(msg) = &self.message { 168 - write!(f, "{}: {}", self.error, msg) 169 - } else { 170 - write!(f, "{}", self.error) 171 - } 172 - } 173 - } 174 - 175 - impl std::error::Error for GenericXrpcError {} 176 - 177 - /// XRPC-specific errors returned from endpoints 178 - /// 179 - /// Represents errors returned in the response body 180 - /// Type parameter `E` is the endpoint's specific error enum type. 181 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 182 - pub enum XrpcError<E: std::error::Error + IntoStatic> { 183 - /// Typed XRPC error from the endpoint's specific error enum 184 - #[error("XRPC error: {0}")] 185 - Xrpc(E), 186 - 187 - /// Authentication error (ExpiredToken, InvalidToken, etc.) 188 - #[error("Authentication error: {0}")] 189 - Auth(#[from] AuthError), 190 - 191 - /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 192 - #[error("XRPC error: {0}")] 193 - Generic(GenericXrpcError), 194 - 195 - /// Failed to decode the response body 196 - #[error("Failed to decode response: {0}")] 197 - Decode(#[from] serde_json::Error), 198 - }
···
+30 -57
crates/jacquard/src/client/token.rs
··· 1 use async_trait::async_trait; 2 - use std::path::{Path, PathBuf}; 3 - use std::sync::Arc; 4 - use thiserror::Error; 5 - 6 - use super::Session; 7 use jacquard_common::IntoStatic; 8 use jacquard_common::types::string::{Did, Handle}; 9 - 10 - /// Errors emitted by token stores. 11 - #[derive(Debug, Error)] 12 - pub enum TokenStoreError { 13 - /// An underlying I/O or serialization error with context. 14 - #[error("token store error: {0}")] 15 - Other(String), 16 - } 17 - 18 - /// Pluggable session token storage (memory, disk, browser, etc.). 19 - #[async_trait] 20 - pub trait TokenStore: Send + Sync { 21 - /// Get the current session if present. 22 - async fn get(&self) -> Option<Session>; 23 - /// Persist the given session. 24 - async fn set(&self, session: Session) -> Result<(), TokenStoreError>; 25 - /// Remove any stored session. 26 - async fn clear(&self) -> Result<(), TokenStoreError>; 27 - } 28 - 29 - /// In-memory token store suitable for short-lived sessions and tests. 30 - #[derive(Default, Clone)] 31 - pub struct MemoryTokenStore(Arc<tokio::sync::RwLock<Option<Session>>>); 32 - 33 - #[async_trait] 34 - impl TokenStore for MemoryTokenStore { 35 - async fn get(&self) -> Option<Session> { 36 - self.0.read().await.clone() 37 - } 38 - async fn set(&self, session: Session) -> Result<(), TokenStoreError> { 39 - *self.0.write().await = Some(session); 40 - Ok(()) 41 - } 42 - async fn clear(&self) -> Result<(), TokenStoreError> { 43 - *self.0.write().await = None; 44 - Ok(()) 45 - } 46 - } 47 48 /// File-backed token store using a JSON file. 49 /// ··· 77 } 78 79 #[async_trait] 80 - impl TokenStore for FileTokenStore { 81 - async fn get(&self) -> Option<Session> { 82 - let data = tokio::fs::read(&self.path).await.ok()?; 83 let disk: FileSession = serde_json::from_slice(&data).ok()?; 84 let did = Did::new_owned(disk.did).ok()?; 85 let handle = Handle::new_owned(disk.handle).ok()?; 86 - Some(Session { 87 access_jwt: disk.access_jwt.into(), 88 refresh_jwt: disk.refresh_jwt.into(), 89 did: did.into_static(), ··· 91 }) 92 } 93 94 - async fn set(&self, session: Session) -> Result<(), TokenStoreError> { 95 let disk = FileSession { 96 access_jwt: session.access_jwt.to_string(), 97 refresh_jwt: session.refresh_jwt.to_string(), 98 did: session.did.to_string(), 99 handle: session.handle.to_string(), 100 }; 101 - let buf = 102 - serde_json::to_vec_pretty(&disk).map_err(|e| TokenStoreError::Other(e.to_string()))?; 103 if let Some(parent) = self.path.parent() { 104 tokio::fs::create_dir_all(parent) 105 .await 106 - .map_err(|e| TokenStoreError::Other(e.to_string()))?; 107 } 108 - let tmp = self.path.with_extension("tmp"); 109 tokio::fs::write(&tmp, &buf) 110 .await 111 - .map_err(|e| TokenStoreError::Other(e.to_string()))?; 112 - tokio::fs::rename(&tmp, &self.path) 113 .await 114 - .map_err(|e| TokenStoreError::Other(e.to_string()))?; 115 Ok(()) 116 } 117 118 - async fn clear(&self) -> Result<(), TokenStoreError> { 119 match tokio::fs::remove_file(&self.path).await { 120 Ok(_) => Ok(()), 121 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 122 - Err(e) => Err(TokenStoreError::Other(e.to_string())), 123 } 124 } 125 }
··· 1 + use crate::client::AtpSession; 2 use async_trait::async_trait; 3 use jacquard_common::IntoStatic; 4 + use jacquard_common::session::{SessionStore, SessionStoreError}; 5 use jacquard_common::types::string::{Did, Handle}; 6 + use std::path::{Path, PathBuf}; 7 8 /// File-backed token store using a JSON file. 9 /// ··· 37 } 38 39 #[async_trait] 40 + impl SessionStore<Did<'static>, AtpSession> for FileTokenStore { 41 + async fn get(&self, key: &Did<'static>) -> Option<AtpSession> { 42 + let mut path = self.path.clone(); 43 + path.push(key.to_string()); 44 + let data = tokio::fs::read(&path).await.ok()?; 45 let disk: FileSession = serde_json::from_slice(&data).ok()?; 46 let did = Did::new_owned(disk.did).ok()?; 47 let handle = Handle::new_owned(disk.handle).ok()?; 48 + Some(AtpSession { 49 access_jwt: disk.access_jwt.into(), 50 refresh_jwt: disk.refresh_jwt.into(), 51 did: did.into_static(), ··· 53 }) 54 } 55 56 + async fn set(&self, key: Did<'static>, session: AtpSession) -> Result<(), SessionStoreError> { 57 let disk = FileSession { 58 access_jwt: session.access_jwt.to_string(), 59 refresh_jwt: session.refresh_jwt.to_string(), 60 did: session.did.to_string(), 61 handle: session.handle.to_string(), 62 }; 63 + let buf = serde_json::to_vec_pretty(&disk).map_err(SessionStoreError::from)?; 64 if let Some(parent) = self.path.parent() { 65 tokio::fs::create_dir_all(parent) 66 .await 67 + .map_err(SessionStoreError::from)?; 68 } 69 + let mut path = self.path.clone(); 70 + path.push(key.to_string()); 71 + let tmp = path.with_extension("tmp"); 72 tokio::fs::write(&tmp, &buf) 73 .await 74 + .map_err(SessionStoreError::from)?; 75 + tokio::fs::rename(&tmp, &path) 76 .await 77 + .map_err(SessionStoreError::from)?; 78 Ok(()) 79 } 80 81 + async fn del(&self, key: &Did<'static>) -> Result<(), SessionStoreError> { 82 + let mut path = self.path.clone(); 83 + path.push(key.to_string()); 84 + match tokio::fs::remove_file(&path).await { 85 + Ok(_) => Ok(()), 86 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 87 + Err(e) => Err(SessionStoreError::from(e)), 88 + } 89 + } 90 + 91 + async fn clear(&self) -> Result<(), SessionStoreError> { 92 match tokio::fs::remove_file(&self.path).await { 93 Ok(_) => Ok(()), 94 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 95 + Err(e) => Err(SessionStoreError::from(e)), 96 } 97 } 98 }
-154
crates/jacquard/src/client/xrpc_call.rs
··· 1 - use bytes::Bytes; 2 - use http::{HeaderName, HeaderValue}; 3 - use url::Url; 4 - 5 - use crate::CowStr; 6 - use crate::client::{self as super_mod, Response, error}; 7 - use crate::client::{AuthorizationToken, HttpClient}; 8 - use jacquard_common::types::xrpc::XrpcRequest; 9 - 10 - /// Per-request options for XRPC calls. 11 - #[derive(Debug, Default, Clone)] 12 - pub struct CallOptions<'a> { 13 - /// Optional Authorization to apply (`Bearer` or `DPoP`). 14 - pub auth: Option<AuthorizationToken<'a>>, 15 - /// `atproto-proxy` header value. 16 - pub atproto_proxy: Option<CowStr<'a>>, 17 - /// `atproto-accept-labelers` header values. 18 - pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>, 19 - /// Extra headers to attach to this request. 20 - pub extra_headers: Vec<(HeaderName, HeaderValue)>, 21 - } 22 - 23 - /// Extension for stateless XRPC calls on any `HttpClient`. 24 - /// 25 - /// Example 26 - /// ```ignore 27 - /// use jacquard::client::XrpcExt; 28 - /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 29 - /// use jacquard::types::ident::AtIdentifier; 30 - /// use miette::IntoDiagnostic; 31 - /// 32 - /// #[tokio::main] 33 - /// async fn main() -> miette::Result<()> { 34 - /// let http = reqwest::Client::new(); 35 - /// let base = url::Url::parse("https://public.api.bsky.app")?; 36 - /// let resp = http 37 - /// .xrpc(base) 38 - /// .send( 39 - /// GetAuthorFeed::new() 40 - /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 41 - /// .limit(5) 42 - /// .build(), 43 - /// ) 44 - /// .await?; 45 - /// let out = resp.into_output()?; 46 - /// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 47 - /// Ok(()) 48 - /// } 49 - /// ``` 50 - pub trait XrpcExt: HttpClient { 51 - /// Start building an XRPC call for the given base URL. 52 - fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> 53 - where 54 - Self: Sized, 55 - { 56 - XrpcCall { 57 - client: self, 58 - base, 59 - opts: CallOptions::default(), 60 - } 61 - } 62 - } 63 - 64 - impl<T: HttpClient> XrpcExt for T {} 65 - 66 - /// Stateless XRPC call builder. 67 - /// 68 - /// Example (per-request overrides) 69 - /// ```ignore 70 - /// use jacquard::client::{XrpcExt, AuthorizationToken}; 71 - /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 72 - /// use jacquard::types::ident::AtIdentifier; 73 - /// use jacquard::CowStr; 74 - /// use miette::IntoDiagnostic; 75 - /// 76 - /// #[tokio::main] 77 - /// async fn main() -> miette::Result<()> { 78 - /// let http = reqwest::Client::new(); 79 - /// let base = url::Url::parse("https://public.api.bsky.app")?; 80 - /// let resp = http 81 - /// .xrpc(base) 82 - /// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 83 - /// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 84 - /// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 85 - /// .send( 86 - /// GetAuthorFeed::new() 87 - /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 88 - /// .limit(5) 89 - /// .build(), 90 - /// ) 91 - /// .await?; 92 - /// let out = resp.into_output()?; 93 - /// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 94 - /// Ok(()) 95 - /// } 96 - /// ``` 97 - pub struct XrpcCall<'a, C: HttpClient> { 98 - pub(crate) client: &'a C, 99 - pub(crate) base: Url, 100 - pub(crate) opts: CallOptions<'a>, 101 - } 102 - 103 - impl<'a, C: HttpClient> XrpcCall<'a, C> { 104 - /// Apply Authorization to this call. 105 - pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { 106 - self.opts.auth = Some(token); 107 - self 108 - } 109 - /// Set `atproto-proxy` header for this call. 110 - pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { 111 - self.opts.atproto_proxy = Some(proxy); 112 - self 113 - } 114 - /// Set `atproto-accept-labelers` header(s) for this call. 115 - pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self { 116 - self.opts.atproto_accept_labelers = Some(labelers); 117 - self 118 - } 119 - /// Add an extra header. 120 - pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { 121 - self.opts.extra_headers.push((name, value)); 122 - self 123 - } 124 - /// Replace the builder's options entirely. 125 - pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { 126 - self.opts = opts; 127 - self 128 - } 129 - 130 - /// Send the given typed XRPC request and return a response wrapper. 131 - pub async fn send<R: XrpcRequest + Send>(self, request: R) -> super_mod::Result<Response<R>> { 132 - let http_request = super_mod::build_http_request(&self.base, &request, &self.opts) 133 - .map_err(error::TransportError::from)?; 134 - 135 - let http_response = self 136 - .client 137 - .send_http(http_request) 138 - .await 139 - .map_err(|e| error::TransportError::Other(Box::new(e)))?; 140 - 141 - let status = http_response.status(); 142 - let buffer = Bytes::from(http_response.into_body()); 143 - 144 - if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 145 - return Err(error::HttpError { 146 - status, 147 - body: Some(buffer), 148 - } 149 - .into()); 150 - } 151 - 152 - Ok(Response::new(buffer, status)) 153 - } 154 - }
···
-3
crates/jacquard/src/identity/mod.rs
··· 1 - //! Identity resolution utilities: DID and handle resolution, DID document fetch, 2 - //! and helpers for PDS endpoint discovery. See `identity::resolver` for details. 3 - pub mod resolver;
···
+86 -388
crates/jacquard/src/identity/resolver.rs crates/jacquard/src/identity.rs
··· 1 //! Identity resolution: handle → DID and DID → document, with smart fallbacks. 2 //! 3 //! Fallback order (default): ··· 9 //! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 10 //! and optionally validate the document `id` against the requested DID. 11 12 - use std::collections::BTreeMap; 13 - use std::str::FromStr; 14 - 15 // use crate::CowStr; // not currently needed directly here 16 - use crate::client::XrpcExt; 17 - use bon::Builder; 18 use bytes::Bytes; 19 - use jacquard_common::types::did_doc::Service; 20 - use jacquard_common::types::string::AtprotoStr; 21 - use jacquard_common::types::uri::Uri; 22 - use jacquard_common::types::value::Data; 23 - use jacquard_common::{CowStr, IntoStatic}; 24 - use miette::Diagnostic; 25 use percent_encoding::percent_decode_str; 26 use reqwest::StatusCode; 27 - use thiserror::Error; 28 use url::{ParseError, Url}; 29 30 use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle}; 31 use crate::types::did_doc::DidDocument; 32 use crate::types::ident::AtIdentifier; 33 use crate::types::string::{Did, Handle}; 34 - use crate::types::value::AtDataError; 35 36 #[cfg(feature = "dns")] 37 use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 38 39 - /// Errors that can occur during identity resolution. 40 - /// 41 - /// Note: when validating a fetched DID document against a requested DID, a 42 - /// `DocIdMismatch` error is returned that includes the owned document so callers 43 - /// can inspect it and decide how to proceed. 44 - #[derive(Debug, Error, Diagnostic)] 45 - #[allow(missing_docs)] 46 - pub enum IdentityError { 47 - #[error("unsupported DID method: {0}")] 48 - UnsupportedDidMethod(String), 49 - #[error("invalid well-known atproto-did content")] 50 - InvalidWellKnown, 51 - #[error("missing PDS endpoint in DID document")] 52 - MissingPdsEndpoint, 53 - #[error("HTTP error: {0}")] 54 - Http(#[from] reqwest::Error), 55 - #[error("HTTP status {0}")] 56 - HttpStatus(StatusCode), 57 - #[error("XRPC error: {0}")] 58 - Xrpc(String), 59 - #[error("URL parse error: {0}")] 60 - Url(#[from] url::ParseError), 61 - #[error("DNS error: {0}")] 62 - #[cfg(feature = "dns")] 63 - Dns(#[from] hickory_resolver::error::ResolveError), 64 - #[error("serialize/deserialize error: {0}")] 65 - Serde(#[from] serde_json::Error), 66 - #[error("invalid DID document: {0}")] 67 - InvalidDoc(String), 68 - #[error(transparent)] 69 - Data(#[from] AtDataError), 70 - /// DID document id did not match requested DID; includes the fetched document 71 - #[error("DID doc id mismatch")] 72 - DocIdMismatch { 73 - expected: Did<'static>, 74 - doc: DidDocument<'static>, 75 - }, 76 - } 77 - 78 - /// Source to fetch PLC (did:plc) documents from. 79 - /// 80 - /// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). 81 - /// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as 82 - /// `com.atproto.identity.resolveHandle` and a "mini-doc" 83 - /// endpoint (`com.bad-example.identity.resolveMiniDoc`). 84 - #[derive(Debug, Clone, PartialEq, Eq)] 85 - pub enum PlcSource { 86 - /// Use the public PLC directory 87 - PlcDirectory { 88 - /// Base URL for the PLC directory 89 - base: Url, 90 - }, 91 - /// Use the slingshot mini-docs service 92 - Slingshot { 93 - /// Base URL for the Slingshot service 94 - base: Url, 95 - }, 96 - } 97 - 98 - impl Default for PlcSource { 99 - fn default() -> Self { 100 - Self::PlcDirectory { 101 - base: Url::parse("https://plc.directory/").expect("valid url"), 102 - } 103 - } 104 - } 105 - 106 - impl PlcSource { 107 - /// Default Slingshot source (`https://slingshot.microcosm.blue`) 108 - pub fn slingshot_default() -> Self { 109 - PlcSource::Slingshot { 110 - base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), 111 - } 112 - } 113 - } 114 - 115 - /// DID Document fetch response for borrowed/owned parsing. 116 - /// 117 - /// Carries the raw response bytes and the HTTP status, plus the requested DID 118 - /// (if supplied) to enable validation. Use `parse()` to borrow from the buffer 119 - /// or `parse_validated()` to also enforce that the doc `id` matches the 120 - /// requested DID (returns a `DocIdMismatch` containing the fetched doc on 121 - /// mismatch). Use `into_owned()` to parse into an owned document. 122 - #[derive(Clone)] 123 - pub struct DidDocResponse { 124 - buffer: Bytes, 125 - status: StatusCode, 126 - /// Optional DID we intended to resolve; used for validation helpers 127 - requested: Option<Did<'static>>, 128 - } 129 - 130 - impl DidDocResponse { 131 - /// Parse as borrowed DidDocument<'_> 132 - pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 133 - if self.status.is_success() { 134 - if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 135 - Ok(doc) 136 - } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) { 137 - Ok(DidDocument { 138 - id: mini_doc.did, 139 - also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 140 - verification_method: None, 141 - service: Some(vec![Service { 142 - id: CowStr::new_static("#atproto_pds"), 143 - r#type: CowStr::new_static("AtprotoPersonalDataServer"), 144 - service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 145 - Url::from_str(&mini_doc.pds).unwrap(), 146 - )))), 147 - extra_data: BTreeMap::new(), 148 - }]), 149 - extra_data: BTreeMap::new(), 150 - }) 151 - } else { 152 - Err(IdentityError::MissingPdsEndpoint) 153 - } 154 - } else { 155 - Err(IdentityError::HttpStatus(self.status)) 156 - } 157 - } 158 - 159 - /// Parse and validate that the DID in the document matches the requested DID if present. 160 - /// 161 - /// On mismatch, returns an error that contains the owned document for inspection. 162 - pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 163 - let doc = self.parse()?; 164 - if let Some(expected) = &self.requested { 165 - if doc.id.as_str() != expected.as_str() { 166 - return Err(IdentityError::DocIdMismatch { 167 - expected: expected.clone(), 168 - doc: doc.clone().into_static(), 169 - }); 170 - } 171 - } 172 - Ok(doc) 173 - } 174 - 175 - /// Parse as owned DidDocument<'static> 176 - pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 177 - if self.status.is_success() { 178 - if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 179 - Ok(doc.into_static()) 180 - } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) { 181 - Ok(DidDocument { 182 - id: mini_doc.did, 183 - also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 184 - verification_method: None, 185 - service: Some(vec![Service { 186 - id: CowStr::new_static("#atproto_pds"), 187 - r#type: CowStr::new_static("AtprotoPersonalDataServer"), 188 - service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 189 - Url::from_str(&mini_doc.pds).unwrap(), 190 - )))), 191 - extra_data: BTreeMap::new(), 192 - }]), 193 - extra_data: BTreeMap::new(), 194 - } 195 - .into_static()) 196 - } else { 197 - Err(IdentityError::MissingPdsEndpoint) 198 - } 199 - } else { 200 - Err(IdentityError::HttpStatus(self.status)) 201 - } 202 - } 203 - } 204 - 205 - /// Handle → DID fallback step. 206 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 207 - pub enum HandleStep { 208 - /// DNS TXT _atproto.\<handle\> 209 - DnsTxt, 210 - /// HTTPS GET https://\<handle\>/.well-known/atproto-did 211 - HttpsWellKnown, 212 - /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 213 - PdsResolveHandle, 214 - } 215 - 216 - /// DID → Doc fallback step. 217 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 218 - pub enum DidStep { 219 - /// For did:web: fetch from the well-known location 220 - DidWebHttps, 221 - /// For did:plc: fetch from PLC source 222 - PlcHttp, 223 - /// If a PDS base is known, ask it for the DID doc 224 - PdsResolveDid, 225 - } 226 - 227 - /// Configurable resolver options. 228 - /// 229 - /// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 230 - /// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless 231 - /// XRPC over reqwest; authentication can be layered as needed). 232 - /// - `handle_order`/`did_order`: ordered strategies for resolution. 233 - /// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 234 - /// returning `DocIdMismatch` with the fetched document on mismatch. 235 - /// - `public_fallback_for_handle`: if true (default), attempt 236 - /// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 237 - /// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC 238 - /// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 239 - #[derive(Debug, Clone, Builder)] 240 - #[builder(start_fn = new)] 241 - pub struct ResolverOptions { 242 - /// PLC data source (directory or slingshot) 243 - pub plc_source: PlcSource, 244 - /// Optional PDS base to use for fallbacks 245 - pub pds_fallback: Option<Url>, 246 - /// Order of attempts for handle → DID resolution 247 - pub handle_order: Vec<HandleStep>, 248 - /// Order of attempts for DID → Doc resolution 249 - pub did_order: Vec<DidStep>, 250 - /// Validate that fetched DID document id matches the requested DID 251 - pub validate_doc_id: bool, 252 - /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 253 - pub public_fallback_for_handle: bool, 254 - } 255 - 256 - impl Default for ResolverOptions { 257 - fn default() -> Self { 258 - // By default, prefer DNS then HTTPS for handles, then PDS fallback 259 - // For DID documents, prefer method-native sources, then PDS fallback 260 - Self::new() 261 - .plc_source(PlcSource::default()) 262 - .handle_order(vec![ 263 - HandleStep::DnsTxt, 264 - HandleStep::HttpsWellKnown, 265 - HandleStep::PdsResolveHandle, 266 - ]) 267 - .did_order(vec![ 268 - DidStep::DidWebHttps, 269 - DidStep::PlcHttp, 270 - DidStep::PdsResolveDid, 271 - ]) 272 - .validate_doc_id(true) 273 - .public_fallback_for_handle(true) 274 - .build() 275 - } 276 - } 277 - 278 - /// Trait for identity resolution, for pluggable implementations. 279 - /// 280 - /// The provided `DefaultResolver` supports: 281 - /// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 282 - /// - HTTPS well-known for handles and `did:web` 283 - /// - PLC directory or Slingshot for `did:plc` 284 - /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 285 - /// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 286 - #[async_trait::async_trait] 287 - pub trait IdentityResolver { 288 - /// Access options for validation decisions in default methods 289 - fn options(&self) -> &ResolverOptions; 290 - 291 - /// Resolve handle 292 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>; 293 - 294 - /// Resolve DID document 295 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>; 296 - 297 - /// Resolve the DID document and return an owned version 298 - async fn resolve_did_doc_owned( 299 - &self, 300 - did: &Did<'_>, 301 - ) -> Result<DidDocument<'static>, IdentityError> { 302 - self.resolve_did_doc(did).await?.into_owned() 303 - } 304 - /// reutrn the PDS url for a DID 305 - async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 306 - let resp = self.resolve_did_doc(did).await?; 307 - let doc = resp.parse()?; 308 - // Default-on doc id equality check 309 - if self.options().validate_doc_id { 310 - if doc.id.as_str() != did.as_str() { 311 - return Err(IdentityError::DocIdMismatch { 312 - expected: did.clone().into_static(), 313 - doc: doc.clone().into_static(), 314 - }); 315 - } 316 - } 317 - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 318 - } 319 - /// Return the DIS and PDS url for a handle 320 - async fn pds_for_handle( 321 - &self, 322 - handle: &Handle<'_>, 323 - ) -> Result<(Did<'static>, Url), IdentityError> { 324 - let did = self.resolve_handle(handle).await?; 325 - let pds = self.pds_for_did(&did).await?; 326 - Ok((did, pds)) 327 - } 328 - } 329 - 330 /// Default resolver implementation with configurable fallback order. 331 pub struct DefaultResolver { 332 http: reqwest::Client, ··· 343 opts, 344 #[cfg(feature = "dns")] 345 dns: None, 346 } 347 } 348 ··· 415 } 416 417 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 418 - let resp = self.http.get(url).send().await?; 419 let status = resp.status(); 420 - let buf = resp.bytes().await?; 421 Ok((buf, status)) 422 } 423 424 async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 425 - let resp = self.http.get(url).send().await?; 426 if resp.status() == StatusCode::OK { 427 - Ok(resp.text().await?) 428 } else { 429 - Err(IdentityError::Http(resp.error_for_status().unwrap_err())) 430 } 431 } 432 ··· 684 } 685 } 686 687 /// Warnings produced during identity checks that are not fatal 688 #[derive(Debug, Clone, PartialEq, Eq)] 689 pub enum IdentityWarning { ··· 778 } 779 } 780 781 - /// Slingshot mini-doc data (subset of DID doc info) 782 - #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 783 - #[serde(rename_all = "camelCase")] 784 - #[allow(missing_docs)] 785 - pub struct MiniDoc<'a> { 786 - #[serde(borrow)] 787 - pub did: Did<'a>, 788 - #[serde(borrow)] 789 - pub handle: Handle<'a>, 790 - #[serde(borrow)] 791 - pub pds: crate::CowStr<'a>, 792 - #[serde(borrow, rename = "signingKey", alias = "signing_key")] 793 - pub signing_key: crate::CowStr<'a>, 794 } 795 796 #[cfg(test)] ··· 811 } 812 813 #[test] 814 - fn parse_validated_ok() { 815 - let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 816 - let requested = Did::new_owned("did:plc:alice").unwrap(); 817 - let resp = DidDocResponse { 818 - buffer: buf, 819 - status: StatusCode::OK, 820 - requested: Some(requested), 821 - }; 822 - let _doc = resp.parse_validated().expect("valid"); 823 - } 824 - 825 - #[test] 826 - fn parse_validated_mismatch() { 827 - let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 828 - let requested = Did::new_owned("did:plc:alice").unwrap(); 829 - let resp = DidDocResponse { 830 - buffer: buf, 831 - status: StatusCode::OK, 832 - requested: Some(requested), 833 - }; 834 - match resp.parse_validated() { 835 - Err(IdentityError::DocIdMismatch { expected, doc }) => { 836 - assert_eq!(expected.as_str(), "did:plc:alice"); 837 - assert_eq!(doc.id.as_str(), "did:plc:bob"); 838 - } 839 - other => panic!("unexpected result: {:?}", other), 840 - } 841 - } 842 - 843 - #[test] 844 fn slingshot_mini_doc_url_build() { 845 let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default()); 846 let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); ··· 893 } 894 } 895 } 896 - 897 - /// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC 898 - pub type PublicResolver = DefaultResolver; 899 - 900 - impl Default for PublicResolver { 901 - /// Build a resolver with: 902 - /// - reqwest HTTP client 903 - /// - Public fallbacks enabled for handle resolution 904 - /// - default options (DNS enabled if compiled, public fallback for handles enabled) 905 - /// 906 - /// Example 907 - /// ```ignore 908 - /// use jacquard::identity::resolver::PublicResolver; 909 - /// let resolver = PublicResolver::default(); 910 - /// ``` 911 - fn default() -> Self { 912 - let http = reqwest::Client::new(); 913 - let opts = ResolverOptions::default(); 914 - let resolver = DefaultResolver::new(http, opts); 915 - #[cfg(feature = "dns")] 916 - let resolver = resolver.with_system_dns(); 917 - resolver 918 - } 919 - } 920 - 921 - /// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 922 - /// mini-doc fallbacks, unauthenticated by default. 923 - pub fn slingshot_resolver_default() -> PublicResolver { 924 - let http = reqwest::Client::new(); 925 - let mut opts = ResolverOptions::default(); 926 - opts.plc_source = PlcSource::slingshot_default(); 927 - let resolver = DefaultResolver::new(http, opts); 928 - #[cfg(feature = "dns")] 929 - let resolver = resolver.with_system_dns(); 930 - resolver 931 - }
··· 1 + //! Identity resolution utilities: DID and handle resolution, DID document fetch, 2 + //! and helpers for PDS endpoint discovery. See `identity::resolver` for details. 3 //! Identity resolution: handle → DID and DID → document, with smart fallbacks. 4 //! 5 //! Fallback order (default): ··· 11 //! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 12 //! and optionally validate the document `id` against the requested DID. 13 14 // use crate::CowStr; // not currently needed directly here 15 + 16 use bytes::Bytes; 17 + use jacquard_common::IntoStatic; 18 + use jacquard_common::error::TransportError; 19 + use jacquard_common::http_client::HttpClient; 20 + use jacquard_common::ident_resolver::{ 21 + DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource, 22 + ResolverOptions, 23 + }; 24 + use jacquard_common::types::xrpc::XrpcExt; 25 use percent_encoding::percent_decode_str; 26 use reqwest::StatusCode; 27 use url::{ParseError, Url}; 28 29 use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle}; 30 use crate::types::did_doc::DidDocument; 31 use crate::types::ident::AtIdentifier; 32 use crate::types::string::{Did, Handle}; 33 34 #[cfg(feature = "dns")] 35 use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 36 37 /// Default resolver implementation with configurable fallback order. 38 pub struct DefaultResolver { 39 http: reqwest::Client, ··· 50 opts, 51 #[cfg(feature = "dns")] 52 dns: None, 53 + } 54 + } 55 + 56 + #[cfg(feature = "dns")] 57 + /// Create a new instance of the default resolver with all options, plus default DNS, up front 58 + pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self { 59 + Self { 60 + http, 61 + opts, 62 + dns: Some(TokioAsyncResolver::tokio( 63 + ResolverConfig::default(), 64 + Default::default(), 65 + )), 66 } 67 } 68 ··· 135 } 136 137 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 138 + let resp = self 139 + .http 140 + .get(url) 141 + .send() 142 + .await 143 + .map_err(TransportError::from)?; 144 let status = resp.status(); 145 + let buf = resp.bytes().await.map_err(TransportError::from)?; 146 Ok((buf, status)) 147 } 148 149 async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 150 + let resp = self 151 + .http 152 + .get(url) 153 + .send() 154 + .await 155 + .map_err(TransportError::from)?; 156 if resp.status() == StatusCode::OK { 157 + Ok(resp.text().await.map_err(TransportError::from)?) 158 } else { 159 + Err(IdentityError::Http( 160 + resp.error_for_status().unwrap_err().into(), 161 + )) 162 } 163 } 164 ··· 416 } 417 } 418 419 + impl HttpClient for DefaultResolver { 420 + async fn send_http( 421 + &self, 422 + request: http::Request<Vec<u8>>, 423 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 424 + self.http.send_http(request).await 425 + } 426 + 427 + type Error = reqwest::Error; 428 + } 429 + 430 /// Warnings produced during identity checks that are not fatal 431 #[derive(Debug, Clone, PartialEq, Eq)] 432 pub enum IdentityWarning { ··· 521 } 522 } 523 524 + /// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC 525 + pub type PublicResolver = DefaultResolver; 526 + 527 + impl Default for PublicResolver { 528 + /// Build a resolver with: 529 + /// - reqwest HTTP client 530 + /// - Public fallbacks enabled for handle resolution 531 + /// - default options (DNS enabled if compiled, public fallback for handles enabled) 532 + /// 533 + /// Example 534 + /// ```ignore 535 + /// use jacquard::identity::resolver::PublicResolver; 536 + /// let resolver = PublicResolver::default(); 537 + /// ``` 538 + fn default() -> Self { 539 + let http = reqwest::Client::new(); 540 + let opts = ResolverOptions::default(); 541 + let resolver = DefaultResolver::new(http, opts); 542 + #[cfg(feature = "dns")] 543 + let resolver = resolver.with_system_dns(); 544 + resolver 545 + } 546 + } 547 + 548 + /// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 549 + /// mini-doc fallbacks, unauthenticated by default. 550 + pub fn slingshot_resolver_default() -> PublicResolver { 551 + let http = reqwest::Client::new(); 552 + let mut opts = ResolverOptions::default(); 553 + opts.plc_source = PlcSource::slingshot_default(); 554 + let resolver = DefaultResolver::new(http, opts); 555 + #[cfg(feature = "dns")] 556 + let resolver = resolver.with_system_dns(); 557 + resolver 558 } 559 560 #[cfg(test)] ··· 575 } 576 577 #[test] 578 fn slingshot_mini_doc_url_build() { 579 let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default()); 580 let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); ··· 627 } 628 } 629 }
+14 -11
crates/jacquard/src/lib.rs
··· 24 //! # use jacquard::CowStr; 25 //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 26 //! use jacquard::api::com_atproto::server::create_session::CreateSession; 27 - //! use jacquard::client::{BasicClient, Session}; 28 //! # use miette::IntoDiagnostic; 29 //! 30 //! # #[derive(Parser, Debug)] ··· 50 //! let url = url::Url::parse(&args.pds).unwrap(); 51 //! let client = BasicClient::new(url); 52 //! // Create session 53 - //! let session = Session::from( 54 //! client 55 //! .send( 56 //! CreateSession::new() ··· 87 //! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you 88 //! want to pass auth on each call or build advanced flows. 89 //! ```no_run 90 - //! # use jacquard::client::XrpcExt; 91 //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 92 //! # use jacquard::types::ident::AtIdentifier; 93 //! # 94 //! #[tokio::main] 95 - //! async fn main() -> anyhow::Result<()> { 96 //! let http = reqwest::Client::new(); 97 - //! let base = url::Url::parse("https://public.api.bsky.app")?; 98 //! let resp = http 99 //! .xrpc(base) 100 //! .send( ··· 110 //! } 111 //! ``` 112 //! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a 113 - //! `TokenStore` implementation. It automatically sets Authorization and can 114 //! auto-refresh a session when expired, retrying once. 115 //! - Convenience wrapper: `BasicClient` is an ergonomic newtype over 116 - //! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor. 117 //! 118 //! Per-request overrides (stateless) 119 //! ```no_run 120 - //! # use jacquard::client::{XrpcExt, AuthorizationToken}; 121 //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 122 //! # use jacquard::types::ident::AtIdentifier; 123 //! # use jacquard::CowStr; ··· 126 //! #[tokio::main] 127 //! async fn main() -> miette::Result<()> { 128 //! let http = reqwest::Client::new(); 129 - //! let base = url::Url::parse("https://public.api.bsky.app")?; 130 //! let resp = http 131 //! .xrpc(base) 132 //! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) ··· 146 //! ``` 147 //! 148 //! Token storage: 149 - //! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs. 150 - //! - For persistence, `FileTokenStore` stores session tokens as JSON on disk. 151 //! See `client::token::FileTokenStore` docs for details. 152 //! ```no_run 153 //! use jacquard::client::{AtClient, FileTokenStore}; ··· 161 162 /// XRPC client traits and basic implementation 163 pub mod client; 164 165 #[cfg(feature = "api")] 166 /// If enabled, re-export the generated api crate
··· 24 //! # use jacquard::CowStr; 25 //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 26 //! use jacquard::api::com_atproto::server::create_session::CreateSession; 27 + //! use jacquard::client::{BasicClient, AuthSession, AtpSession}; 28 //! # use miette::IntoDiagnostic; 29 //! 30 //! # #[derive(Parser, Debug)] ··· 50 //! let url = url::Url::parse(&args.pds).unwrap(); 51 //! let client = BasicClient::new(url); 52 //! // Create session 53 + //! let session = AtpSession::from( 54 //! client 55 //! .send( 56 //! CreateSession::new() ··· 87 //! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you 88 //! want to pass auth on each call or build advanced flows. 89 //! ```no_run 90 + //! # use jacquard::types::xrpc::XrpcExt; 91 //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 92 //! # use jacquard::types::ident::AtIdentifier; 93 + //! # use miette::IntoDiagnostic; 94 //! # 95 //! #[tokio::main] 96 + //! async fn main() -> miette::Result<()> { 97 //! let http = reqwest::Client::new(); 98 + //! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; 99 //! let resp = http 100 //! .xrpc(base) 101 //! .send( ··· 111 //! } 112 //! ``` 113 //! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a 114 + //! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can 115 //! auto-refresh a session when expired, retrying once. 116 //! - Convenience wrapper: `BasicClient` is an ergonomic newtype over 117 + //! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` with a `new(Url)` constructor. 118 //! 119 //! Per-request overrides (stateless) 120 //! ```no_run 121 + //! # use jacquard::AuthorizationToken; 122 + //! # use jacquard::types::xrpc::XrpcExt; 123 //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 124 //! # use jacquard::types::ident::AtIdentifier; 125 //! # use jacquard::CowStr; ··· 128 //! #[tokio::main] 129 //! async fn main() -> miette::Result<()> { 130 //! let http = reqwest::Client::new(); 131 + //! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; 132 //! let resp = http 133 //! .xrpc(base) 134 //! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) ··· 148 //! ``` 149 //! 150 //! Token storage: 151 + //! - Use `MemorySessionStore<AuthSession>` for ephemeral sessions, tests, and CLIs. 152 + //! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk. 153 //! See `client::token::FileTokenStore` docs for details. 154 //! ```no_run 155 //! use jacquard::client::{AtClient, FileTokenStore}; ··· 163 164 /// XRPC client traits and basic implementation 165 pub mod client; 166 + /// OAuth usage helpers (discovery, PAR, token exchange) 167 168 #[cfg(feature = "api")] 169 /// If enabled, re-export the generated api crate
+5 -4
crates/jacquard/src/main.rs
··· 2 use jacquard::CowStr; 3 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 use jacquard::api::com_atproto::server::create_session::CreateSession; 5 - use jacquard::client::{BasicClient, Session}; 6 - use jacquard::identity::resolver::{IdentityResolver, slingshot_resolver_default}; 7 use jacquard::types::string::Handle; 8 use miette::IntoDiagnostic; 9 ··· 33 let client = BasicClient::new(pds_url); 34 35 // Create session 36 - let session = Session::from( 37 client 38 .send( 39 CreateSession::new() ··· 46 ); 47 48 println!("logged in as {} ({})", session.handle, session.did); 49 - client.set_session(session).await.into_diagnostic()?; 50 51 // Fetch timeline 52 println!("\nfetching timeline...");
··· 2 use jacquard::CowStr; 3 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 use jacquard::api::com_atproto::server::create_session::CreateSession; 5 + use jacquard::client::{AtpSession, AuthSession, BasicClient}; 6 + use jacquard::ident_resolver::IdentityResolver; 7 + use jacquard::identity::slingshot_resolver_default; 8 use jacquard::types::string::Handle; 9 use miette::IntoDiagnostic; 10 ··· 34 let client = BasicClient::new(pds_url); 35 36 // Create session 37 + let session = AtpSession::from( 38 client 39 .send( 40 CreateSession::new() ··· 47 ); 48 49 println!("logged in as {} ({})", session.handle, session.did); 50 + client.set_session(session.into()).await.into_diagnostic()?; 51 52 // Fetch timeline 53 println!("\nfetching timeline...");