A better Rust ATProto crate

Compare changes

Choose any two refs to compare.

+2
.gitignore
··· 9 9 crates/jacquard-lexicon/tests/fixtures/lexicons/atproto 10 10 crates/jacquard-lexicon/target 11 11 codegen_plan.md 12 + /lex_js 13 + /docs
+587 -27
Cargo.lock
··· 37 37 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 38 38 39 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]] 40 46 name = "aho-corasick" 41 47 version = "1.1.3" 42 48 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 52 58 checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 53 59 54 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]] 55 76 name = "android_system_properties" 56 77 version = "0.1.5" 57 78 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 109 130 "once_cell_polyfill", 110 131 "windows-sys 0.60.2", 111 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" 112 139 113 140 [[package]] 114 141 name = "async-compression" ··· 184 211 185 212 [[package]] 186 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" 187 220 version = "0.22.1" 188 221 source = "registry+https://github.com/rust-lang/crates.io-index" 189 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" 190 229 191 230 [[package]] 192 231 name = "bitflags" ··· 205 244 206 245 [[package]] 207 246 name = "bon" 208 - version = "3.7.2" 247 + version = "3.8.0" 209 248 source = "registry+https://github.com/rust-lang/crates.io-index" 210 - checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb" 249 + checksum = "f44aa969f86ffb99e5c2d51f393ec9ed6e9fe2f47b609c917b0071f129854d29" 211 250 dependencies = [ 212 251 "bon-macros", 213 252 "rustversion", ··· 215 254 216 255 [[package]] 217 256 name = "bon-macros" 218 - version = "3.7.2" 257 + version = "3.8.0" 219 258 source = "registry+https://github.com/rust-lang/crates.io-index" 220 - checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005" 259 + checksum = "e1e78cd86b6a6515d87392332fd63c4950ed3e50eab54275259a5f59f3666f90" 221 260 dependencies = [ 222 261 "darling", 223 262 "ident_case", ··· 238 277 ] 239 278 240 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]] 241 301 name = "btree-range-map" 242 302 version = "0.7.2" 243 303 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 262 322 ] 263 323 264 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]] 265 335 name = "bumpalo" 266 336 version = "3.19.0" 267 337 source = "registry+https://github.com/rust-lang/crates.io-index" 268 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" 269 345 270 346 [[package]] 271 347 name = "bytes" ··· 331 407 ] 332 408 333 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]] 334 416 name = "ciborium" 335 417 version = "0.2.2" 336 418 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 484 566 ] 485 567 486 568 [[package]] 569 + name = "crossbeam-utils" 570 + version = "0.8.21" 571 + source = "registry+https://github.com/rust-lang/crates.io-index" 572 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 573 + 574 + [[package]] 487 575 name = "crunchy" 488 576 version = "0.2.4" 489 577 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 573 661 ] 574 662 575 663 [[package]] 664 + name = "dashmap" 665 + version = "6.1.0" 666 + source = "registry+https://github.com/rust-lang/crates.io-index" 667 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 668 + dependencies = [ 669 + "cfg-if", 670 + "crossbeam-utils", 671 + "hashbrown 0.14.5", 672 + "lock_api", 673 + "once_cell", 674 + "parking_lot_core", 675 + ] 676 + 677 + [[package]] 576 678 name = "data-encoding" 577 679 version = "2.9.0" 578 680 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 599 701 ] 600 702 601 703 [[package]] 704 + name = "deflate" 705 + version = "1.0.0" 706 + source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 708 + dependencies = [ 709 + "adler32", 710 + "gzip-header", 711 + ] 712 + 713 + [[package]] 602 714 name = "der" 603 715 version = "0.7.10" 604 716 source = "registry+https://github.com/rust-lang/crates.io-index" 605 717 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 606 718 dependencies = [ 607 719 "const-oid", 720 + "pem-rfc7468", 608 721 "zeroize", 609 722 ] 610 723 ··· 625 738 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 626 739 dependencies = [ 627 740 "block-buffer", 741 + "const-oid", 628 742 "crypto-common", 743 + "subtle", 629 744 ] 630 745 631 746 [[package]] ··· 646 761 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 647 762 648 763 [[package]] 764 + name = "ecdsa" 765 + version = "0.16.9" 766 + source = "registry+https://github.com/rust-lang/crates.io-index" 767 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 768 + dependencies = [ 769 + "der", 770 + "digest", 771 + "elliptic-curve", 772 + "rfc6979", 773 + "signature", 774 + "spki", 775 + ] 776 + 777 + [[package]] 649 778 name = "ed25519" 650 779 version = "2.2.3" 651 780 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 681 810 dependencies = [ 682 811 "base16ct", 683 812 "crypto-bigint", 813 + "digest", 684 814 "ff", 685 815 "generic-array", 686 816 "group", 817 + "pem-rfc7468", 818 + "pkcs8", 687 819 "rand_core 0.6.4", 688 820 "sec1", 689 821 "subtle", ··· 712 844 ] 713 845 714 846 [[package]] 715 - name = "enum_dispatch" 716 - version = "0.3.13" 717 - source = "registry+https://github.com/rust-lang/crates.io-index" 718 - checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" 719 - dependencies = [ 720 - "once_cell", 721 - "proc-macro2", 722 - "quote", 723 - "syn 2.0.106", 724 - ] 725 - 726 - [[package]] 727 847 name = "equivalent" 728 848 version = "1.0.2" 729 849 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 762 882 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 763 883 764 884 [[package]] 885 + name = "filetime" 886 + version = "0.2.26" 887 + source = "registry+https://github.com/rust-lang/crates.io-index" 888 + checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" 889 + dependencies = [ 890 + "cfg-if", 891 + "libc", 892 + "libredox", 893 + "windows-sys 0.60.2", 894 + ] 895 + 896 + [[package]] 765 897 name = "find-msvc-tools" 766 898 version = "0.1.2" 767 899 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 894 1026 ] 895 1027 896 1028 [[package]] 1029 + name = "gzip-header" 1030 + version = "1.0.0" 1031 + source = "registry+https://github.com/rust-lang/crates.io-index" 1032 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1033 + dependencies = [ 1034 + "crc32fast", 1035 + ] 1036 + 1037 + [[package]] 897 1038 name = "h2" 898 1039 version = "0.4.12" 899 1040 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 930 1071 931 1072 [[package]] 932 1073 name = "hashbrown" 1074 + version = "0.14.5" 1075 + source = "registry+https://github.com/rust-lang/crates.io-index" 1076 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1077 + 1078 + [[package]] 1079 + name = "hashbrown" 933 1080 version = "0.16.0" 934 1081 source = "registry+https://github.com/rust-lang/crates.io-index" 935 1082 checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" ··· 945 1092 version = "0.5.0" 946 1093 source = "registry+https://github.com/rust-lang/crates.io-index" 947 1094 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1095 + 1096 + [[package]] 1097 + name = "hermit-abi" 1098 + version = "0.5.2" 1099 + source = "registry+https://github.com/rust-lang/crates.io-index" 1100 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 948 1101 949 1102 [[package]] 950 1103 name = "hex" ··· 1004 1157 ] 1005 1158 1006 1159 [[package]] 1160 + name = "hmac" 1161 + version = "0.12.1" 1162 + source = "registry+https://github.com/rust-lang/crates.io-index" 1163 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1164 + dependencies = [ 1165 + "digest", 1166 + ] 1167 + 1168 + [[package]] 1007 1169 name = "http" 1008 1170 version = "1.3.1" 1009 1171 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1042 1204 version = "1.10.1" 1043 1205 source = "registry+https://github.com/rust-lang/crates.io-index" 1044 1206 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1207 + 1208 + [[package]] 1209 + name = "httpdate" 1210 + version = "1.0.3" 1211 + source = "registry+https://github.com/rust-lang/crates.io-index" 1212 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1045 1213 1046 1214 [[package]] 1047 1215 name = "hyper" ··· 1088 1256 source = "registry+https://github.com/rust-lang/crates.io-index" 1089 1257 checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1090 1258 dependencies = [ 1091 - "base64", 1259 + "base64 0.22.1", 1092 1260 "bytes", 1093 1261 "futures-channel", 1094 1262 "futures-core", ··· 1353 1521 1354 1522 [[package]] 1355 1523 name = "jacquard" 1356 - version = "0.1.0" 1524 + version = "0.3.0" 1357 1525 dependencies = [ 1358 1526 "async-trait", 1359 1527 "bon", 1360 1528 "bytes", 1361 1529 "clap", 1362 - "hickory-resolver", 1363 1530 "http", 1364 1531 "jacquard-api", 1365 1532 "jacquard-common", 1366 1533 "jacquard-derive", 1534 + "jacquard-identity", 1535 + "jacquard-oauth", 1536 + "jose-jwk", 1367 1537 "miette", 1538 + "p256", 1368 1539 "percent-encoding", 1540 + "rand_core 0.6.4", 1369 1541 "reqwest", 1542 + "rouille", 1370 1543 "serde", 1371 1544 "serde_html_form", 1372 1545 "serde_ipld_dagcbor", ··· 1380 1553 1381 1554 [[package]] 1382 1555 name = "jacquard-api" 1383 - version = "0.1.0" 1556 + version = "0.3.0" 1384 1557 dependencies = [ 1385 1558 "bon", 1386 1559 "bytes", ··· 1393 1566 1394 1567 [[package]] 1395 1568 name = "jacquard-common" 1396 - version = "0.1.0" 1569 + version = "0.3.0" 1397 1570 dependencies = [ 1398 - "base64", 1571 + "async-trait", 1572 + "base64 0.22.1", 1399 1573 "bon", 1400 1574 "bytes", 1401 1575 "chrono", 1402 1576 "cid", 1403 1577 "ed25519-dalek", 1404 - "enum_dispatch", 1578 + "http", 1405 1579 "ipld-core", 1406 1580 "k256", 1407 1581 "langtag", ··· 1413 1587 "p256", 1414 1588 "rand 0.9.2", 1415 1589 "regex", 1590 + "reqwest", 1416 1591 "serde", 1417 1592 "serde_html_form", 1593 + "serde_ipld_dagcbor", 1418 1594 "serde_json", 1419 1595 "serde_with", 1420 1596 "smol_str", 1421 1597 "thiserror 2.0.17", 1598 + "tokio", 1599 + "trait-variant", 1422 1600 "url", 1423 1601 ] 1424 1602 1425 1603 [[package]] 1426 1604 name = "jacquard-derive" 1427 - version = "0.1.0" 1605 + version = "0.3.0" 1428 1606 dependencies = [ 1429 1607 "heck 0.5.0", 1430 1608 "itertools", ··· 1440 1618 ] 1441 1619 1442 1620 [[package]] 1621 + name = "jacquard-identity" 1622 + version = "0.3.0" 1623 + dependencies = [ 1624 + "async-trait", 1625 + "bon", 1626 + "bytes", 1627 + "hickory-resolver", 1628 + "http", 1629 + "jacquard-api", 1630 + "jacquard-common", 1631 + "miette", 1632 + "percent-encoding", 1633 + "reqwest", 1634 + "serde", 1635 + "serde_html_form", 1636 + "serde_json", 1637 + "thiserror 2.0.17", 1638 + "tokio", 1639 + "url", 1640 + "urlencoding", 1641 + ] 1642 + 1643 + [[package]] 1443 1644 name = "jacquard-lexicon" 1444 - version = "0.1.0" 1645 + version = "0.3.0" 1445 1646 dependencies = [ 1446 1647 "clap", 1447 1648 "heck 0.5.0", ··· 1461 1662 ] 1462 1663 1463 1664 [[package]] 1665 + name = "jacquard-oauth" 1666 + version = "0.3.0" 1667 + dependencies = [ 1668 + "async-trait", 1669 + "base64 0.22.1", 1670 + "bytes", 1671 + "chrono", 1672 + "dashmap", 1673 + "elliptic-curve", 1674 + "http", 1675 + "jacquard-common", 1676 + "jacquard-identity", 1677 + "jose-jwa", 1678 + "jose-jwk", 1679 + "miette", 1680 + "p256", 1681 + "rand 0.8.5", 1682 + "rand_core 0.6.4", 1683 + "reqwest", 1684 + "serde", 1685 + "serde_html_form", 1686 + "serde_json", 1687 + "sha2", 1688 + "signature", 1689 + "smol_str", 1690 + "thiserror 2.0.17", 1691 + "tokio", 1692 + "trait-variant", 1693 + "url", 1694 + "uuid", 1695 + ] 1696 + 1697 + [[package]] 1698 + name = "jose-b64" 1699 + version = "0.1.2" 1700 + source = "registry+https://github.com/rust-lang/crates.io-index" 1701 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 1702 + dependencies = [ 1703 + "base64ct", 1704 + "serde", 1705 + "subtle", 1706 + "zeroize", 1707 + ] 1708 + 1709 + [[package]] 1710 + name = "jose-jwa" 1711 + version = "0.1.2" 1712 + source = "registry+https://github.com/rust-lang/crates.io-index" 1713 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 1714 + dependencies = [ 1715 + "serde", 1716 + ] 1717 + 1718 + [[package]] 1719 + name = "jose-jwk" 1720 + version = "0.1.2" 1721 + source = "registry+https://github.com/rust-lang/crates.io-index" 1722 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 1723 + dependencies = [ 1724 + "jose-b64", 1725 + "jose-jwa", 1726 + "p256", 1727 + "p384", 1728 + "rsa", 1729 + "serde", 1730 + "zeroize", 1731 + ] 1732 + 1733 + [[package]] 1464 1734 name = "js-sys" 1465 1735 version = "0.3.81" 1466 1736 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1492 1762 ] 1493 1763 1494 1764 [[package]] 1765 + name = "lazy_static" 1766 + version = "1.5.0" 1767 + source = "registry+https://github.com/rust-lang/crates.io-index" 1768 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1769 + dependencies = [ 1770 + "spin", 1771 + ] 1772 + 1773 + [[package]] 1495 1774 name = "libc" 1496 1775 version = "0.2.176" 1497 1776 source = "registry+https://github.com/rust-lang/crates.io-index" 1498 1777 checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 1778 + 1779 + [[package]] 1780 + name = "libm" 1781 + version = "0.2.15" 1782 + source = "registry+https://github.com/rust-lang/crates.io-index" 1783 + checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" 1784 + 1785 + [[package]] 1786 + name = "libredox" 1787 + version = "0.1.10" 1788 + source = "registry+https://github.com/rust-lang/crates.io-index" 1789 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 1790 + dependencies = [ 1791 + "bitflags", 1792 + "libc", 1793 + "redox_syscall", 1794 + ] 1499 1795 1500 1796 [[package]] 1501 1797 name = "linked-hash-map" ··· 1588 1884 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1589 1885 1590 1886 [[package]] 1887 + name = "mime_guess" 1888 + version = "2.0.5" 1889 + source = "registry+https://github.com/rust-lang/crates.io-index" 1890 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1891 + dependencies = [ 1892 + "mime", 1893 + "unicase", 1894 + ] 1895 + 1896 + [[package]] 1591 1897 name = "minimal-lexical" 1592 1898 version = "0.2.1" 1593 1899 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1636 1942 ] 1637 1943 1638 1944 [[package]] 1945 + name = "multipart" 1946 + version = "0.18.0" 1947 + source = "registry+https://github.com/rust-lang/crates.io-index" 1948 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 1949 + dependencies = [ 1950 + "buf_redux", 1951 + "httparse", 1952 + "log", 1953 + "mime", 1954 + "mime_guess", 1955 + "quick-error", 1956 + "rand 0.8.5", 1957 + "safemem", 1958 + "tempfile", 1959 + "twoway", 1960 + ] 1961 + 1962 + [[package]] 1639 1963 name = "nom" 1640 1964 version = "7.1.3" 1641 1965 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1646 1970 ] 1647 1971 1648 1972 [[package]] 1973 + name = "num-bigint-dig" 1974 + version = "0.8.4" 1975 + source = "registry+https://github.com/rust-lang/crates.io-index" 1976 + checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" 1977 + dependencies = [ 1978 + "byteorder", 1979 + "lazy_static", 1980 + "libm", 1981 + "num-integer", 1982 + "num-iter", 1983 + "num-traits", 1984 + "rand 0.8.5", 1985 + "smallvec", 1986 + "zeroize", 1987 + ] 1988 + 1989 + [[package]] 1649 1990 name = "num-conv" 1650 1991 version = "0.1.0" 1651 1992 source = "registry+https://github.com/rust-lang/crates.io-index" 1652 1993 checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1653 1994 1654 1995 [[package]] 1996 + name = "num-integer" 1997 + version = "0.1.46" 1998 + source = "registry+https://github.com/rust-lang/crates.io-index" 1999 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 2000 + dependencies = [ 2001 + "num-traits", 2002 + ] 2003 + 2004 + [[package]] 2005 + name = "num-iter" 2006 + version = "0.1.45" 2007 + source = "registry+https://github.com/rust-lang/crates.io-index" 2008 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 2009 + dependencies = [ 2010 + "autocfg", 2011 + "num-integer", 2012 + "num-traits", 2013 + ] 2014 + 2015 + [[package]] 1655 2016 name = "num-traits" 1656 2017 version = "0.2.19" 1657 2018 source = "registry+https://github.com/rust-lang/crates.io-index" 1658 2019 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1659 2020 dependencies = [ 1660 2021 "autocfg", 2022 + "libm", 2023 + ] 2024 + 2025 + [[package]] 2026 + name = "num_cpus" 2027 + version = "1.17.0" 2028 + source = "registry+https://github.com/rust-lang/crates.io-index" 2029 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 2030 + dependencies = [ 2031 + "hermit-abi", 2032 + "libc", 2033 + ] 2034 + 2035 + [[package]] 2036 + name = "num_threads" 2037 + version = "0.1.7" 2038 + source = "registry+https://github.com/rust-lang/crates.io-index" 2039 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 2040 + dependencies = [ 2041 + "libc", 1661 2042 ] 1662 2043 1663 2044 [[package]] ··· 1717 2098 source = "registry+https://github.com/rust-lang/crates.io-index" 1718 2099 checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 1719 2100 dependencies = [ 2101 + "ecdsa", 2102 + "elliptic-curve", 2103 + "primeorder", 2104 + "sha2", 2105 + ] 2106 + 2107 + [[package]] 2108 + name = "p384" 2109 + version = "0.13.1" 2110 + source = "registry+https://github.com/rust-lang/crates.io-index" 2111 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2112 + dependencies = [ 1720 2113 "elliptic-curve", 1721 2114 "primeorder", 1722 2115 ] ··· 1742 2135 "redox_syscall", 1743 2136 "smallvec", 1744 2137 "windows-link 0.2.0", 2138 + ] 2139 + 2140 + [[package]] 2141 + name = "pem-rfc7468" 2142 + version = "0.7.0" 2143 + source = "registry+https://github.com/rust-lang/crates.io-index" 2144 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 2145 + dependencies = [ 2146 + "base64ct", 1745 2147 ] 1746 2148 1747 2149 [[package]] ··· 1763 2165 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1764 2166 1765 2167 [[package]] 2168 + name = "pkcs1" 2169 + version = "0.7.5" 2170 + source = "registry+https://github.com/rust-lang/crates.io-index" 2171 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 2172 + dependencies = [ 2173 + "der", 2174 + "pkcs8", 2175 + "spki", 2176 + ] 2177 + 2178 + [[package]] 1766 2179 name = "pkcs8" 1767 2180 version = "0.10.2" 1768 2181 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1860 2273 "version_check", 1861 2274 "yansi", 1862 2275 ] 2276 + 2277 + [[package]] 2278 + name = "quick-error" 2279 + version = "1.2.3" 2280 + source = "registry+https://github.com/rust-lang/crates.io-index" 2281 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 1863 2282 1864 2283 [[package]] 1865 2284 name = "quinn" ··· 2061 2480 checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" 2062 2481 dependencies = [ 2063 2482 "async-compression", 2064 - "base64", 2483 + "base64 0.22.1", 2065 2484 "bytes", 2066 2485 "encoding_rs", 2067 2486 "futures-core", ··· 2105 2524 checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2106 2525 2107 2526 [[package]] 2527 + name = "rfc6979" 2528 + version = "0.4.0" 2529 + source = "registry+https://github.com/rust-lang/crates.io-index" 2530 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2531 + dependencies = [ 2532 + "hmac", 2533 + "subtle", 2534 + ] 2535 + 2536 + [[package]] 2108 2537 name = "ring" 2109 2538 version = "0.17.14" 2110 2539 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2119 2548 ] 2120 2549 2121 2550 [[package]] 2551 + name = "rouille" 2552 + version = "3.6.2" 2553 + source = "registry+https://github.com/rust-lang/crates.io-index" 2554 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 2555 + dependencies = [ 2556 + "base64 0.13.1", 2557 + "brotli", 2558 + "chrono", 2559 + "deflate", 2560 + "filetime", 2561 + "multipart", 2562 + "percent-encoding", 2563 + "rand 0.8.5", 2564 + "serde", 2565 + "serde_derive", 2566 + "serde_json", 2567 + "sha1_smol", 2568 + "threadpool", 2569 + "time", 2570 + "tiny_http", 2571 + "url", 2572 + ] 2573 + 2574 + [[package]] 2575 + name = "rsa" 2576 + version = "0.9.8" 2577 + source = "registry+https://github.com/rust-lang/crates.io-index" 2578 + checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" 2579 + dependencies = [ 2580 + "const-oid", 2581 + "digest", 2582 + "num-bigint-dig", 2583 + "num-integer", 2584 + "num-traits", 2585 + "pkcs1", 2586 + "pkcs8", 2587 + "rand_core 0.6.4", 2588 + "signature", 2589 + "spki", 2590 + "subtle", 2591 + "zeroize", 2592 + ] 2593 + 2594 + [[package]] 2122 2595 name = "rustc-demangle" 2123 2596 version = "0.1.26" 2124 2597 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2200 2673 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2201 2674 2202 2675 [[package]] 2676 + name = "safemem" 2677 + version = "0.3.3" 2678 + source = "registry+https://github.com/rust-lang/crates.io-index" 2679 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2680 + 2681 + [[package]] 2203 2682 name = "schemars" 2204 2683 version = "0.9.0" 2205 2684 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2238 2717 "base16ct", 2239 2718 "der", 2240 2719 "generic-array", 2720 + "pkcs8", 2241 2721 "subtle", 2242 2722 "zeroize", 2243 2723 ] ··· 2354 2834 source = "registry+https://github.com/rust-lang/crates.io-index" 2355 2835 checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" 2356 2836 dependencies = [ 2357 - "base64", 2837 + "base64 0.22.1", 2358 2838 "chrono", 2359 2839 "hex", 2360 2840 "indexmap 1.9.3", ··· 2381 2861 ] 2382 2862 2383 2863 [[package]] 2864 + name = "sha1_smol" 2865 + version = "1.0.1" 2866 + source = "registry+https://github.com/rust-lang/crates.io-index" 2867 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 2868 + 2869 + [[package]] 2384 2870 name = "sha2" 2385 2871 version = "0.10.9" 2386 2872 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2402 2888 version = "2.2.0" 2403 2889 source = "registry+https://github.com/rust-lang/crates.io-index" 2404 2890 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2891 + dependencies = [ 2892 + "digest", 2893 + "rand_core 0.6.4", 2894 + ] 2405 2895 2406 2896 [[package]] 2407 2897 name = "slab" ··· 2446 2936 ] 2447 2937 2448 2938 [[package]] 2939 + name = "spin" 2940 + version = "0.9.8" 2941 + source = "registry+https://github.com/rust-lang/crates.io-index" 2942 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 2943 + 2944 + [[package]] 2449 2945 name = "spki" 2450 2946 version = "0.7.3" 2451 2947 source = "registry+https://github.com/rust-lang/crates.io-index" 2452 2948 checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2453 2949 dependencies = [ 2950 + "base64ct", 2454 2951 "der", 2455 2952 ] 2456 2953 ··· 2655 3152 ] 2656 3153 2657 3154 [[package]] 3155 + name = "threadpool" 3156 + version = "1.8.1" 3157 + source = "registry+https://github.com/rust-lang/crates.io-index" 3158 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 3159 + dependencies = [ 3160 + "num_cpus", 3161 + ] 3162 + 3163 + [[package]] 2658 3164 name = "time" 2659 3165 version = "0.3.44" 2660 3166 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2662 3168 dependencies = [ 2663 3169 "deranged", 2664 3170 "itoa", 3171 + "libc", 2665 3172 "num-conv", 3173 + "num_threads", 2666 3174 "powerfmt", 2667 3175 "serde", 2668 3176 "time-core", ··· 2683 3191 dependencies = [ 2684 3192 "num-conv", 2685 3193 "time-core", 3194 + ] 3195 + 3196 + [[package]] 3197 + name = "tiny_http" 3198 + version = "0.12.0" 3199 + source = "registry+https://github.com/rust-lang/crates.io-index" 3200 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 3201 + dependencies = [ 3202 + "ascii", 3203 + "chunked_transfer", 3204 + "httpdate", 3205 + "log", 2686 3206 ] 2687 3207 2688 3208 [[package]] ··· 2839 3359 ] 2840 3360 2841 3361 [[package]] 3362 + name = "trait-variant" 3363 + version = "0.1.2" 3364 + source = "registry+https://github.com/rust-lang/crates.io-index" 3365 + checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 3366 + dependencies = [ 3367 + "proc-macro2", 3368 + "quote", 3369 + "syn 2.0.106", 3370 + ] 3371 + 3372 + [[package]] 2842 3373 name = "try-lock" 2843 3374 version = "0.2.5" 2844 3375 source = "registry+https://github.com/rust-lang/crates.io-index" 2845 3376 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2846 3377 2847 3378 [[package]] 3379 + name = "twoway" 3380 + version = "0.1.8" 3381 + source = "registry+https://github.com/rust-lang/crates.io-index" 3382 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 3383 + dependencies = [ 3384 + "memchr", 3385 + ] 3386 + 3387 + [[package]] 2848 3388 name = "typenum" 2849 3389 version = "1.18.0" 2850 3390 source = "registry+https://github.com/rust-lang/crates.io-index" 2851 3391 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 3392 + 3393 + [[package]] 3394 + name = "unicase" 3395 + version = "2.8.1" 3396 + source = "registry+https://github.com/rust-lang/crates.io-index" 3397 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 2852 3398 2853 3399 [[package]] 2854 3400 name = "unicode-ident" ··· 2915 3461 version = "0.2.2" 2916 3462 source = "registry+https://github.com/rust-lang/crates.io-index" 2917 3463 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3464 + 3465 + [[package]] 3466 + name = "uuid" 3467 + version = "1.18.1" 3468 + source = "registry+https://github.com/rust-lang/crates.io-index" 3469 + checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 3470 + dependencies = [ 3471 + "getrandom 0.3.3", 3472 + "js-sys", 3473 + "wasm-bindgen", 3474 + ] 2918 3475 2919 3476 [[package]] 2920 3477 name = "version_check" ··· 3476 4033 version = "1.8.2" 3477 4034 source = "registry+https://github.com/rust-lang/crates.io-index" 3478 4035 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 4036 + dependencies = [ 4037 + "serde", 4038 + ] 3479 4039 3480 4040 [[package]] 3481 4041 name = "zerotrie"
+24 -1
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.1.0" 8 + version = "0.3.0" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 repository = "https://tangled.org/@nonbinary.computer/jacquard" 11 11 keywords = ["atproto", "at", "bluesky", "api", "client"] ··· 35 35 miette = "7.6" 36 36 thiserror = "2.0" 37 37 38 + # trait stuff 39 + trait-variant = "0.1.2" 40 + 41 + 42 + bon = "3.8.0" 43 + 38 44 # Data types 39 45 bytes = "1.10" 40 46 smol_str = { version = "0.3", features = ["serde"] } ··· 51 57 # HTTP 52 58 http = "1.3" 53 59 reqwest = { version = "0.12", default-features = false } 60 + 61 + # Async and runtimes 62 + async-trait = "0.1" 63 + tokio = "1" 64 + 65 + # Encoding and crypto building blocks 66 + base64 = "0.22" 67 + percent-encoding = "2.3" 68 + urlencoding = "2.1.3" 69 + rand_core = "0.6" 70 + 71 + # Time 72 + chrono = "0.4" 73 + 74 + # Crypto curves and JOSE 75 + p256 = "0.13" 76 + jose-jwk = "0.1"
+22 -30
README.md
··· 18 18 19 19 ## Example 20 20 21 - Dead simple api client. Logs in, prints the latest 5 posts from your timeline. 21 + Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline. 22 22 23 23 ```rust 24 + use std::sync::Arc; 24 25 use clap::Parser; 25 26 use jacquard::CowStr; 26 27 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 27 - use jacquard::api::com_atproto::server::create_session::CreateSession; 28 - use jacquard::client::{AuthenticatedClient, Session, XrpcClient}; 28 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 29 + use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore}; 30 + use jacquard::identity::PublicResolver as JacquardResolver; 29 31 use miette::IntoDiagnostic; 30 32 31 33 #[derive(Parser, Debug)] 32 34 #[command(author, version, about = "Jacquard - AT Protocol client demo")] 33 35 struct Args { 34 - /// Username/handle (e.g., alice.mosphere.at) 36 + /// Username/handle (e.g., alice.bsky.social) or DID 35 37 #[arg(short, long)] 36 38 username: CowStr<'static>, 37 - 38 - /// PDS URL (e.g., https://bsky.social) 39 - #[arg(long, default_value = "https://bsky.social")] 40 - pds: CowStr<'static>, 41 - 42 39 /// App password 43 40 #[arg(short, long)] 44 41 password: CowStr<'static>, ··· 48 45 async fn main() -> miette::Result<()> { 49 46 let args = Args::parse(); 50 47 51 - // Create HTTP client 52 - let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds); 53 - 54 - // Create session 55 - let session = Session::from( 56 - client 57 - .send( 58 - CreateSession::new() 59 - .identifier(args.username) 60 - .password(args.password) 61 - .build(), 62 - ) 63 - .await? 64 - .into_output()?, 65 - ); 48 + // Resolver + storage 49 + let resolver = Arc::new(JacquardResolver::default()); 50 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 51 + let client = Arc::new(resolver.clone()); 52 + let session = CredentialSession::new(store, client); 66 53 67 - println!("logged in as {} ({})", session.handle, session.did); 68 - client.set_session(session); 54 + // Login (resolves PDS automatically) and persist as (did, "session") 55 + session 56 + .login(args.username.clone(), args.password.clone(), None, None, None) 57 + .await 58 + .into_diagnostic()?; 69 59 70 60 // Fetch timeline 71 - println!("\nfetching timeline..."); 72 - let timeline = client 61 + let timeline = session 62 + .clone() 73 63 .send(GetTimeline::new().limit(5).build()) 74 - .await? 75 - .into_output()?; 64 + .await 65 + .into_diagnostic()? 66 + .into_output() 67 + .into_diagnostic()?; 76 68 77 69 println!("\ntimeline ({} posts):", timeline.feed.len()); 78 70 for (i, post) in timeline.feed.iter().enumerate() {
+22 -13
crates/jacquard/Cargo.toml
··· 2 2 name = "jacquard" 3 3 description.workspace = true 4 4 edition.workspace = true 5 - version.workspace = true 5 + version = "0.3.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 12 12 license.workspace = true 13 13 14 14 [features] 15 - default = ["api_all"] 15 + default = ["api_all", "dns", "fancy", "loopback"] 16 16 derive = ["dep:jacquard-derive"] 17 17 api = ["jacquard-api/com_atproto"] 18 18 api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"] 19 - dns = ["dep:hickory-resolver"] 19 + dns = ["jacquard-identity/dns"] 20 + fancy = ["miette/fancy"] 21 + loopback = ["dep:rouille"] 20 22 21 23 [lib] 22 24 name = "jacquard" ··· 25 27 [[bin]] 26 28 name = "jacquard" 27 29 path = "src/main.rs" 30 + 28 31 29 32 [dependencies] 30 - bon = "3" 31 - async-trait = "0.1" 33 + jacquard-api = { version = "0.3.0", path = "../jacquard-api" } 34 + jacquard-common = { version = "0.3.0", path = "../jacquard-common", features = ["reqwest-client"] } 35 + jacquard-oauth = { version = "0.3.0", path = "../jacquard-oauth" } 36 + jacquard-derive = { version = "0.3.0", path = "../jacquard-derive", optional = true } 37 + jacquard-identity = { version = "0.3.0", path = "../jacquard-identity" } 38 + 39 + bon.workspace = true 40 + async-trait.workspace = true 32 41 bytes.workspace = true 33 42 clap.workspace = true 34 43 http.workspace = true 35 - jacquard-api = { version = "0.1.0", path = "../jacquard-api" } 36 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 37 - jacquard-derive = { version = "0.1.0", path = "../jacquard-derive", optional = true } 38 - miette.workspace = true 44 + miette = { workspace = true } 39 45 reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 40 46 serde.workspace = true 41 47 serde_html_form.workspace = true 42 48 serde_ipld_dagcbor.workspace = true 43 49 serde_json.workspace = true 44 50 thiserror.workspace = true 45 - tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 46 - hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true } 51 + tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } 47 52 url.workspace = true 48 53 smol_str.workspace = true 49 - percent-encoding = "2" 50 - urlencoding = "2" 54 + percent-encoding.workspace = true 55 + urlencoding.workspace = true 56 + jose-jwk = { workspace = true, features = ["p256"] } 57 + p256 = { workspace = true, features = ["ecdsa"] } 58 + rand_core.workspace = true 59 + rouille = { version = "3.6.2", optional = true }
+462
crates/jacquard/src/client/credential_session.rs
··· 1 + use std::sync::Arc; 2 + 3 + use jacquard_api::com_atproto::server::refresh_session::RefreshSession; 4 + use jacquard_common::{ 5 + AuthorizationToken, CowStr, IntoStatic, 6 + error::{AuthError, ClientError, XrpcResult}, 7 + http_client::HttpClient, 8 + session::SessionStore, 9 + types::{ 10 + did::Did, 11 + xrpc::{CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest}, 12 + }, 13 + }; 14 + use tokio::sync::RwLock; 15 + use url::Url; 16 + 17 + use crate::client::AtpSession; 18 + use jacquard_identity::resolver::IdentityResolver; 19 + use std::any::Any; 20 + 21 + /// Storage key for appโ€‘password sessions: `(account DID, session id)`. 22 + pub type SessionKey = (Did<'static>, CowStr<'static>); 23 + 24 + /// Stateful client for appโ€‘password based sessions. 25 + /// 26 + /// - Persists sessions via a pluggable `SessionStore`. 27 + /// - Automatically refreshes on token expiry. 28 + /// - Tracks a base endpoint, defaulting to the public appview until login/restore. 29 + pub struct CredentialSession<S, T> 30 + where 31 + S: SessionStore<SessionKey, AtpSession>, 32 + { 33 + store: Arc<S>, 34 + client: Arc<T>, 35 + /// Default call options applied to each request (auth/headers/labelers). 36 + pub options: RwLock<CallOptions<'static>>, 37 + /// Active session key, if any. 38 + pub key: RwLock<Option<SessionKey>>, 39 + /// Current base endpoint (PDS); defaults to public appview when unset. 40 + pub endpoint: RwLock<Option<Url>>, 41 + } 42 + 43 + impl<S, T> CredentialSession<S, T> 44 + where 45 + S: SessionStore<SessionKey, AtpSession>, 46 + { 47 + /// Create a new credential session using the given store and client. 48 + pub fn new(store: Arc<S>, client: Arc<T>) -> Self { 49 + Self { 50 + store, 51 + client, 52 + options: RwLock::new(CallOptions::default()), 53 + key: RwLock::new(None), 54 + endpoint: RwLock::new(None), 55 + } 56 + } 57 + } 58 + 59 + impl<S, T> CredentialSession<S, T> 60 + where 61 + S: SessionStore<SessionKey, AtpSession>, 62 + { 63 + /// Return a copy configured with the provided default call options. 64 + pub fn with_options(self, options: CallOptions<'_>) -> Self { 65 + Self { 66 + client: self.client, 67 + store: self.store, 68 + options: RwLock::new(options.into_static()), 69 + key: self.key, 70 + endpoint: self.endpoint, 71 + } 72 + } 73 + 74 + /// Replace default call options. 75 + pub async fn set_options(&self, options: CallOptions<'_>) { 76 + *self.options.write().await = options.into_static(); 77 + } 78 + 79 + /// Get the active session key (account DID and session id), if any. 80 + pub async fn session_info(&self) -> Option<SessionKey> { 81 + self.key.read().await.clone() 82 + } 83 + 84 + /// Current base endpoint. Defaults to the public appview when unset. 85 + pub async fn endpoint(&self) -> Url { 86 + self.endpoint.read().await.clone().unwrap_or( 87 + Url::parse("https://public.bsky.app").expect("public appview should be valid url"), 88 + ) 89 + } 90 + 91 + /// Override the current base endpoint. 92 + pub async fn set_endpoint(&self, endpoint: Url) { 93 + *self.endpoint.write().await = Some(endpoint); 94 + } 95 + 96 + /// Current access token (Bearer), if logged in. 97 + pub async fn access_token(&self) -> Option<AuthorizationToken<'_>> { 98 + let key = self.key.read().await.clone()?; 99 + let session = self.store.get(&key).await; 100 + session.map(|session| AuthorizationToken::Bearer(session.access_jwt)) 101 + } 102 + 103 + /// Current refresh token (Bearer), if logged in. 104 + pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> { 105 + let key = self.key.read().await.clone()?; 106 + let session = self.store.get(&key).await; 107 + session.map(|session| AuthorizationToken::Bearer(session.refresh_jwt)) 108 + } 109 + } 110 + 111 + impl<S, T> CredentialSession<S, T> 112 + where 113 + S: SessionStore<SessionKey, AtpSession>, 114 + T: HttpClient, 115 + { 116 + /// Refresh the active session by calling `com.atproto.server.refreshSession`. 117 + pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> { 118 + let key = self.key.read().await.clone().ok_or(ClientError::Auth( 119 + jacquard_common::error::AuthError::NotAuthenticated, 120 + ))?; 121 + let session = self.store.get(&key).await; 122 + let endpoint = self.endpoint().await; 123 + let mut opts = self.options.read().await.clone(); 124 + opts.auth = session.map(|s| AuthorizationToken::Bearer(s.refresh_jwt)); 125 + let response = self 126 + .client 127 + .xrpc(endpoint) 128 + .with_options(opts) 129 + .send(&RefreshSession) 130 + .await?; 131 + let refresh = response 132 + .into_output() 133 + .map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?; 134 + 135 + let new_session: AtpSession = refresh.into(); 136 + let token = AuthorizationToken::Bearer(new_session.access_jwt.clone()); 137 + self.store 138 + .set(key, new_session) 139 + .await 140 + .map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?; 141 + 142 + Ok(token) 143 + } 144 + } 145 + 146 + impl<S, T> CredentialSession<S, T> 147 + where 148 + S: SessionStore<SessionKey, AtpSession>, 149 + T: HttpClient + IdentityResolver + XrpcExt, 150 + { 151 + /// Resolve the user's PDS and create an app-password session. 152 + /// 153 + /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. 154 + /// - `session_id`: optional session label; defaults to "session". 155 + /// - Persists and activates the session, and updates the base endpoint to the user's PDS. 156 + pub async fn login( 157 + &self, 158 + identifier: CowStr<'_>, 159 + password: CowStr<'_>, 160 + session_id: Option<CowStr<'_>>, 161 + allow_takendown: Option<bool>, 162 + auth_factor_token: Option<CowStr<'_>>, 163 + ) -> Result<AtpSession, ClientError> 164 + where 165 + S: Any + 'static, 166 + { 167 + // Resolve PDS base 168 + let pds = if identifier.as_ref().starts_with("http://") 169 + || identifier.as_ref().starts_with("https://") 170 + { 171 + Url::parse(identifier.as_ref()).map_err(|e| { 172 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 173 + e.to_string(), 174 + )) 175 + })? 176 + } else if identifier.as_ref().starts_with("did:") { 177 + let did = Did::new(identifier.as_ref()).map_err(|e| { 178 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 179 + format!("invalid did: {:?}", e), 180 + )) 181 + })?; 182 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 183 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 184 + })?; 185 + resp.into_owned() 186 + .map_err(|e| { 187 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 188 + e, 189 + ))) 190 + })? 191 + .pds_endpoint() 192 + .ok_or_else(|| { 193 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 194 + "missing PDS endpoint".into(), 195 + )) 196 + })? 197 + } else { 198 + // treat as handle 199 + let handle = 200 + jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| { 201 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 202 + format!("invalid handle: {:?}", e), 203 + )) 204 + })?; 205 + let did = self.client.resolve_handle(&handle).await.map_err(|e| { 206 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 207 + })?; 208 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 209 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 210 + })?; 211 + resp.into_owned() 212 + .map_err(|e| { 213 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 214 + e, 215 + ))) 216 + })? 217 + .pds_endpoint() 218 + .ok_or_else(|| { 219 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 220 + "missing PDS endpoint".into(), 221 + )) 222 + })? 223 + }; 224 + 225 + // Build and send createSession 226 + use std::collections::BTreeMap; 227 + let req = jacquard_api::com_atproto::server::create_session::CreateSession { 228 + allow_takendown, 229 + auth_factor_token, 230 + identifier: identifier.clone().into_static(), 231 + password: password.into_static(), 232 + extra_data: BTreeMap::new(), 233 + }; 234 + 235 + let resp = self 236 + .client 237 + .xrpc(pds.clone()) 238 + .with_options(self.options.read().await.clone()) 239 + .send(&req) 240 + .await?; 241 + let out = resp 242 + .into_output() 243 + .map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?; 244 + let session = AtpSession::from(out); 245 + 246 + let sid = session_id.unwrap_or_else(|| CowStr::new_static("session")); 247 + let key = (session.did.clone(), sid.into_static()); 248 + self.store 249 + .set(key.clone(), session.clone()) 250 + .await 251 + .map_err(|e| { 252 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 253 + })?; 254 + // If using FileAuthStore, persist PDS for faster resume 255 + if let Some(file_store) = 256 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 257 + { 258 + let _ = file_store.set_atp_pds(&key, &pds); 259 + } 260 + // Activate 261 + *self.key.write().await = Some(key); 262 + *self.endpoint.write().await = Some(pds); 263 + 264 + Ok(session) 265 + } 266 + 267 + /// Restore a previously persisted app-password session and set base endpoint. 268 + pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError> 269 + where 270 + S: Any + 'static, 271 + { 272 + let key = (did.clone().into_static(), session_id.clone().into_static()); 273 + let Some(sess) = self.store.get(&key).await else { 274 + return Err(ClientError::Auth(AuthError::NotAuthenticated)); 275 + }; 276 + // Try to read cached PDS; otherwise resolve via DID 277 + let pds = if let Some(file_store) = 278 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 279 + { 280 + file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 281 + } else { 282 + None 283 + } 284 + .unwrap_or({ 285 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 286 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 287 + })?; 288 + resp.into_owned() 289 + .map_err(|e| { 290 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 291 + e, 292 + ))) 293 + })? 294 + .pds_endpoint() 295 + .ok_or_else(|| { 296 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 297 + "missing PDS endpoint".into(), 298 + )) 299 + })? 300 + }); 301 + 302 + // Activate 303 + *self.key.write().await = Some(key.clone()); 304 + *self.endpoint.write().await = Some(pds); 305 + // ensure store has the session (no-op if it existed) 306 + self.store 307 + .set((sess.did.clone(), session_id.into_static()), sess) 308 + .await 309 + .map_err(|e| { 310 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 311 + })?; 312 + if let Some(file_store) = 313 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 314 + { 315 + let _ = file_store.set_atp_pds(&key, &self.endpoint().await); 316 + } 317 + Ok(()) 318 + } 319 + 320 + /// Switch to a different stored session (and refresh endpoint/PDS). 321 + pub async fn switch_session( 322 + &self, 323 + did: Did<'_>, 324 + session_id: CowStr<'_>, 325 + ) -> Result<(), ClientError> 326 + where 327 + S: Any + 'static, 328 + { 329 + let key = (did.clone().into_static(), session_id.into_static()); 330 + if self.store.get(&key).await.is_none() { 331 + return Err(ClientError::Auth(AuthError::NotAuthenticated)); 332 + } 333 + // Endpoint from store if cached, else resolve 334 + let pds = if let Some(file_store) = 335 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 336 + { 337 + file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 338 + } else { 339 + None 340 + } 341 + .unwrap_or({ 342 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 343 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 344 + })?; 345 + resp.into_owned() 346 + .map_err(|e| { 347 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 348 + e, 349 + ))) 350 + })? 351 + .pds_endpoint() 352 + .ok_or_else(|| { 353 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 354 + "missing PDS endpoint".into(), 355 + )) 356 + })? 357 + }); 358 + *self.key.write().await = Some(key.clone()); 359 + *self.endpoint.write().await = Some(pds); 360 + if let Some(file_store) = 361 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 362 + { 363 + let _ = file_store.set_atp_pds(&key, &self.endpoint().await); 364 + } 365 + Ok(()) 366 + } 367 + 368 + /// Clear and delete the current session from the store. 369 + pub async fn logout(&self) -> Result<(), ClientError> { 370 + let Some(key) = self.key.read().await.clone() else { 371 + return Ok(()); 372 + }; 373 + self.store.del(&key).await.map_err(|e| { 374 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 375 + })?; 376 + *self.key.write().await = None; 377 + Ok(()) 378 + } 379 + } 380 + 381 + impl<S, T> HttpClient for CredentialSession<S, T> 382 + where 383 + S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 384 + T: HttpClient + XrpcExt + Send + Sync + 'static, 385 + { 386 + type Error = T::Error; 387 + 388 + async fn send_http( 389 + &self, 390 + request: http::Request<Vec<u8>>, 391 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 392 + self.client.send_http(request).await 393 + } 394 + } 395 + 396 + impl<S, T> XrpcClient for CredentialSession<S, T> 397 + where 398 + S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 399 + T: HttpClient + XrpcExt + Send + Sync + 'static, 400 + { 401 + fn base_uri(&self) -> Url { 402 + // base_uri is a synchronous trait method; avoid `.await` here. 403 + // Under Tokio, use `block_in_place` to make a blocking RwLock read safe. 404 + if tokio::runtime::Handle::try_current().is_ok() { 405 + tokio::task::block_in_place(|| { 406 + self.endpoint 407 + .blocking_read() 408 + .clone() 409 + .unwrap_or( 410 + Url::parse("https://public.bsky.app") 411 + .expect("public appview should be valid url"), 412 + ) 413 + }) 414 + } else { 415 + self.endpoint 416 + .blocking_read() 417 + .clone() 418 + .unwrap_or( 419 + Url::parse("https://public.bsky.app") 420 + .expect("public appview should be valid url"), 421 + ) 422 + } 423 + } 424 + async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>( 425 + self, 426 + request: &R, 427 + ) -> XrpcResult<Response<R>> { 428 + let base_uri = self.base_uri(); 429 + let auth = self.access_token().await; 430 + let mut opts = self.options.read().await.clone(); 431 + opts.auth = auth; 432 + let resp = self 433 + .client 434 + .xrpc(base_uri.clone()) 435 + .with_options(opts.clone()) 436 + .send(request) 437 + .await; 438 + 439 + if is_expired(&resp) { 440 + let auth = self.refresh().await?; 441 + opts.auth = Some(auth); 442 + self.client 443 + .xrpc(base_uri) 444 + .with_options(opts) 445 + .send(request) 446 + .await 447 + } else { 448 + resp 449 + } 450 + } 451 + } 452 + 453 + fn is_expired<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool { 454 + match response { 455 + Err(ClientError::Auth(AuthError::TokenExpired)) => true, 456 + Ok(resp) => match resp.parse() { 457 + Err(XrpcError::Auth(AuthError::TokenExpired)) => true, 458 + _ => false, 459 + }, 460 + _ => false, 461 + } 462 + }
-158
crates/jacquard/src/client/error.rs
··· 1 - //! Error types for XRPC client operations 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 { 8 - /// HTTP transport error 9 - #[error("HTTP transport error: {0}")] 10 - Transport( 11 - #[from] 12 - #[diagnostic_source] 13 - TransportError, 14 - ), 15 - 16 - /// Request serialization failed 17 - #[error("{0}")] 18 - Encode( 19 - #[from] 20 - #[diagnostic_source] 21 - EncodeError, 22 - ), 23 - 24 - /// Response deserialization failed 25 - #[error("{0}")] 26 - Decode( 27 - #[from] 28 - #[diagnostic_source] 29 - DecodeError, 30 - ), 31 - 32 - /// HTTP error response 33 - #[error("HTTP {0}")] 34 - Http( 35 - #[from] 36 - #[diagnostic_source] 37 - HttpError, 38 - ), 39 - 40 - /// Authentication error 41 - #[error("Authentication error: {0}")] 42 - Auth( 43 - #[from] 44 - #[diagnostic_source] 45 - AuthError, 46 - ), 47 - } 48 - 49 - /// Transport-level errors that occur during HTTP communication 50 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 51 - pub enum TransportError { 52 - /// Failed to establish connection to server 53 - #[error("Connection error: {0}")] 54 - Connect(String), 55 - 56 - /// Request timed out 57 - #[error("Request timeout")] 58 - Timeout, 59 - 60 - /// Request construction failed (malformed URI, headers, etc.) 61 - #[error("Invalid request: {0}")] 62 - InvalidRequest(String), 63 - 64 - /// Other transport error 65 - #[error("Transport error: {0}")] 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 { 75 - /// JSON deserialization failed 76 - #[error("Failed to deserialize JSON: {0}")] 77 - Json( 78 - #[from] 79 - #[source] 80 - serde_json::Error, 81 - ), 82 - /// CBOR deserialization failed (local I/O) 83 - #[error("Failed to deserialize CBOR: {0}")] 84 - CborLocal( 85 - #[from] 86 - #[source] 87 - serde_ipld_dagcbor::DecodeError<std::io::Error>, 88 - ), 89 - /// CBOR deserialization failed (remote/reqwest) 90 - #[error("Failed to deserialize CBOR: {0}")] 91 - CborRemote( 92 - #[from] 93 - #[source] 94 - serde_ipld_dagcbor::DecodeError<reqwest::Error>, 95 - ), 96 - } 97 - 98 - /// HTTP error response (non-200 status codes outside of XRPC error handling) 99 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 100 - pub struct HttpError { 101 - /// HTTP status code 102 - pub status: http::StatusCode, 103 - /// Response body if available 104 - pub body: Option<Bytes>, 105 - } 106 - 107 - impl std::fmt::Display for HttpError { 108 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 109 - write!(f, "HTTP {}", self.status)?; 110 - if let Some(body) = &self.body { 111 - if let Ok(s) = std::str::from_utf8(body) { 112 - write!(f, ":\n{}", s)?; 113 - } 114 - } 115 - Ok(()) 116 - } 117 - } 118 - 119 - /// Authentication and authorization errors 120 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 121 - pub enum AuthError { 122 - /// Access token has expired (use refresh token to get a new one) 123 - #[error("Access token expired")] 124 - TokenExpired, 125 - 126 - /// Access token is invalid or malformed 127 - #[error("Invalid access token")] 128 - InvalidToken, 129 - 130 - /// Token refresh request failed 131 - #[error("Token refresh failed")] 132 - RefreshFailed, 133 - 134 - /// Request requires authentication but none was provided 135 - #[error("No authentication provided")] 136 - NotAuthenticated, 137 - 138 - /// Other authentication error 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 - }
-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 - }
+490
crates/jacquard/src/client/token.rs
··· 1 + use jacquard_common::IntoStatic; 2 + use jacquard_common::cowstr::ToCowStr; 3 + use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError}; 4 + use jacquard_common::types::string::{Datetime, Did}; 5 + use jacquard_oauth::scopes::Scope; 6 + use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData}; 7 + use jacquard_oauth::types::OAuthTokenType; 8 + use jose_jwk::Key; 9 + use serde::{Deserialize, Serialize}; 10 + use serde_json::Value; 11 + use url::Url; 12 + 13 + /// On-disk session records for app-password and OAuth flows, sharing a single JSON map. 14 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 15 + pub enum StoredSession { 16 + /// App-password session 17 + Atp(StoredAtSession), 18 + /// OAuth client session 19 + OAuth(OAuthSession), 20 + /// OAuth authorization request state 21 + OAuthState(OAuthState), 22 + } 23 + 24 + /// Minimal persisted representation of an appโ€‘password session. 25 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 26 + pub struct StoredAtSession { 27 + /// Access token (JWT) 28 + access_jwt: String, 29 + /// Refresh token (JWT) 30 + refresh_jwt: String, 31 + /// Account DID 32 + did: String, 33 + /// Optional PDS endpoint for faster resume 34 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 35 + pds: Option<String>, 36 + /// Session id label (e.g., "session") 37 + session_id: String, 38 + /// Last known handle 39 + handle: String, 40 + } 41 + 42 + /// Persisted OAuth client session (on-disk format). 43 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 44 + pub struct OAuthSession { 45 + /// Account DID 46 + account_did: String, 47 + /// Client-generated session id (usually auth `state`) 48 + session_id: String, 49 + 50 + /// Base URL of the resource server (PDS) 51 + host_url: Url, 52 + 53 + /// Base URL of the authorization server (PDS or entryway) 54 + authserver_url: Url, 55 + 56 + /// Full token endpoint URL 57 + authserver_token_endpoint: String, 58 + 59 + /// Full revocation endpoint URL, if available 60 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 61 + authserver_revocation_endpoint: Option<String>, 62 + 63 + /// Granted scopes 64 + scopes: Vec<String>, 65 + 66 + /// Client DPoP key material 67 + pub dpop_key: Key, 68 + /// Current auth server DPoP nonce 69 + pub dpop_authserver_nonce: String, 70 + /// Current resource server (PDS) DPoP nonce 71 + pub dpop_host_nonce: String, 72 + 73 + /// Token response issuer 74 + pub iss: String, 75 + /// Token subject (DID) 76 + pub sub: String, 77 + /// Token audience (verified PDS URL) 78 + pub aud: String, 79 + /// Token scopes (raw) if provided 80 + pub scope: Option<String>, 81 + 82 + /// Refresh token 83 + pub refresh_token: Option<String>, 84 + /// Access token 85 + pub access_token: String, 86 + /// Token type (e.g., DPoP) 87 + pub token_type: OAuthTokenType, 88 + 89 + /// Expiration timestamp 90 + pub expires_at: Option<Datetime>, 91 + } 92 + 93 + impl From<ClientSessionData<'_>> for OAuthSession { 94 + fn from(data: ClientSessionData<'_>) -> Self { 95 + OAuthSession { 96 + account_did: data.account_did.to_string(), 97 + session_id: data.session_id.to_string(), 98 + host_url: data.host_url, 99 + authserver_url: data.authserver_url, 100 + authserver_token_endpoint: data.authserver_token_endpoint.to_string(), 101 + authserver_revocation_endpoint: data 102 + .authserver_revocation_endpoint 103 + .map(|s| s.to_string()), 104 + scopes: data.scopes.into_iter().map(|s| s.to_string()).collect(), 105 + dpop_key: data.dpop_data.dpop_key, 106 + dpop_authserver_nonce: data.dpop_data.dpop_authserver_nonce.to_string(), 107 + dpop_host_nonce: data.dpop_data.dpop_host_nonce.to_string(), 108 + iss: data.token_set.iss.to_string(), 109 + sub: data.token_set.sub.to_string(), 110 + aud: data.token_set.aud.to_string(), 111 + scope: data.token_set.scope.map(|s| s.to_string()), 112 + refresh_token: data.token_set.refresh_token.map(|s| s.to_string()), 113 + access_token: data.token_set.access_token.to_string(), 114 + token_type: data.token_set.token_type, 115 + expires_at: data.token_set.expires_at, 116 + } 117 + } 118 + } 119 + 120 + impl From<OAuthSession> for ClientSessionData<'_> { 121 + fn from(session: OAuthSession) -> Self { 122 + ClientSessionData { 123 + account_did: session.account_did.into(), 124 + session_id: session.session_id.to_cowstr(), 125 + host_url: session.host_url, 126 + authserver_url: session.authserver_url, 127 + authserver_token_endpoint: session.authserver_token_endpoint.to_cowstr(), 128 + authserver_revocation_endpoint: session 129 + .authserver_revocation_endpoint 130 + .map(|s| s.to_cowstr().into_static()), 131 + scopes: session 132 + .scopes 133 + .into_iter() 134 + .map(|s| Scope::parse(&s).unwrap().into_static()) 135 + .collect(), 136 + dpop_data: DpopClientData { 137 + dpop_key: session.dpop_key, 138 + dpop_authserver_nonce: session.dpop_authserver_nonce.to_cowstr(), 139 + dpop_host_nonce: session.dpop_host_nonce.to_cowstr(), 140 + }, 141 + token_set: jacquard_oauth::types::TokenSet { 142 + iss: session.iss.into(), 143 + sub: session.sub.into(), 144 + aud: session.aud.into(), 145 + scope: session.scope.map(|s| s.into()), 146 + refresh_token: session.refresh_token.map(|s| s.into()), 147 + access_token: session.access_token.into(), 148 + token_type: session.token_type, 149 + expires_at: session.expires_at, 150 + }, 151 + } 152 + .into_static() 153 + } 154 + } 155 + 156 + /// Persisted OAuth authorization request state. 157 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 158 + pub struct OAuthState { 159 + /// Random identifier generated for the authorization flow (`state`) 160 + pub state: String, 161 + 162 + /// Base URL of the authorization server (PDS or entryway) 163 + pub authserver_url: Url, 164 + 165 + /// Optional pre-known account DID 166 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 167 + pub account_did: Option<String>, 168 + 169 + /// Requested scopes 170 + pub scopes: Vec<String>, 171 + 172 + /// Request URI for the authorization step 173 + pub request_uri: String, 174 + 175 + /// Full token endpoint URL 176 + pub authserver_token_endpoint: String, 177 + 178 + /// Full revocation endpoint URL, if available 179 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 180 + pub authserver_revocation_endpoint: Option<String>, 181 + 182 + /// PKCE verifier 183 + pub pkce_verifier: String, 184 + 185 + /// Client DPoP key material 186 + pub dpop_key: Key, 187 + /// Auth server DPoP nonce at PAR time 188 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 189 + pub dpop_authserver_nonce: Option<String>, 190 + } 191 + 192 + impl From<AuthRequestData<'_>> for OAuthState { 193 + fn from(value: AuthRequestData) -> Self { 194 + OAuthState { 195 + authserver_url: value.authserver_url, 196 + account_did: value.account_did.map(|s| s.to_string()), 197 + scopes: value.scopes.into_iter().map(|s| s.to_string()).collect(), 198 + request_uri: value.request_uri.to_string(), 199 + authserver_token_endpoint: value.authserver_token_endpoint.to_string(), 200 + authserver_revocation_endpoint: value 201 + .authserver_revocation_endpoint 202 + .map(|s| s.to_string()), 203 + pkce_verifier: value.pkce_verifier.to_string(), 204 + dpop_key: value.dpop_data.dpop_key, 205 + dpop_authserver_nonce: value.dpop_data.dpop_authserver_nonce.map(|s| s.to_string()), 206 + state: value.state.to_string(), 207 + } 208 + } 209 + } 210 + 211 + impl From<OAuthState> for AuthRequestData<'_> { 212 + fn from(value: OAuthState) -> Self { 213 + AuthRequestData { 214 + authserver_url: value.authserver_url, 215 + state: value.state.to_cowstr(), 216 + account_did: value.account_did.map(|s| Did::from(s).into_static()), 217 + authserver_revocation_endpoint: value 218 + .authserver_revocation_endpoint 219 + .map(|s| s.to_cowstr().into_static()), 220 + scopes: value 221 + .scopes 222 + .into_iter() 223 + .map(|s| Scope::parse(&s).unwrap().into_static()) 224 + .collect(), 225 + request_uri: value.request_uri.to_cowstr(), 226 + authserver_token_endpoint: value.authserver_token_endpoint.to_cowstr(), 227 + pkce_verifier: value.pkce_verifier.to_cowstr(), 228 + dpop_data: DpopReqData { 229 + dpop_key: value.dpop_key, 230 + dpop_authserver_nonce: value 231 + .dpop_authserver_nonce 232 + .map(|s| s.to_cowstr().into_static()), 233 + }, 234 + } 235 + .into_static() 236 + } 237 + } 238 + 239 + /// Convenience wrapper over `FileTokenStore` offering unified storage across auth modes. 240 + pub struct FileAuthStore(FileTokenStore); 241 + 242 + impl FileAuthStore { 243 + /// Create a new file-backed auth store wrapping `FileTokenStore`. 244 + pub fn new(path: impl AsRef<std::path::Path>) -> Self { 245 + Self(FileTokenStore::new(path)) 246 + } 247 + } 248 + 249 + #[async_trait::async_trait] 250 + impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore { 251 + async fn get_session( 252 + &self, 253 + did: &Did<'_>, 254 + session_id: &str, 255 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 256 + let key = format!("{}_{}", did, session_id); 257 + if let StoredSession::OAuth(session) = self 258 + .0 259 + .get(&key) 260 + .await 261 + .ok_or(SessionStoreError::Other("not found".into()))? 262 + { 263 + Ok(Some(session.into())) 264 + } else { 265 + Ok(None) 266 + } 267 + } 268 + 269 + async fn upsert_session( 270 + &self, 271 + session: ClientSessionData<'_>, 272 + ) -> Result<(), SessionStoreError> { 273 + let key = format!("{}_{}", session.account_did, session.session_id); 274 + self.0 275 + .set(key, StoredSession::OAuth(session.into())) 276 + .await?; 277 + Ok(()) 278 + } 279 + 280 + async fn delete_session( 281 + &self, 282 + did: &Did<'_>, 283 + session_id: &str, 284 + ) -> Result<(), SessionStoreError> { 285 + let key = format!("{}_{}", did, session_id); 286 + let file = std::fs::read_to_string(&self.0.path)?; 287 + let mut store: Value = serde_json::from_str(&file)?; 288 + let key_string = key.to_string(); 289 + if let Some(store) = store.as_object_mut() { 290 + store.remove(&key_string); 291 + 292 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 293 + Ok(()) 294 + } else { 295 + Err(SessionStoreError::Other("invalid store".into())) 296 + } 297 + } 298 + 299 + async fn get_auth_req_info( 300 + &self, 301 + state: &str, 302 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 303 + let key = format!("authreq_{}", state); 304 + if let StoredSession::OAuthState(auth_req) = self 305 + .0 306 + .get(&key) 307 + .await 308 + .ok_or(SessionStoreError::Other("not found".into()))? 309 + { 310 + Ok(Some(auth_req.into())) 311 + } else { 312 + Ok(None) 313 + } 314 + } 315 + 316 + async fn save_auth_req_info( 317 + &self, 318 + auth_req_info: &AuthRequestData<'_>, 319 + ) -> Result<(), SessionStoreError> { 320 + let key = format!("authreq_{}", auth_req_info.state); 321 + self.0 322 + .set(key, StoredSession::OAuthState(auth_req_info.clone().into())) 323 + .await?; 324 + Ok(()) 325 + } 326 + 327 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 328 + let key = format!("authreq_{}", state); 329 + let file = std::fs::read_to_string(&self.0.path)?; 330 + let mut store: Value = serde_json::from_str(&file)?; 331 + let key_string = key.to_string(); 332 + if let Some(store) = store.as_object_mut() { 333 + store.remove(&key_string); 334 + 335 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 336 + Ok(()) 337 + } else { 338 + Err(SessionStoreError::Other("invalid store".into())) 339 + } 340 + } 341 + } 342 + 343 + impl FileAuthStore { 344 + /// Update the persisted PDS endpoint for an app-password session (best-effort). 345 + pub fn set_atp_pds( 346 + &self, 347 + key: &crate::client::credential_session::SessionKey, 348 + pds: &Url, 349 + ) -> Result<(), SessionStoreError> { 350 + let key_str = format!("{}_{}", key.0, key.1); 351 + let file = std::fs::read_to_string(&self.0.path)?; 352 + let mut store: Value = serde_json::from_str(&file)?; 353 + if let Some(map) = store.as_object_mut() { 354 + if let Some(value) = map.get_mut(&key_str) { 355 + if let Some(outer) = value.as_object_mut() { 356 + if let Some(inner) = outer.get_mut("Atp").and_then(|v| v.as_object_mut()) { 357 + inner.insert( 358 + "pds".to_string(), 359 + serde_json::Value::String(pds.to_string()), 360 + ); 361 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 362 + return Ok(()); 363 + } 364 + } 365 + } 366 + } 367 + Err(SessionStoreError::Other("invalid store".into())) 368 + } 369 + 370 + /// Read the persisted PDS endpoint for an app-password session, if present. 371 + pub fn get_atp_pds( 372 + &self, 373 + key: &crate::client::credential_session::SessionKey, 374 + ) -> Result<Option<Url>, SessionStoreError> { 375 + let key_str = format!("{}_{}", key.0, key.1); 376 + let file = std::fs::read_to_string(&self.0.path)?; 377 + let store: Value = serde_json::from_str(&file)?; 378 + if let Some(value) = store.get(&key_str) { 379 + if let Some(obj) = value.as_object() { 380 + if let Some(serde_json::Value::Object(inner)) = obj.get("Atp") { 381 + if let Some(serde_json::Value::String(pds)) = inner.get("pds") { 382 + return Ok(Url::parse(pds).ok()); 383 + } 384 + } 385 + } 386 + } 387 + Ok(None) 388 + } 389 + } 390 + 391 + #[async_trait::async_trait] 392 + impl jacquard_common::session::SessionStore< 393 + crate::client::credential_session::SessionKey, 394 + crate::client::AtpSession, 395 + > for FileAuthStore 396 + { 397 + async fn get( 398 + &self, 399 + key: &crate::client::credential_session::SessionKey, 400 + ) -> Option<crate::client::AtpSession> { 401 + let key_str = format!("{}_{}", key.0, key.1); 402 + if let Some(StoredSession::Atp(stored)) = self.0.get(&key_str).await { 403 + Some(crate::client::AtpSession { 404 + access_jwt: stored.access_jwt.into(), 405 + refresh_jwt: stored.refresh_jwt.into(), 406 + did: stored.did.into(), 407 + handle: stored.handle.into(), 408 + }) 409 + } else { 410 + None 411 + } 412 + } 413 + 414 + async fn set( 415 + &self, 416 + key: crate::client::credential_session::SessionKey, 417 + session: crate::client::AtpSession, 418 + ) -> Result<(), jacquard_common::session::SessionStoreError> { 419 + let key_str = format!("{}_{}", key.0, key.1); 420 + let stored = StoredAtSession { 421 + access_jwt: session.access_jwt.to_string(), 422 + refresh_jwt: session.refresh_jwt.to_string(), 423 + did: session.did.to_string(), 424 + // pds endpoint is resolved on restore; do not persist 425 + pds: None, 426 + session_id: key.1.to_string(), 427 + handle: session.handle.to_string(), 428 + }; 429 + self.0.set(key_str, StoredSession::Atp(stored)).await 430 + } 431 + 432 + async fn del( 433 + &self, 434 + key: &crate::client::credential_session::SessionKey, 435 + ) -> Result<(), jacquard_common::session::SessionStoreError> { 436 + let key_str = format!("{}_{}", key.0, key.1); 437 + // Manual removal to mirror existing pattern 438 + let file = std::fs::read_to_string(&self.0.path)?; 439 + let mut store: serde_json::Value = serde_json::from_str(&file)?; 440 + if let Some(map) = store.as_object_mut() { 441 + map.remove(&key_str); 442 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 443 + Ok(()) 444 + } else { 445 + Err(jacquard_common::session::SessionStoreError::Other( 446 + "invalid store".into(), 447 + )) 448 + } 449 + } 450 + } 451 + 452 + #[cfg(test)] 453 + mod tests { 454 + use super::*; 455 + use crate::client::credential_session::SessionKey; 456 + use crate::client::AtpSession; 457 + use jacquard_common::types::string::{Did, Handle}; 458 + use std::fs; 459 + use std::path::PathBuf; 460 + 461 + fn temp_file() -> PathBuf { 462 + let mut p = std::env::temp_dir(); 463 + p.push(format!("jacquard-test-{}.json", std::process::id())); 464 + p 465 + } 466 + 467 + #[tokio::test] 468 + async fn file_auth_store_roundtrip_atp() { 469 + let path = temp_file(); 470 + // initialize empty store file 471 + fs::write(&path, "{}").unwrap(); 472 + let store = FileAuthStore::new(&path); 473 + let session = AtpSession { 474 + access_jwt: "a".into(), 475 + refresh_jwt: "r".into(), 476 + did: Did::new_static("did:plc:alice").unwrap(), 477 + handle: Handle::new_static("alice.bsky.social").unwrap(), 478 + }; 479 + let key: SessionKey = (session.did.clone(), "session".into()); 480 + jacquard_common::session::SessionStore::set(&store, key.clone(), session.clone()) 481 + .await 482 + .unwrap(); 483 + let restored = jacquard_common::session::SessionStore::get(&store, &key) 484 + .await 485 + .unwrap(); 486 + assert_eq!(restored.access_jwt.as_ref(), "a"); 487 + // clean up 488 + let _ = fs::remove_file(&path); 489 + } 490 + }
+214 -271
crates/jacquard/src/client.rs
··· 3 3 //! This module provides HTTP and XRPC client traits along with an authenticated 4 4 //! client implementation that manages session tokens. 5 5 6 - mod error; 7 - mod response; 6 + /// Stateful session client for appโ€‘password auth with autoโ€‘refresh. 7 + pub mod credential_session; 8 + /// Token storage and onโ€‘disk formats shared across appโ€‘password and OAuth. 9 + pub mod token; 8 10 9 - use std::fmt::Display; 10 - use std::future::Future; 11 + use core::future::Future; 11 12 12 - use bytes::Bytes; 13 - pub use error::{ClientError, Result}; 14 - use http::{ 15 - HeaderName, HeaderValue, Request, 16 - header::{AUTHORIZATION, CONTENT_TYPE, InvalidHeaderValue}, 17 - }; 18 - pub use response::Response; 19 - 13 + use jacquard_common::AuthorizationToken; 14 + use jacquard_common::error::TransportError; 15 + pub use jacquard_common::error::{ClientError, XrpcResult}; 16 + pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; 17 + use jacquard_common::types::xrpc::{CallOptions, Response, XrpcClient, XrpcRequest}; 20 18 use jacquard_common::{ 21 19 CowStr, IntoStatic, 22 - types::{ 23 - string::{Did, Handle}, 24 - xrpc::{XrpcMethod, XrpcRequest}, 25 - }, 20 + types::string::{Did, Handle}, 26 21 }; 22 + use jacquard_common::{http_client::HttpClient, types::xrpc::XrpcExt}; 23 + use jacquard_identity::resolver::IdentityResolver; 24 + use jacquard_oauth::authstore::ClientAuthStore; 25 + use jacquard_oauth::client::OAuthSession; 26 + use jacquard_oauth::dpop::DpopExt; 27 + use jacquard_oauth::resolver::OAuthResolver; 28 + pub use token::FileAuthStore; 27 29 28 - /// Implement HttpClient for reqwest::Client 29 - impl HttpClient for reqwest::Client { 30 - type Error = reqwest::Error; 30 + use crate::client::credential_session::{CredentialSession, SessionKey}; 31 31 32 - async fn send_http( 33 - &self, 34 - request: Request<Vec<u8>>, 35 - ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 36 - // Convert http::Request to reqwest::Request 37 - let (parts, body) = request.into_parts(); 38 - 39 - let mut req = self.request(parts.method, parts.uri.to_string()).body(body); 32 + /// App password session information from `com.atproto.server.createSession` 33 + /// 34 + /// Contains the access and refresh tokens along with user identity information. 35 + #[derive(Debug, Clone)] 36 + pub struct AtpSession { 37 + /// Access token (JWT) used for authenticated requests 38 + pub access_jwt: CowStr<'static>, 39 + /// Refresh token (JWT) used to obtain new access tokens 40 + pub refresh_jwt: CowStr<'static>, 41 + /// User's DID (Decentralized Identifier) 42 + pub did: Did<'static>, 43 + /// User's handle (e.g., "alice.bsky.social") 44 + pub handle: Handle<'static>, 45 + } 40 46 41 - // Copy headers 42 - for (name, value) in parts.headers.iter() { 43 - req = req.header(name.as_str(), value.as_bytes()); 47 + impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> 48 + for AtpSession 49 + { 50 + fn from( 51 + output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>, 52 + ) -> Self { 53 + Self { 54 + access_jwt: output.access_jwt.into_static(), 55 + refresh_jwt: output.refresh_jwt.into_static(), 56 + did: output.did.into_static(), 57 + handle: output.handle.into_static(), 44 58 } 59 + } 60 + } 45 61 46 - // Send request 47 - let resp = req.send().await?; 48 - 49 - // Convert reqwest::Response to http::Response 50 - let mut builder = http::Response::builder().status(resp.status()); 51 - 52 - // Copy headers 53 - for (name, value) in resp.headers().iter() { 54 - builder = builder.header(name.as_str(), value.as_bytes()); 62 + impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>> 63 + for AtpSession 64 + { 65 + fn from( 66 + output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>, 67 + ) -> Self { 68 + Self { 69 + access_jwt: output.access_jwt.into_static(), 70 + refresh_jwt: output.refresh_jwt.into_static(), 71 + did: output.did.into_static(), 72 + handle: output.handle.into_static(), 55 73 } 56 - 57 - // Read body 58 - let body = resp.bytes().await?.to_vec(); 59 - 60 - Ok(builder.body(body).expect("Failed to build response")) 61 74 } 62 75 } 63 76 64 - /// HTTP client trait for sending raw HTTP requests 65 - pub trait HttpClient { 66 - /// Error type returned by the HTTP client 67 - type Error: std::error::Error + Display + Send + Sync + 'static; 68 - /// Send an HTTP request and return the response. 69 - fn send_http( 77 + /// Identifies the active authentication mode for an agent/session. 78 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 79 + pub enum AgentKind { 80 + /// App password (Bearer) session 81 + AppPassword, 82 + /// OAuth (DPoP) session 83 + OAuth, 84 + } 85 + 86 + /// Common interface for stateful sessions used by the Agent wrapper. 87 + /// 88 + /// Implemented by `CredentialSession` (appโ€‘password) and `OAuthSession` (DPoP). 89 + pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { 90 + /// Identify the kind of session. 91 + fn session_kind(&self) -> AgentKind; 92 + /// Return current DID and an optional session id (always Some for OAuth). 93 + fn session_info( 94 + &self, 95 + ) -> core::pin::Pin< 96 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 97 + >; 98 + /// Current base endpoint. 99 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>>; 100 + /// Override per-session call options. 101 + fn set_options<'a>( 102 + &'a self, 103 + opts: CallOptions<'a>, 104 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>; 105 + /// Refresh the session and return a fresh AuthorizationToken. 106 + fn refresh( 70 107 &self, 71 - request: Request<Vec<u8>>, 72 - ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send; 108 + ) -> core::pin::Pin< 109 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 110 + >; 73 111 } 74 - /// XRPC client trait for AT Protocol RPC calls 75 - pub trait XrpcClient: HttpClient + Sync { 76 - /// Get the base URI for XRPC requests (e.g., "https://bsky.social") 77 - fn base_uri(&self) -> CowStr<'_>; 78 - /// Get the authorization token for XRPC requests 79 - #[allow(unused_variables)] 80 - fn authorization_token( 112 + 113 + impl<S, T> AgentSession for CredentialSession<S, T> 114 + where 115 + S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 116 + T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, 117 + { 118 + fn session_kind(&self) -> AgentKind { 119 + AgentKind::AppPassword 120 + } 121 + fn session_info( 81 122 &self, 82 - is_refresh: bool, 83 - ) -> impl Future<Output = Option<AuthorizationToken<'_>>> + Send { 84 - async { None } 123 + ) -> core::pin::Pin< 124 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 125 + > { 126 + Box::pin(async move { 127 + CredentialSession::<S, T>::session_info(self) 128 + .await 129 + .map(|(did, sid)| (did, Some(sid))) 130 + }) 85 131 } 86 - /// Get the `atproto-proxy` header. 87 - fn atproto_proxy_header(&self) -> impl Future<Output = Option<String>> + Send { 88 - async { None } 132 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> { 133 + Box::pin(async move { CredentialSession::<S, T>::endpoint(self).await }) 89 134 } 90 - /// Get the `atproto-accept-labelers` header. 91 - fn atproto_accept_labelers_header(&self) -> impl Future<Output = Option<Vec<String>>> + Send { 92 - async { None } 135 + fn set_options<'a>( 136 + &'a self, 137 + opts: CallOptions<'a>, 138 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> { 139 + Box::pin(async move { CredentialSession::<S, T>::set_options(self, opts).await }) 93 140 } 94 - /// Send an XRPC request and get back a response 95 - fn send<R: XrpcRequest + Send>(&self, request: R) -> impl Future<Output = Result<Response<R>>> + Send 96 - where 97 - Self: Sized + Sync, 98 - { 99 - send_xrpc(self, request) 100 - } 101 - } 102 - 103 - pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 104 - 105 - /// Authorization token types for XRPC requests 106 - pub enum AuthorizationToken<'s> { 107 - /// Bearer token (access JWT, refresh JWT to refresh the session) 108 - Bearer(CowStr<'s>), 109 - /// DPoP token (proof-of-possession) for OAuth 110 - Dpop(CowStr<'s>), 111 - } 112 - 113 - impl TryFrom<AuthorizationToken<'_>> for HeaderValue { 114 - type Error = InvalidHeaderValue; 115 - 116 - fn try_from(token: AuthorizationToken) -> core::result::Result<Self, Self::Error> { 117 - HeaderValue::from_str(&match token { 118 - AuthorizationToken::Bearer(t) => format!("Bearer {t}"), 119 - AuthorizationToken::Dpop(t) => format!("DPoP {t}"), 141 + fn refresh( 142 + &self, 143 + ) -> core::pin::Pin< 144 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 145 + > { 146 + Box::pin(async move { 147 + Ok(CredentialSession::<S, T>::refresh(self) 148 + .await? 149 + .into_static()) 120 150 }) 121 151 } 122 152 } 123 153 124 - /// HTTP headers commonly used in XRPC requests 125 - pub enum Header { 126 - /// Content-Type header 127 - ContentType, 128 - /// Authorization header 129 - Authorization, 130 - /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 131 - /// 132 - /// See: <https://atproto.com/specs/xrpc#service-proxying> 133 - AtprotoProxy, 134 - /// `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. 135 - AtprotoAcceptLabelers, 136 - } 137 - 138 - impl From<Header> for HeaderName { 139 - fn from(value: Header) -> Self { 140 - match value { 141 - Header::ContentType => CONTENT_TYPE, 142 - Header::Authorization => AUTHORIZATION, 143 - Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 144 - Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 145 - } 146 - } 147 - } 148 - 149 - /// Generic XRPC send implementation that uses HttpClient 150 - async fn send_xrpc<R, C>(client: &C, request: R) -> Result<Response<R>> 154 + impl<T, S> AgentSession for OAuthSession<T, S> 151 155 where 152 - R: XrpcRequest + Send, 153 - C: XrpcClient + ?Sized + Sync, 156 + S: ClientAuthStore + Send + Sync + 'static, 157 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 154 158 { 155 - // Build URI: base_uri + /xrpc/ + NSID 156 - let mut uri = format!("{}/xrpc/{}", client.base_uri(), R::NSID); 157 - 158 - // Add query parameters for Query methods 159 - if let XrpcMethod::Query = R::METHOD { 160 - let qs = serde_html_form::to_string(&request).map_err(error::EncodeError::from)?; 161 - if !qs.is_empty() { 162 - uri.push('?'); 163 - uri.push_str(&qs); 164 - } 159 + fn session_kind(&self) -> AgentKind { 160 + AgentKind::OAuth 165 161 } 166 - 167 - // Build HTTP request 168 - let method = match R::METHOD { 169 - XrpcMethod::Query => http::Method::GET, 170 - XrpcMethod::Procedure(_) => http::Method::POST, 171 - }; 172 - 173 - let mut builder = Request::builder().method(method).uri(&uri); 174 - 175 - // Add Content-Type for procedures 176 - if let XrpcMethod::Procedure(encoding) = R::METHOD { 177 - builder = builder.header(Header::ContentType, encoding); 162 + fn session_info( 163 + &self, 164 + ) -> core::pin::Pin< 165 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 166 + > { 167 + Box::pin(async move { 168 + let (did, sid) = OAuthSession::<T, S>::session_info(self).await; 169 + Some((did.into_static(), Some(sid.into_static()))) 170 + }) 178 171 } 179 - 180 - // Add authorization header 181 - let is_refresh = R::NSID == NSID_REFRESH_SESSION; 182 - if let Some(token) = client.authorization_token(is_refresh).await { 183 - let header_value: HeaderValue = token.try_into().map_err(|e| { 184 - error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 185 - })?; 186 - builder = builder.header(Header::Authorization, header_value); 172 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> { 173 + Box::pin(async move { self.endpoint().await }) 187 174 } 188 - 189 - // Add atproto-proxy header 190 - if let Some(proxy) = client.atproto_proxy_header().await { 191 - builder = builder.header(Header::AtprotoProxy, proxy); 175 + fn set_options<'a>( 176 + &'a self, 177 + opts: CallOptions<'a>, 178 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> { 179 + Box::pin(async move { self.set_options(opts).await }) 192 180 } 193 - 194 - // Add atproto-accept-labelers header 195 - if let Some(labelers) = client.atproto_accept_labelers_header().await { 196 - builder = builder.header(Header::AtprotoAcceptLabelers, labelers.join(", ")); 197 - } 198 - 199 - // Serialize body for procedures 200 - let body = if let XrpcMethod::Procedure(_) = R::METHOD { 201 - request.encode_body()? 202 - } else { 203 - vec![] 204 - }; 205 - 206 - // TODO: make this not panic 207 - let http_request = builder.body(body).expect("Failed to build HTTP request"); 208 - 209 - // Send HTTP request 210 - let http_response = client 211 - .send_http(http_request) 212 - .await 213 - .map_err(|e| error::TransportError::Other(Box::new(e)))?; 214 - 215 - let status = http_response.status(); 216 - let buffer = Bytes::from(http_response.into_body()); 217 - 218 - // XRPC errors come as 400/401 with structured error bodies 219 - // Other error status codes (404, 500, etc.) are generic HTTP errors 220 - if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 221 - return Err(ClientError::Http(error::HttpError { 222 - status, 223 - body: Some(buffer), 224 - })); 181 + fn refresh( 182 + &self, 183 + ) -> core::pin::Pin< 184 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 185 + > { 186 + Box::pin(async move { 187 + self.refresh() 188 + .await 189 + .map(|t| t.into_static()) 190 + .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e)))) 191 + }) 225 192 } 226 - 227 - // Response will parse XRPC errors for 400/401, or output for 2xx 228 - Ok(Response::new(buffer, status)) 229 193 } 230 194 231 - /// Session information from `com.atproto.server.createSession` 232 - /// 233 - /// Contains the access and refresh tokens along with user identity information. 234 - #[derive(Debug, Clone)] 235 - pub struct Session { 236 - /// Access token (JWT) used for authenticated requests 237 - pub access_jwt: CowStr<'static>, 238 - /// Refresh token (JWT) used to obtain new access tokens 239 - pub refresh_jwt: CowStr<'static>, 240 - /// User's DID (Decentralized Identifier) 241 - pub did: Did<'static>, 242 - /// User's handle (e.g., "alice.bsky.social") 243 - pub handle: Handle<'static>, 195 + /// Thin wrapper over a stateful session providing a uniform `XrpcClient`. 196 + pub struct Agent<A: AgentSession> { 197 + inner: A, 244 198 } 245 199 246 - impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> for Session { 247 - fn from( 248 - output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>, 249 - ) -> Self { 250 - Self { 251 - access_jwt: output.access_jwt.into_static(), 252 - refresh_jwt: output.refresh_jwt.into_static(), 253 - did: output.did.into_static(), 254 - handle: output.handle.into_static(), 255 - } 200 + impl<A: AgentSession> Agent<A> { 201 + /// Wrap an existing session in an Agent. 202 + pub fn new(inner: A) -> Self { 203 + Self { inner } 256 204 } 257 - } 258 205 259 - /// Authenticated XRPC client wrapper that manages session tokens 260 - /// 261 - /// Wraps an HTTP client and adds automatic Bearer token authentication for XRPC requests. 262 - /// Handles both access tokens for regular requests and refresh tokens for session refresh. 263 - pub struct AuthenticatedClient<C> { 264 - client: C, 265 - base_uri: CowStr<'static>, 266 - session: Option<Session>, 267 - } 206 + /// Return the underlying session kind. 207 + pub fn kind(&self) -> AgentKind { 208 + self.inner.session_kind() 209 + } 268 210 269 - impl<C> AuthenticatedClient<C> { 270 - /// Create a new authenticated client with a base URI 271 - /// 272 - /// # Example 273 - /// ```ignore 274 - /// let client = AuthenticatedClient::new( 275 - /// reqwest::Client::new(), 276 - /// CowStr::from("https://bsky.social") 277 - /// ); 278 - /// ``` 279 - pub fn new(client: C, base_uri: CowStr<'static>) -> Self { 280 - Self { 281 - client, 282 - base_uri: base_uri, 283 - session: None, 284 - } 211 + /// Return session info if available. 212 + pub async fn info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> { 213 + self.inner.session_info().await 285 214 } 286 215 287 - /// Set the session obtained from `createSession` or `refreshSession` 288 - pub fn set_session(&mut self, session: Session) { 289 - self.session = Some(session); 216 + /// Get current endpoint. 217 + pub async fn endpoint(&self) -> url::Url { 218 + self.inner.endpoint().await 290 219 } 291 220 292 - /// Get the current session if one exists 293 - pub fn session(&self) -> Option<&Session> { 294 - self.session.as_ref() 221 + /// Override call options for subsequent requests. 222 + pub async fn set_options(&self, opts: CallOptions<'_>) { 223 + self.inner.set_options(opts).await 295 224 } 296 225 297 - /// Clear the current session locally 298 - /// 299 - /// Note: This only clears the local session state. To properly revoke the session 300 - /// server-side, use `com.atproto.server.deleteSession` before calling this. 301 - pub fn clear_session(&mut self) { 302 - self.session = None; 226 + /// Refresh the session and return a fresh token. 227 + pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> { 228 + self.inner.refresh().await 303 229 } 304 230 } 305 231 306 - impl<C: HttpClient> HttpClient for AuthenticatedClient<C> { 307 - type Error = C::Error; 232 + impl<A: AgentSession> HttpClient for Agent<A> { 233 + type Error = <A as HttpClient>::Error; 308 234 309 235 fn send_http( 310 236 &self, 311 - request: Request<Vec<u8>>, 312 - ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 313 - self.client.send_http(request) 237 + request: http::Request<Vec<u8>>, 238 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 239 + { 240 + self.inner.send_http(request) 314 241 } 315 242 } 316 243 317 - impl<C: HttpClient + Sync> XrpcClient for AuthenticatedClient<C> { 318 - fn base_uri(&self) -> CowStr<'_> { 319 - self.base_uri.clone() 244 + impl<A: AgentSession> XrpcClient for Agent<A> { 245 + fn base_uri(&self) -> url::Url { 246 + self.inner.base_uri() 247 + } 248 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 249 + self.inner.opts() 320 250 } 251 + fn send<R: XrpcRequest + Send>( 252 + self, 253 + request: &R, 254 + ) -> impl Future<Output = XrpcResult<Response<R>>> { 255 + async move { self.inner.send(request).await } 256 + } 257 + } 321 258 322 - async fn authorization_token(&self, is_refresh: bool) -> Option<AuthorizationToken<'_>> { 323 - if is_refresh { 324 - self.session 325 - .as_ref() 326 - .map(|s| AuthorizationToken::Bearer(s.refresh_jwt.clone())) 327 - } else { 328 - self.session 329 - .as_ref() 330 - .map(|s| AuthorizationToken::Bearer(s.access_jwt.clone())) 331 - } 259 + impl<A: AgentSession> From<A> for Agent<A> { 260 + fn from(inner: A) -> Self { 261 + Self::new(inner) 332 262 } 333 263 } 264 + 265 + /// Alias for an agent over a credential (appโ€‘password) session. 266 + pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>; 267 + /// Alias for an agent over an OAuth (DPoP) session. 268 + pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>; 269 + 270 + /// BasicClient: in-memory store + public resolver over a credential session. 271 + pub type BasicClient = Agent< 272 + CredentialSession< 273 + MemorySessionStore<SessionKey, AtpSession>, 274 + jacquard_identity::PublicResolver, 275 + >, 276 + >;
-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;
-960
crates/jacquard/src/identity/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 โ†’ embedded XRPC 5 - //! `resolveHandle` โ†’ public API fallback โ†’ Slingshot `resolveHandle` (if configured). 6 - //! - DID โ†’ Doc: did:web well-known โ†’ PLC/slingshot HTTP โ†’ embedded XRPC `resolveDid`, 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 crate::CowStr; 13 - use crate::client::AuthenticatedClient; 14 - use bon::Builder; 15 - use bytes::Bytes; 16 - use jacquard_common::IntoStatic; 17 - use miette::Diagnostic; 18 - use percent_encoding::percent_decode_str; 19 - use reqwest::StatusCode; 20 - use thiserror::Error; 21 - use url::{ParseError, Url}; 22 - 23 - use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle}; 24 - use crate::types::did_doc::DidDocument; 25 - use crate::types::ident::AtIdentifier; 26 - use crate::types::string::{Did, Handle}; 27 - use crate::types::value::AtDataError; 28 - 29 - #[cfg(feature = "dns")] 30 - use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 31 - 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] reqwest::Error), 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 - buffer: Bytes, 118 - status: StatusCode, 119 - /// Optional DID we intended to resolve; used for validation helpers 120 - 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 - serde_json::from_slice::<DidDocument<'b>>(&self.buffer).map_err(IdentityError::from) 128 - } else { 129 - Err(IdentityError::HttpStatus(self.status)) 130 - } 131 - } 132 - 133 - /// Parse and validate that the DID in the document matches the requested DID if present. 134 - /// 135 - /// On mismatch, returns an error that contains the owned document for inspection. 136 - pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 137 - let doc = self.parse()?; 138 - if let Some(expected) = &self.requested { 139 - if doc.id.as_str() != expected.as_str() { 140 - return Err(IdentityError::DocIdMismatch { 141 - expected: expected.clone(), 142 - doc: doc.clone().into_static(), 143 - }); 144 - } 145 - } 146 - Ok(doc) 147 - } 148 - 149 - /// Parse as owned DidDocument<'static> 150 - pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 151 - if self.status.is_success() { 152 - serde_json::from_slice::<DidDocument<'_>>(&self.buffer) 153 - .map(|d| d.into_static()) 154 - .map_err(IdentityError::from) 155 - } else { 156 - Err(IdentityError::HttpStatus(self.status)) 157 - } 158 - } 159 - } 160 - 161 - /// Handle โ†’ DID fallback step. 162 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 163 - pub enum HandleStep { 164 - /// DNS TXT _atproto.<handle> 165 - DnsTxt, 166 - /// HTTPS GET https://<handle>/.well-known/atproto-did 167 - HttpsWellKnown, 168 - /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 169 - PdsResolveHandle, 170 - } 171 - 172 - /// DID โ†’ Doc fallback step. 173 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 174 - pub enum DidStep { 175 - /// For did:web: fetch from the well-known location 176 - DidWebHttps, 177 - /// For did:plc: fetch from PLC source 178 - PlcHttp, 179 - /// If a PDS base is known, ask it for the DID doc 180 - PdsResolveDid, 181 - } 182 - 183 - /// Configurable resolver options. 184 - /// 185 - /// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 186 - /// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware 187 - /// paths available via helpers that take an `XrpcClient`). 188 - /// - `handle_order`/`did_order`: ordered strategies for resolution. 189 - /// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 190 - /// returning `DocIdMismatch` with the fetched document on mismatch. 191 - /// - `public_fallback_for_handle`: if true (default), attempt 192 - /// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 193 - /// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC 194 - /// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 195 - #[derive(Debug, Clone, Builder)] 196 - #[builder(start_fn = new)] 197 - pub struct ResolverOptions { 198 - /// PLC data source (directory or slingshot) 199 - pub plc_source: PlcSource, 200 - /// Optional PDS base to use for fallbacks 201 - pub pds_fallback: Option<Url>, 202 - /// Order of attempts for handle โ†’ DID resolution 203 - pub handle_order: Vec<HandleStep>, 204 - /// Order of attempts for DID โ†’ Doc resolution 205 - pub did_order: Vec<DidStep>, 206 - /// Validate that fetched DID document id matches the requested DID 207 - pub validate_doc_id: bool, 208 - /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 209 - pub public_fallback_for_handle: bool, 210 - } 211 - 212 - impl Default for ResolverOptions { 213 - fn default() -> Self { 214 - // By default, prefer DNS then HTTPS for handles, then PDS fallback 215 - // For DID documents, prefer method-native sources, then PDS fallback 216 - Self::new() 217 - .plc_source(PlcSource::default()) 218 - .handle_order(vec![ 219 - HandleStep::DnsTxt, 220 - HandleStep::HttpsWellKnown, 221 - HandleStep::PdsResolveHandle, 222 - ]) 223 - .did_order(vec![ 224 - DidStep::DidWebHttps, 225 - DidStep::PlcHttp, 226 - DidStep::PdsResolveDid, 227 - ]) 228 - .validate_doc_id(true) 229 - .public_fallback_for_handle(true) 230 - .build() 231 - } 232 - } 233 - 234 - /// Trait for identity resolution, for pluggable implementations. 235 - /// 236 - /// The provided `DefaultResolver` supports: 237 - /// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 238 - /// - HTTPS well-known for handles and `did:web` 239 - /// - PLC directory or Slingshot for `did:plc` 240 - /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 241 - /// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient` 242 - #[async_trait::async_trait] 243 - pub trait IdentityResolver { 244 - /// Access options for validation decisions in default methods 245 - fn options(&self) -> &ResolverOptions; 246 - 247 - /// Resolve handle 248 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>; 249 - 250 - /// Resolve DID document 251 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>; 252 - async fn resolve_did_doc_owned( 253 - &self, 254 - did: &Did<'_>, 255 - ) -> Result<DidDocument<'static>, IdentityError> { 256 - self.resolve_did_doc(did).await?.into_owned() 257 - } 258 - async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 259 - let resp = self.resolve_did_doc(did).await?; 260 - let doc = resp.parse()?; 261 - // Default-on doc id equality check 262 - if self.options().validate_doc_id { 263 - if doc.id.as_str() != did.as_str() { 264 - return Err(IdentityError::DocIdMismatch { 265 - expected: did.clone().into_static(), 266 - doc: doc.clone().into_static(), 267 - }); 268 - } 269 - } 270 - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 271 - } 272 - async fn pds_for_handle( 273 - &self, 274 - handle: &Handle<'_>, 275 - ) -> Result<(Did<'static>, Url), IdentityError> { 276 - let did = self.resolve_handle(handle).await?; 277 - let pds = self.pds_for_did(&did).await?; 278 - Ok((did, pds)) 279 - } 280 - } 281 - 282 - /// Default resolver implementation with configurable fallback order. 283 - /// 284 - /// Behavior highlights: 285 - /// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS 286 - /// well-known, then Slingshot's unauthenticated `resolveHandle` when 287 - /// `PlcSource::Slingshot` is configured. 288 - /// - DID resolution tries did:web well-known for `did:web`, and the configured 289 - /// PLC base (PLC directory or Slingshot) for `did:plc`. 290 - /// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS) 291 - /// are available via helper methods that accept a user-provided `XrpcClient`. 292 - /// 293 - /// Example 294 - /// ```ignore 295 - /// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions}; 296 - /// use jacquard::client::{AuthenticatedClient, XrpcClient}; 297 - /// use jacquard::types::string::Handle; 298 - /// use jacquard::CowStr; 299 - /// 300 - /// // Build an auth-capable XRPC client (without a session it behaves like public/unauth) 301 - /// let http = reqwest::Client::new(); 302 - /// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://bsky.social")); 303 - /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default()); 304 - /// 305 - /// // Resolve a handle to a DID 306 - /// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap(); 307 - /// ``` 308 - pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> { 309 - http: reqwest::Client, 310 - xrpc: C, 311 - opts: ResolverOptions, 312 - #[cfg(feature = "dns")] 313 - dns: Option<TokioAsyncResolver>, 314 - } 315 - 316 - impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> { 317 - pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self { 318 - Self { 319 - http, 320 - xrpc, 321 - opts, 322 - #[cfg(feature = "dns")] 323 - dns: None, 324 - } 325 - } 326 - 327 - #[cfg(feature = "dns")] 328 - pub fn with_system_dns(mut self) -> Self { 329 - self.dns = Some(TokioAsyncResolver::tokio( 330 - ResolverConfig::default(), 331 - Default::default(), 332 - )); 333 - self 334 - } 335 - 336 - /// Set PLC source (PLC directory or Slingshot) 337 - /// 338 - /// Example 339 - /// ```ignore 340 - /// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions, PlcSource}; 341 - /// let http = reqwest::Client::new(); 342 - /// let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app")); 343 - /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default()) 344 - /// .with_plc_source(PlcSource::slingshot_default()); 345 - /// ``` 346 - pub fn with_plc_source(mut self, source: PlcSource) -> Self { 347 - self.opts.plc_source = source; 348 - self 349 - } 350 - 351 - /// Enable/disable public unauthenticated fallback for resolveHandle 352 - /// 353 - /// Example 354 - /// ```ignore 355 - /// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions}; 356 - /// # let http = reqwest::Client::new(); 357 - /// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app")); 358 - /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default()) 359 - /// .with_public_fallback_for_handle(true); 360 - /// ``` 361 - pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self { 362 - self.opts.public_fallback_for_handle = enable; 363 - self 364 - } 365 - 366 - /// Enable/disable doc id validation 367 - /// 368 - /// Example 369 - /// ```ignore 370 - /// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions}; 371 - /// # let http = reqwest::Client::new(); 372 - /// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app")); 373 - /// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default()) 374 - /// .with_validate_doc_id(true); 375 - /// ``` 376 - pub fn with_validate_doc_id(mut self, enable: bool) -> Self { 377 - self.opts.validate_doc_id = enable; 378 - self 379 - } 380 - 381 - /// Construct the well-known HTTPS URL for a `did:web` DID. 382 - /// 383 - /// - `did:web:example.com` โ†’ `https://example.com/.well-known/did.json` 384 - /// - `did:web:example.com:user:alice` โ†’ `https://example.com/user/alice/did.json` 385 - fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 386 - // did:web:example.com[:path:segments] 387 - let s = did.as_str(); 388 - let rest = s 389 - .strip_prefix("did:web:") 390 - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 391 - let mut parts = rest.split(':'); 392 - let host = parts 393 - .next() 394 - .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 395 - let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; 396 - let path: Vec<&str> = parts.collect(); 397 - if path.is_empty() { 398 - url.set_path(".well-known/did.json"); 399 - } else { 400 - // Append path segments and did.json 401 - let mut segments = url 402 - .path_segments_mut() 403 - .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; 404 - for seg in path { 405 - // Minimally percent-decode each segment per spec guidance 406 - let decoded = percent_decode_str(seg).decode_utf8_lossy(); 407 - segments.push(&decoded); 408 - } 409 - segments.push("did.json"); 410 - // drop segments 411 - } 412 - Ok(url) 413 - } 414 - 415 - #[cfg(test)] 416 - fn test_did_web_url_raw(&self, s: &str) -> String { 417 - let did = Did::new(s).unwrap(); 418 - self.did_web_url(&did).unwrap().to_string() 419 - } 420 - 421 - async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 422 - let resp = self.http.get(url).send().await?; 423 - let status = resp.status(); 424 - let buf = resp.bytes().await?; 425 - Ok((buf, status)) 426 - } 427 - 428 - async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 429 - let resp = self.http.get(url).send().await?; 430 - if resp.status() == StatusCode::OK { 431 - Ok(resp.text().await?) 432 - } else { 433 - Err(IdentityError::Http(resp.error_for_status().unwrap_err())) 434 - } 435 - } 436 - 437 - #[cfg(feature = "dns")] 438 - async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> { 439 - let Some(dns) = &self.dns else { 440 - return Ok(vec![]); 441 - }; 442 - let fqdn = format!("_atproto.{name}."); 443 - let response = dns.txt_lookup(fqdn).await?; 444 - let mut out = Vec::new(); 445 - for txt in response.iter() { 446 - for data in txt.txt_data().iter() { 447 - out.push(String::from_utf8_lossy(data).to_string()); 448 - } 449 - } 450 - Ok(out) 451 - } 452 - 453 - fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> { 454 - let line = body 455 - .lines() 456 - .find(|l| !l.trim().is_empty()) 457 - .ok_or(IdentityError::InvalidWellKnown)?; 458 - let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; 459 - Ok(did.into_static()) 460 - } 461 - } 462 - 463 - impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> { 464 - /// Resolve handle to DID via a PDS XRPC client (auth-aware path) 465 - pub async fn resolve_handle_via_pds( 466 - &self, 467 - handle: &Handle<'_>, 468 - ) -> Result<Did<'static>, IdentityError> { 469 - let req = ResolveHandle::new().handle((*handle).clone()).build(); 470 - let resp = self 471 - .xrpc 472 - .send(req) 473 - .await 474 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 475 - let out = resp 476 - .into_output() 477 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 478 - Did::new_owned(out.did.as_str()) 479 - .map(|d| d.into_static()) 480 - .map_err(|_| IdentityError::InvalidWellKnown) 481 - } 482 - 483 - /// Fetch DID document via PDS resolveDid (returns owned DidDocument) 484 - pub async fn fetch_did_doc_via_pds_owned( 485 - &self, 486 - did: &Did<'_>, 487 - ) -> Result<DidDocument<'static>, IdentityError> { 488 - let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 489 - let resp = self 490 - .xrpc 491 - .send(req) 492 - .await 493 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 494 - let out = resp 495 - .into_output() 496 - .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 497 - let doc_json = serde_json::to_value(&out.did_doc)?; 498 - let s = serde_json::to_string(&doc_json)?; 499 - let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; 500 - Ok(doc_borrowed.into_static()) 501 - } 502 - 503 - /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot. 504 - /// Returns the raw response wrapper for borrowed parsing and validation. 505 - pub async fn fetch_mini_doc_via_slingshot( 506 - &self, 507 - did: &Did<'_>, 508 - ) -> Result<DidDocResponse, IdentityError> { 509 - let base = match &self.opts.plc_source { 510 - PlcSource::Slingshot { base } => base.clone(), 511 - _ => { 512 - return Err(IdentityError::UnsupportedDidMethod( 513 - "mini-doc requires Slingshot source".into(), 514 - )); 515 - } 516 - }; 517 - let mut url = base; 518 - url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 519 - if let Ok(qs) = 520 - serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build()) 521 - { 522 - url.set_query(Some(&qs)); 523 - } 524 - let (buf, status) = self.get_json_bytes(url).await?; 525 - Ok(DidDocResponse { 526 - buffer: buf, 527 - status, 528 - requested: Some(did.clone().into_static()), 529 - }) 530 - } 531 - } 532 - 533 - #[async_trait::async_trait] 534 - impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> { 535 - fn options(&self) -> &ResolverOptions { 536 - &self.opts 537 - } 538 - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 539 - let host = handle.as_str(); 540 - for step in &self.opts.handle_order { 541 - match step { 542 - HandleStep::DnsTxt => { 543 - #[cfg(feature = "dns")] 544 - { 545 - if let Ok(txts) = self.dns_txt(host).await { 546 - for txt in txts { 547 - if let Some(did_str) = txt.strip_prefix("did=") { 548 - if let Ok(did) = Did::new(did_str) { 549 - return Ok(did.into_static()); 550 - } 551 - } 552 - } 553 - } 554 - } 555 - } 556 - HandleStep::HttpsWellKnown => { 557 - let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 558 - if let Ok(text) = self.get_text(url).await { 559 - if let Ok(did) = Self::parse_atproto_did_body(&text) { 560 - return Ok(did); 561 - } 562 - } 563 - } 564 - HandleStep::PdsResolveHandle => { 565 - // Prefer embedded XRPC client 566 - if let Ok(did) = self.resolve_handle_via_pds(handle).await { 567 - return Ok(did); 568 - } 569 - // Public unauth fallback 570 - if self.opts.public_fallback_for_handle { 571 - if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { 572 - url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 573 - if let Ok(qs) = serde_html_form::to_string( 574 - &ResolveHandle::new().handle((*handle).clone()).build(), 575 - ) { 576 - url.set_query(Some(&qs)); 577 - } else { 578 - continue; 579 - } 580 - if let Ok((buf, status)) = self.get_json_bytes(url).await { 581 - if status.is_success() { 582 - if let Ok(val) = 583 - serde_json::from_slice::<serde_json::Value>(&buf) 584 - { 585 - if let Some(did_str) = 586 - val.get("did").and_then(|v| v.as_str()) 587 - { 588 - if let Ok(did) = Did::new_owned(did_str) { 589 - return Ok(did.into_static()); 590 - } 591 - } 592 - } 593 - } 594 - } 595 - } 596 - } 597 - // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint. 598 - if let PlcSource::Slingshot { base } = &self.opts.plc_source { 599 - let mut url = base.clone(); 600 - url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 601 - if let Ok(qs) = serde_html_form::to_string( 602 - &ResolveHandle::new().handle((*handle).clone()).build(), 603 - ) { 604 - url.set_query(Some(&qs)); 605 - } else { 606 - continue; 607 - } 608 - if let Ok((buf, status)) = self.get_json_bytes(url).await { 609 - if status.is_success() { 610 - if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) { 611 - if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) { 612 - if let Ok(did) = Did::new_owned(did_str) { 613 - return Ok(did.into_static()); 614 - } 615 - } 616 - } 617 - } 618 - } 619 - } 620 - } 621 - } 622 - } 623 - Err(IdentityError::InvalidWellKnown) 624 - } 625 - 626 - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 627 - let s = did.as_str(); 628 - for step in &self.opts.did_order { 629 - match step { 630 - DidStep::DidWebHttps if s.starts_with("did:web:") => { 631 - let url = self.did_web_url(did)?; 632 - if let Ok((buf, status)) = self.get_json_bytes(url).await { 633 - return Ok(DidDocResponse { 634 - buffer: buf, 635 - status, 636 - requested: Some(did.clone().into_static()), 637 - }); 638 - } 639 - } 640 - DidStep::PlcHttp if s.starts_with("did:plc:") => { 641 - let url = match &self.opts.plc_source { 642 - PlcSource::PlcDirectory { base } => base.join(did.as_str())?, 643 - PlcSource::Slingshot { base } => base.join(did.as_str())?, 644 - }; 645 - if let Ok((buf, status)) = self.get_json_bytes(url).await { 646 - return Ok(DidDocResponse { 647 - buffer: buf, 648 - status, 649 - requested: Some(did.clone().into_static()), 650 - }); 651 - } 652 - } 653 - DidStep::PdsResolveDid => { 654 - // Try embedded XRPC client for full DID doc 655 - if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await { 656 - let buf = serde_json::to_vec(&doc).unwrap_or_default(); 657 - return Ok(DidDocResponse { 658 - buffer: Bytes::from(buf), 659 - status: StatusCode::OK, 660 - requested: Some(did.clone().into_static()), 661 - }); 662 - } 663 - // Fallback: if Slingshot configured, return mini-doc response (partial doc) 664 - if let PlcSource::Slingshot { base } = &self.opts.plc_source { 665 - let url = self.slingshot_mini_doc_url(base, did.as_str())?; 666 - let (buf, status) = self.get_json_bytes(url).await?; 667 - return Ok(DidDocResponse { 668 - buffer: buf, 669 - status, 670 - requested: Some(did.clone().into_static()), 671 - }); 672 - } 673 - } 674 - _ => {} 675 - } 676 - } 677 - Err(IdentityError::UnsupportedDidMethod(s.to_string())) 678 - } 679 - } 680 - 681 - /// Warnings produced during identity checks that are not fatal 682 - #[derive(Debug, Clone, PartialEq, Eq)] 683 - pub enum IdentityWarning { 684 - /// The DID doc did not contain the expected handle alias under alsoKnownAs 685 - HandleAliasMismatch { expected: Handle<'static> }, 686 - } 687 - 688 - impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> { 689 - /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings. 690 - /// This applies the default equality check on the document id (error with doc if mismatch). 691 - pub async fn resolve_handle_and_doc( 692 - &self, 693 - handle: &Handle<'_>, 694 - ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> { 695 - let did = self.resolve_handle(handle).await?; 696 - let resp = self.resolve_did_doc(&did).await?; 697 - let resp_for_parse = resp.clone(); 698 - let doc_borrowed = resp_for_parse.parse()?; 699 - if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { 700 - return Err(IdentityError::DocIdMismatch { 701 - expected: did.clone().into_static(), 702 - doc: doc_borrowed.clone().into_static(), 703 - }); 704 - } 705 - let mut warnings = Vec::new(); 706 - // Check handle alias presence (soft warning) 707 - let expected_alias = format!("at://{}", handle.as_str()); 708 - let has_alias = doc_borrowed 709 - .also_known_as 710 - .as_ref() 711 - .map(|v| v.iter().any(|s| s.as_ref() == expected_alias)) 712 - .unwrap_or(false); 713 - if !has_alias { 714 - warnings.push(IdentityWarning::HandleAliasMismatch { 715 - expected: handle.clone().into_static(), 716 - }); 717 - } 718 - Ok((did, resp, warnings)) 719 - } 720 - 721 - /// Build Slingshot mini-doc URL for an identifier (handle or DID) 722 - fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> { 723 - let mut url = base.clone(); 724 - url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 725 - url.set_query(Some(&format!( 726 - "identifier={}", 727 - urlencoding::Encoded::new(identifier) 728 - ))); 729 - Ok(url) 730 - } 731 - 732 - /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier 733 - pub async fn fetch_mini_doc_via_slingshot_identifier( 734 - &self, 735 - identifier: &AtIdentifier<'_>, 736 - ) -> Result<MiniDocResponse, IdentityError> { 737 - let base = match &self.opts.plc_source { 738 - PlcSource::Slingshot { base } => base.clone(), 739 - _ => { 740 - return Err(IdentityError::UnsupportedDidMethod( 741 - "mini-doc requires Slingshot source".into(), 742 - )); 743 - } 744 - }; 745 - let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; 746 - let (buf, status) = self.get_json_bytes(url).await?; 747 - Ok(MiniDocResponse { 748 - buffer: buf, 749 - status, 750 - }) 751 - } 752 - } 753 - 754 - /// Slingshot mini-doc JSON response wrapper 755 - #[derive(Clone)] 756 - pub struct MiniDocResponse { 757 - buffer: Bytes, 758 - status: StatusCode, 759 - } 760 - 761 - impl MiniDocResponse { 762 - /// Parse borrowed MiniDoc 763 - pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> { 764 - if self.status.is_success() { 765 - serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 766 - } else { 767 - Err(IdentityError::HttpStatus(self.status)) 768 - } 769 - } 770 - } 771 - 772 - /// Slingshot mini-doc data (subset of DID doc info) 773 - #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 774 - #[serde(rename_all = "camelCase")] 775 - pub struct MiniDoc<'a> { 776 - #[serde(borrow)] 777 - pub did: Did<'a>, 778 - #[serde(borrow)] 779 - pub handle: Handle<'a>, 780 - #[serde(borrow)] 781 - pub pds: crate::CowStr<'a>, 782 - #[serde(borrow, rename = "signingKey", alias = "signing_key")] 783 - pub signing_key: crate::CowStr<'a>, 784 - } 785 - 786 - #[cfg(test)] 787 - mod tests { 788 - use super::*; 789 - 790 - #[test] 791 - fn did_web_urls() { 792 - let r = DefaultResolver::new( 793 - reqwest::Client::new(), 794 - TestXrpc::new(), 795 - ResolverOptions::default(), 796 - ); 797 - assert_eq!( 798 - r.test_did_web_url_raw("did:web:example.com"), 799 - "https://example.com/.well-known/did.json" 800 - ); 801 - assert_eq!( 802 - r.test_did_web_url_raw("did:web:example.com:user:alice"), 803 - "https://example.com/user/alice/did.json" 804 - ); 805 - } 806 - 807 - #[test] 808 - fn parse_validated_ok() { 809 - let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 810 - let requested = Did::new_owned("did:plc:alice").unwrap(); 811 - let resp = DidDocResponse { 812 - buffer: buf, 813 - status: StatusCode::OK, 814 - requested: Some(requested), 815 - }; 816 - let _doc = resp.parse_validated().expect("valid"); 817 - } 818 - 819 - #[test] 820 - fn parse_validated_mismatch() { 821 - let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 822 - let requested = Did::new_owned("did:plc:alice").unwrap(); 823 - let resp = DidDocResponse { 824 - buffer: buf, 825 - status: StatusCode::OK, 826 - requested: Some(requested), 827 - }; 828 - match resp.parse_validated() { 829 - Err(IdentityError::DocIdMismatch { expected, doc }) => { 830 - assert_eq!(expected.as_str(), "did:plc:alice"); 831 - assert_eq!(doc.id.as_str(), "did:plc:bob"); 832 - } 833 - other => panic!("unexpected result: {:?}", other), 834 - } 835 - } 836 - 837 - #[test] 838 - fn slingshot_mini_doc_url_build() { 839 - let r = DefaultResolver::new( 840 - reqwest::Client::new(), 841 - TestXrpc::new(), 842 - ResolverOptions::default(), 843 - ); 844 - let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); 845 - let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap(); 846 - assert_eq!( 847 - url.as_str(), 848 - "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com" 849 - ); 850 - } 851 - 852 - #[test] 853 - fn slingshot_mini_doc_parse_success() { 854 - let buf = Bytes::from_static( 855 - br#"{ 856 - "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid", 857 - "handle": "bad-example.com", 858 - "pds": "https://porcini.us-east.host.bsky.network", 859 - "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j" 860 - }"#, 861 - ); 862 - let resp = MiniDocResponse { 863 - buffer: buf, 864 - status: StatusCode::OK, 865 - }; 866 - let doc = resp.parse().expect("parse mini-doc"); 867 - assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); 868 - assert_eq!(doc.handle.as_str(), "bad-example.com"); 869 - assert_eq!( 870 - doc.pds.as_ref(), 871 - "https://porcini.us-east.host.bsky.network" 872 - ); 873 - assert!(doc.signing_key.as_ref().starts_with('z')); 874 - } 875 - 876 - #[test] 877 - fn slingshot_mini_doc_parse_error_status() { 878 - let buf = Bytes::from_static( 879 - br#"{ 880 - "error": "RecordNotFound", 881 - "message": "This record was deleted" 882 - }"#, 883 - ); 884 - let resp = MiniDocResponse { 885 - buffer: buf, 886 - status: StatusCode::BAD_REQUEST, 887 - }; 888 - match resp.parse() { 889 - Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), 890 - other => panic!("unexpected: {:?}", other), 891 - } 892 - } 893 - use crate::client::{HttpClient, XrpcClient}; 894 - use http::Request; 895 - use jacquard_common::CowStr; 896 - 897 - struct TestXrpc { 898 - client: reqwest::Client, 899 - } 900 - impl TestXrpc { 901 - fn new() -> Self { 902 - Self { 903 - client: reqwest::Client::new(), 904 - } 905 - } 906 - } 907 - impl HttpClient for TestXrpc { 908 - type Error = reqwest::Error; 909 - async fn send_http( 910 - &self, 911 - request: Request<Vec<u8>>, 912 - ) -> Result<http::Response<Vec<u8>>, Self::Error> { 913 - self.client.send_http(request).await 914 - } 915 - } 916 - impl XrpcClient for TestXrpc { 917 - fn base_uri(&self) -> CowStr<'_> { 918 - CowStr::from("https://public.api.bsky.app") 919 - } 920 - } 921 - } 922 - 923 - /// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient 924 - pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>; 925 - 926 - impl Default for PublicResolver { 927 - /// Build a resolver with: 928 - /// - reqwest HTTP client 929 - /// - XRPC base https://public.api.bsky.app (unauthenticated) 930 - /// - default options (DNS enabled if compiled, public fallback for handles enabled) 931 - /// 932 - /// Example 933 - /// ```ignore 934 - /// use jacquard::identity::resolver::PublicResolver; 935 - /// let resolver = PublicResolver::default(); 936 - /// ``` 937 - fn default() -> Self { 938 - let http = reqwest::Client::new(); 939 - let xrpc = 940 - AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app")); 941 - let opts = ResolverOptions::default(); 942 - let resolver = DefaultResolver::new(http, xrpc, opts); 943 - #[cfg(feature = "dns")] 944 - let resolver = resolver.with_system_dns(); 945 - resolver 946 - } 947 - } 948 - 949 - /// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 950 - /// mini-doc fallbacks, unauthenticated by default. 951 - pub fn slingshot_resolver_default() -> PublicResolver { 952 - let http = reqwest::Client::new(); 953 - let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app")); 954 - let mut opts = ResolverOptions::default(); 955 - opts.plc_source = PlcSource::slingshot_default(); 956 - let resolver = DefaultResolver::new(http, xrpc, opts); 957 - #[cfg(feature = "dns")] 958 - let resolver = resolver.with_system_dns(); 959 - resolver 960 - }
+106 -42
crates/jacquard/src/lib.rs
··· 17 17 //! 18 18 //! ## Example 19 19 //! 20 - //! Dead simple api client. Logs in, prints the latest 5 posts from your timeline. 20 + //! Dead simple API client: login with an app password, then fetch the latest 5 posts. 21 21 //! 22 22 //! ```no_run 23 23 //! # use clap::Parser; 24 24 //! # use jacquard::CowStr; 25 + //! use std::sync::Arc; 25 26 //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 26 - //! use jacquard::api::com_atproto::server::create_session::CreateSession; 27 - //! use jacquard::client::{AuthenticatedClient, Session, XrpcClient}; 27 + //! use jacquard::client::credential_session::{CredentialSession, SessionKey}; 28 + //! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore}; 29 + //! use jacquard::identity::PublicResolver as JacquardResolver; 30 + //! use jacquard::types::xrpc::XrpcClient; 28 31 //! # use miette::IntoDiagnostic; 29 32 //! 30 33 //! # #[derive(Parser, Debug)] 31 34 //! # #[command(author, version, about = "Jacquard - AT Protocol client demo")] 32 35 //! # struct Args { 33 - //! # /// Username/handle (e.g., alice.mosphere.at) 36 + //! # /// Username/handle (e.g., alice.bsky.social) or DID 34 37 //! # #[arg(short, long)] 35 38 //! # username: CowStr<'static>, 36 - //! # 37 - //! # /// PDS URL (e.g., https://bsky.social) 38 - //! # #[arg(long, default_value = "https://bsky.social")] 39 - //! # pds: CowStr<'static>, 40 39 //! # 41 40 //! # /// App password 42 41 //! # #[arg(short, long)] ··· 46 45 //! #[tokio::main] 47 46 //! async fn main() -> miette::Result<()> { 48 47 //! let args = Args::parse(); 49 - //! 50 - //! // Create HTTP client 51 - //! let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds); 52 - //! 53 - //! // Create session 54 - //! let session = Session::from( 55 - //! client 56 - //! .send( 57 - //! CreateSession::new() 58 - //! .identifier(args.username) 59 - //! .password(args.password) 60 - //! .build(), 61 - //! ) 62 - //! .await? 63 - //! .into_output()?, 64 - //! ); 65 - //! 66 - //! println!("logged in as {} ({})", session.handle, session.did); 67 - //! client.set_session(session); 68 - //! 48 + //! // Resolver + storage 49 + //! let resolver = Arc::new(JacquardResolver::default()); 50 + //! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 51 + //! let client = Arc::new(resolver.clone()); 52 + //! // Create session object with implicit public appview endpoint until login/restore 53 + //! let session = CredentialSession::new(store, client); 54 + //! // Log in (resolves PDS automatically) and persist as (did, "session") 55 + //! session 56 + //! .login(args.username.clone(), args.password.clone(), None, None, None) 57 + //! .await 58 + //! .into_diagnostic()?; 69 59 //! // Fetch timeline 70 - //! println!("\nfetching timeline..."); 71 - //! let timeline = client 72 - //! .send(GetTimeline::new().limit(5).build()) 73 - //! .await? 74 - //! .into_output()?; 75 - //! 76 - //! println!("\ntimeline ({} posts):", timeline.feed.len()); 60 + //! let timeline = session 61 + //! .send(&GetTimeline::new().limit(5).build()) 62 + //! .await 63 + //! .into_diagnostic()? 64 + //! .into_output() 65 + //! .into_diagnostic()?; 66 + //! println!("timeline ({} posts):", timeline.feed.len()); 77 67 //! for (i, post) in timeline.feed.iter().enumerate() { 78 - //! println!("\n{}. by {}", i + 1, post.post.author.handle); 79 - //! println!( 80 - //! " {}", 81 - //! serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 82 - //! ); 68 + //! println!("{}. by {}", i + 1, post.post.author.handle); 83 69 //! } 70 + //! Ok(()) 71 + //! } 72 + //! ``` 84 73 //! 74 + //! ## Client options: 75 + //! 76 + //! - Stateless XRPC: any `HttpClient` (e.g., `reqwest::Client`) implements `XrpcExt`, 77 + //! which provides `xrpc(base: Url) -> XrpcCall` for per-request calls with 78 + //! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you 79 + //! want to pass auth on each call or build advanced flows. 80 + //! ```no_run 81 + //! # use jacquard::types::xrpc::XrpcExt; 82 + //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 83 + //! # use jacquard::types::ident::AtIdentifier; 84 + //! # use miette::IntoDiagnostic; 85 + //! # 86 + //! #[tokio::main] 87 + //! async fn main() -> miette::Result<()> { 88 + //! let http = reqwest::Client::new(); 89 + //! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; 90 + //! let resp = http 91 + //! .xrpc(base) 92 + //! .send( 93 + //! &GetAuthorFeed::new() 94 + //! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 95 + //! .limit(5) 96 + //! .build(), 97 + //! ) 98 + //! .await?; 99 + //! let out = resp.into_output()?; 100 + //! println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 101 + //! Ok(()) 102 + //! } 103 + //! ``` 104 + //! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and 105 + //! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the 106 + //! base endpoint to the user's PDS on login/restore. 107 + //! 108 + //! Per-request overrides (stateless) 109 + //! ```no_run 110 + //! # use jacquard::AuthorizationToken; 111 + //! # use jacquard::types::xrpc::XrpcExt; 112 + //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 113 + //! # use jacquard::types::ident::AtIdentifier; 114 + //! # use jacquard::CowStr; 115 + //! # use miette::IntoDiagnostic; 116 + //! # 117 + //! #[tokio::main] 118 + //! async fn main() -> miette::Result<()> { 119 + //! let http = reqwest::Client::new(); 120 + //! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; 121 + //! let resp = http 122 + //! .xrpc(base) 123 + //! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 124 + //! .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 125 + //! .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 126 + //! .send( 127 + //! &GetAuthorFeed::new() 128 + //! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 129 + //! .limit(5) 130 + //! .build(), 131 + //! ) 132 + //! .await?; 133 + //! let out = resp.into_output()?; 134 + //! println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 85 135 //! Ok(()) 86 136 //! } 87 137 //! ``` 88 138 //! 139 + //! Token storage: 140 + //! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests. 141 + //! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions 142 + //! and OAuth `ClientAuthStore` (unified on-disk map). 143 + //! ```no_run 144 + //! use std::sync::Arc; 145 + //! use jacquard::client::credential_session::{CredentialSession, SessionKey}; 146 + //! use jacquard::client::{AtpSession, FileAuthStore}; 147 + //! use jacquard::identity::PublicResolver; 148 + //! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json")); 149 + //! let client = Arc::new(PublicResolver::default()); 150 + //! let session = CredentialSession::new(store, client); 151 + //! ``` 152 + //! 89 153 90 154 #![warn(missing_docs)] 91 155 92 156 /// XRPC client traits and basic implementation 93 157 pub mod client; 158 + /// OAuth usage helpers (discovery, PAR, token exchange) 94 159 95 160 #[cfg(feature = "api")] 96 161 /// If enabled, re-export the generated api crate ··· 101 166 /// if enabled, reexport the attribute macros 102 167 pub use jacquard_derive::*; 103 168 104 - /// Identity resolution helpers (DIDs, handles, PDS endpoints) 105 - pub mod identity; 169 + pub use jacquard_identity as identity;
+22 -29
crates/jacquard/src/main.rs
··· 1 + use std::sync::Arc; 1 2 use clap::Parser; 2 3 use jacquard::CowStr; 3 4 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 - use jacquard::api::com_atproto::server::create_session::CreateSession; 5 - use jacquard::client::{AuthenticatedClient, Session, XrpcClient}; 5 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 6 + use jacquard::client::{AtpSession, MemorySessionStore}; 7 + use jacquard::identity::PublicResolver as JacquardResolver; 8 + use jacquard::types::xrpc::XrpcClient; 6 9 use miette::IntoDiagnostic; 7 10 8 11 #[derive(Parser, Debug)] 9 12 #[command(author, version, about = "Jacquard - AT Protocol client demo")] 10 13 struct Args { 11 - /// Username/handle (e.g., alice.bsky.social) 14 + /// Username/handle (e.g., alice.bsky.social) or DID 12 15 #[arg(short, long)] 13 16 username: CowStr<'static>, 14 - 15 - /// PDS URL (e.g., https://bsky.social) 16 - #[arg(long, default_value = "https://bsky.social")] 17 - pds: CowStr<'static>, 18 17 19 18 /// App password 20 19 #[arg(short, long)] ··· 24 23 async fn main() -> miette::Result<()> { 25 24 let args = Args::parse(); 26 25 27 - // Create HTTP client 28 - let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds); 26 + // Resolver + in-memory store 27 + let resolver = Arc::new(JacquardResolver::default()); 28 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 29 + let client = Arc::new(resolver.clone()); 30 + let session = CredentialSession::new(store, client); 29 31 30 - // Create session 31 - let session = Session::from( 32 - client 33 - .send( 34 - CreateSession::new() 35 - .identifier(args.username) 36 - .password(args.password) 37 - .build(), 38 - ) 39 - .await? 40 - .into_output()?, 41 - ); 42 - 43 - println!("logged in as {} ({})", session.handle, session.did); 44 - client.set_session(session); 32 + // Login; resolves PDS from handle/DID automatically. Persisted under (did, "session"). 33 + let _ = session 34 + .login(args.username.clone(), args.password.clone(), None, None, None) 35 + .await 36 + .into_diagnostic()?; 45 37 46 38 // Fetch timeline 47 - println!("\nfetching timeline..."); 48 - let timeline = client 49 - .send(GetTimeline::new().limit(5).build()) 50 - .await? 51 - .into_output()?; 39 + let timeline = session 40 + .send(&GetTimeline::new().limit(5).build()) 41 + .await 42 + .into_diagnostic()? 43 + .into_output() 44 + .into_diagnostic()?; 52 45 53 46 println!("\ntimeline ({} posts):", timeline.feed.len()); 54 47 for (i, post) in timeline.feed.iter().enumerate() {
+147
crates/jacquard/tests/agent.rs
··· 1 + use std::collections::VecDeque; 2 + use std::sync::Arc; 3 + 4 + use http::{HeaderValue, Response as HttpResponse, StatusCode}; 5 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 6 + use jacquard::client::{Agent, AtpSession}; 7 + use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions}; 8 + use jacquard::types::did::Did; 9 + use jacquard::types::string::Handle; 10 + use jacquard_common::http_client::HttpClient; 11 + use jacquard_common::session::MemorySessionStore; 12 + use tokio::sync::Mutex; 13 + 14 + #[derive(Clone, Default)] 15 + struct MockClient { 16 + queue: Arc<Mutex<VecDeque<http::Response<Vec<u8>>>>>, 17 + log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>, 18 + } 19 + 20 + impl MockClient { 21 + async fn push(&self, resp: http::Response<Vec<u8>>) { 22 + self.queue.lock().await.push_back(resp); 23 + } 24 + } 25 + 26 + impl HttpClient for MockClient { 27 + type Error = std::convert::Infallible; 28 + fn send_http( 29 + &self, 30 + request: http::Request<Vec<u8>>, 31 + ) -> impl core::future::Future< 32 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 33 + > + Send { 34 + let log = self.log.clone(); 35 + let queue = self.queue.clone(); 36 + async move { 37 + log.lock().await.push(request); 38 + Ok(queue.lock().await.pop_front().expect("no queued response")) 39 + } 40 + } 41 + } 42 + 43 + #[async_trait::async_trait] 44 + impl IdentityResolver for MockClient { 45 + fn options(&self) -> &ResolverOptions { 46 + use std::sync::LazyLock; 47 + static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default); 48 + &OPTS 49 + } 50 + async fn resolve_handle( 51 + &self, 52 + _handle: &Handle<'_>, 53 + ) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> { 54 + Ok(Did::new_static("did:plc:alice").unwrap()) 55 + } 56 + async fn resolve_did_doc( 57 + &self, 58 + _did: &Did<'_>, 59 + ) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> { 60 + let doc = serde_json::json!({ 61 + "id": "did:plc:alice", 62 + "service": [{ 63 + "id": "#pds", 64 + "type": "AtprotoPersonalDataServer", 65 + "serviceEndpoint": "https://pds" 66 + }] 67 + }); 68 + Ok(DidDocResponse { 69 + buffer: bytes::Bytes::from(serde_json::to_vec(&doc).unwrap()), 70 + status: StatusCode::OK, 71 + requested: None, 72 + }) 73 + } 74 + } 75 + 76 + // XrpcExt blanket impl applies via HttpClient 77 + 78 + fn refresh_session_body(access: &str, refresh: &str) -> Vec<u8> { 79 + serde_json::to_vec(&serde_json::json!({ 80 + "accessJwt": access, 81 + "refreshJwt": refresh, 82 + "did": "did:plc:alice", 83 + "handle": "alice.bsky.social" 84 + })) 85 + .unwrap() 86 + } 87 + 88 + #[tokio::test] 89 + async fn agent_delegates_to_session_and_refreshes() { 90 + let client = Arc::new(MockClient::default()); 91 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 92 + let session = CredentialSession::new(store.clone(), client.clone()); 93 + 94 + // Seed a session in the store and activate it via restore (sets endpoint to PDS) 95 + let atp = AtpSession { 96 + access_jwt: "acc1".into(), 97 + refresh_jwt: "ref1".into(), 98 + did: Did::new_static("did:plc:alice").unwrap(), 99 + handle: Handle::new_static("alice.bsky.social").unwrap(), 100 + }; 101 + let key: SessionKey = (atp.did.clone(), "session".into()); 102 + jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), atp) 103 + .await 104 + .unwrap(); 105 + session 106 + .restore(Did::new_static("did:plc:alice").unwrap(), "session".into()) 107 + .await 108 + .unwrap(); 109 + 110 + let agent: Agent<_> = Agent::from(session); 111 + assert_eq!(agent.kind(), jacquard::client::AgentKind::AppPassword); 112 + let info = agent.info().await.expect("session info"); 113 + assert_eq!(info.0.as_str(), "did:plc:alice"); 114 + assert_eq!(info.1.as_ref().unwrap().as_str(), "session"); 115 + assert_eq!(agent.endpoint().await.as_str(), "https://pds/"); 116 + 117 + // Queue a refresh response and call agent.refresh(); Authorization header must use refresh token 118 + client 119 + .push( 120 + HttpResponse::builder() 121 + .status(StatusCode::OK) 122 + .header(http::header::CONTENT_TYPE, "application/json") 123 + .body(refresh_session_body("acc2", "ref2")) 124 + .unwrap(), 125 + ) 126 + .await; 127 + 128 + let token = agent.refresh().await.expect("refresh ok"); 129 + match token { 130 + jacquard::AuthorizationToken::Bearer(s) => assert_eq!(s.as_ref(), "acc2"), 131 + _ => panic!("expected Bearer token"), 132 + } 133 + 134 + // Validate the refreshSession call used the refresh token header 135 + let log = client.log.lock().await; 136 + assert_eq!(log.len(), 1); 137 + assert!( 138 + log[0] 139 + .uri() 140 + .to_string() 141 + .ends_with("/xrpc/com.atproto.server.refreshSession") 142 + ); 143 + assert_eq!( 144 + log[0].headers().get(http::header::AUTHORIZATION), 145 + Some(&HeaderValue::from_static("Bearer ref1")) 146 + ); 147 + }
+261
crates/jacquard/tests/credential_session.rs
··· 1 + use std::collections::VecDeque; 2 + use std::sync::Arc; 3 + 4 + use bytes::Bytes; 5 + use http::{HeaderValue, Method, Response as HttpResponse, StatusCode}; 6 + use jacquard::client::AtpSession; 7 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 8 + use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions}; 9 + use jacquard::types::did::Did; 10 + use jacquard::types::string::Handle; 11 + use jacquard::types::xrpc::XrpcClient; 12 + use jacquard_common::http_client::HttpClient; 13 + use jacquard_common::session::{MemorySessionStore, SessionStore}; 14 + use tokio::sync::{Mutex, RwLock}; 15 + 16 + #[derive(Clone, Default)] 17 + struct MockClient { 18 + // Queue of HTTP responses to pop for each send_http call 19 + queue: Arc<Mutex<VecDeque<HttpResponse<Vec<u8>>>>>, 20 + // Capture requests for assertions 21 + log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>, 22 + // Count calls to identity resolver helpers 23 + did_doc_calls: Arc<RwLock<usize>>, 24 + } 25 + 26 + impl MockClient { 27 + async fn push(&self, resp: HttpResponse<Vec<u8>>) { 28 + self.queue.lock().await.push_back(resp); 29 + } 30 + async fn take_log(&self) -> Vec<http::Request<Vec<u8>>> { 31 + let mut log = self.log.lock().await; 32 + let out = log.clone(); 33 + log.clear(); 34 + out 35 + } 36 + } 37 + 38 + impl HttpClient for MockClient { 39 + type Error = std::convert::Infallible; 40 + 41 + fn send_http( 42 + &self, 43 + request: http::Request<Vec<u8>>, 44 + ) -> impl core::future::Future< 45 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 46 + > + Send { 47 + let log = self.log.clone(); 48 + let queue = self.queue.clone(); 49 + async move { 50 + log.lock().await.push(request); 51 + Ok(queue.lock().await.pop_front().expect("no queued response")) 52 + } 53 + } 54 + } 55 + 56 + #[async_trait::async_trait] 57 + impl IdentityResolver for MockClient { 58 + fn options(&self) -> &ResolverOptions { 59 + use std::sync::LazyLock; 60 + static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default); 61 + &OPTS 62 + } 63 + 64 + async fn resolve_handle( 65 + &self, 66 + handle: &Handle<'_>, 67 + ) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> { 68 + // Return a fixed DID for any handle 69 + assert!(handle.as_str().contains('.')); 70 + Ok(Did::new_static("did:plc:alice").unwrap()) 71 + } 72 + 73 + async fn resolve_did_doc( 74 + &self, 75 + did: &Did<'_>, 76 + ) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> { 77 + // Track calls and return a minimal DID doc with a PDS endpoint 78 + *self.did_doc_calls.write().await += 1; 79 + assert_eq!(did.as_str(), "did:plc:alice"); 80 + let doc = serde_json::json!({ 81 + "id": "did:plc:alice", 82 + "service": [{ 83 + "id": "#pds", 84 + "type": "AtprotoPersonalDataServer", 85 + "serviceEndpoint": "https://pds" 86 + }] 87 + }); 88 + Ok(DidDocResponse { 89 + buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()), 90 + status: StatusCode::OK, 91 + requested: None, 92 + }) 93 + } 94 + } 95 + 96 + // XrpcExt blanket impl applies via HttpClient 97 + 98 + fn create_session_body() -> Vec<u8> { 99 + serde_json::to_vec(&serde_json::json!({ 100 + "accessJwt": "acc1", 101 + "refreshJwt": "ref1", 102 + "did": "did:plc:alice", 103 + "handle": "alice.bsky.social" 104 + })) 105 + .unwrap() 106 + } 107 + 108 + fn refresh_session_body(access: &str, refresh: &str) -> Vec<u8> { 109 + serde_json::to_vec(&serde_json::json!({ 110 + "accessJwt": access, 111 + "refreshJwt": refresh, 112 + "did": "did:plc:alice", 113 + "handle": "alice.bsky.social" 114 + })) 115 + .unwrap() 116 + } 117 + 118 + fn get_session_ok_body() -> Vec<u8> { 119 + serde_json::to_vec(&serde_json::json!({ 120 + "did": "did:plc:alice", 121 + "handle": "alice.bsky.social", 122 + "active": true 123 + })) 124 + .unwrap() 125 + } 126 + 127 + #[tokio::test(flavor = "multi_thread")] 128 + async fn credential_login_and_auto_refresh() { 129 + let client = Arc::new(MockClient::default()); 130 + 131 + // Queue responses in order: createSession 200 โ†’ getSession 401 โ†’ refreshSession 200 โ†’ getSession 200 132 + client 133 + .push( 134 + HttpResponse::builder() 135 + .status(StatusCode::OK) 136 + .header(http::header::CONTENT_TYPE, "application/json") 137 + .body(create_session_body()) 138 + .unwrap(), 139 + ) 140 + .await; 141 + client 142 + .push( 143 + HttpResponse::builder() 144 + .status(StatusCode::UNAUTHORIZED) 145 + .header(http::header::CONTENT_TYPE, "application/json") 146 + .body(serde_json::to_vec(&serde_json::json!({"error":"ExpiredToken"})).unwrap()) 147 + .unwrap(), 148 + ) 149 + .await; 150 + client 151 + .push( 152 + HttpResponse::builder() 153 + .status(StatusCode::OK) 154 + .header(http::header::CONTENT_TYPE, "application/json") 155 + .body(refresh_session_body("acc2", "ref2")) 156 + .unwrap(), 157 + ) 158 + .await; 159 + client 160 + .push( 161 + HttpResponse::builder() 162 + .status(StatusCode::OK) 163 + .header(http::header::CONTENT_TYPE, "application/json") 164 + .body(get_session_ok_body()) 165 + .unwrap(), 166 + ) 167 + .await; 168 + 169 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 170 + let session = CredentialSession::new(store.clone(), client.clone()); 171 + 172 + // Before login, default endpoint should be public appview 173 + assert_eq!( 174 + session.endpoint().await.as_str(), 175 + "https://public.bsky.app/" 176 + ); 177 + 178 + // Login using handle; resolves to PDS and persists session 179 + session 180 + .login( 181 + jacquard::CowStr::from("alice.bsky.social"), 182 + jacquard::CowStr::from("apppass"), 183 + Some(jacquard::CowStr::from("session")), 184 + None, 185 + None, 186 + ) 187 + .await 188 + .expect("login ok"); 189 + 190 + // Endpoint switches to PDS 191 + assert_eq!(session.endpoint().await.as_str(), "https://pds/"); 192 + 193 + // Send a request that will first 401 (ExpiredToken), then refresh, then succeed 194 + let resp = session 195 + .send(&jacquard::api::com_atproto::server::get_session::GetSession) 196 + .await 197 + .expect("xrpc send ok"); 198 + assert_eq!(resp.status(), StatusCode::OK); 199 + let out = resp 200 + .parse() 201 + .expect("parse ok after refresh (GetSession output)"); 202 + assert_eq!(out.handle.as_str(), "alice.bsky.social"); 203 + 204 + // Verify request sequence and Authorization headers used 205 + let log = client.take_log().await; 206 + assert_eq!(log.len(), 4, "expected four HTTP calls"); 207 + // 0: createSession (no auth) 208 + assert_eq!(log[0].method(), Method::POST); 209 + assert!( 210 + log[0] 211 + .uri() 212 + .to_string() 213 + .ends_with("/xrpc/com.atproto.server.createSession") 214 + ); 215 + assert!(log[0].headers().get(http::header::AUTHORIZATION).is_none()); 216 + // 1: getSession (uses access token acc1) 217 + assert_eq!(log[1].method(), Method::GET); 218 + assert!( 219 + log[1] 220 + .uri() 221 + .to_string() 222 + .ends_with("/xrpc/com.atproto.server.getSession") 223 + ); 224 + assert_eq!( 225 + log[1].headers().get(http::header::AUTHORIZATION), 226 + Some(&HeaderValue::from_static("Bearer acc1")) 227 + ); 228 + // 2: refreshSession (uses refresh token ref1) 229 + assert_eq!(log[2].method(), Method::POST); 230 + assert!( 231 + log[2] 232 + .uri() 233 + .to_string() 234 + .ends_with("/xrpc/com.atproto.server.refreshSession") 235 + ); 236 + assert_eq!( 237 + log[2].headers().get(http::header::AUTHORIZATION), 238 + Some(&HeaderValue::from_static("Bearer ref1")) 239 + ); 240 + // 3: getSession (re-sent with new access token acc2) 241 + assert_eq!(log[3].method(), Method::GET); 242 + assert!( 243 + log[3] 244 + .uri() 245 + .to_string() 246 + .ends_with("/xrpc/com.atproto.server.getSession") 247 + ); 248 + assert_eq!( 249 + log[3].headers().get(http::header::AUTHORIZATION), 250 + Some(&HeaderValue::from_static("Bearer acc2")) 251 + ); 252 + 253 + // Verify store updated with refreshed tokens 254 + let key: SessionKey = ( 255 + Did::new_static("did:plc:alice").unwrap(), 256 + jacquard::CowStr::from("session"), 257 + ); 258 + let updated = store.get(&key).await.expect("session present"); 259 + assert_eq!(updated.access_jwt.as_ref(), "acc2"); 260 + assert_eq!(updated.refresh_jwt.as_ref(), "ref2"); 261 + }
+374
crates/jacquard/tests/oauth_auto_refresh.rs
··· 1 + use std::collections::VecDeque; 2 + use std::sync::Arc; 3 + 4 + use bytes::Bytes; 5 + use http::{HeaderValue, Method, Response as HttpResponse, StatusCode}; 6 + use jacquard::client::Agent; 7 + use jacquard::IntoStatic; 8 + use jacquard::types::did::Did; 9 + use jacquard::types::xrpc::XrpcClient; 10 + use jacquard_common::http_client::HttpClient; 11 + use jacquard_oauth::atproto::AtprotoClientMetadata; 12 + use jacquard_oauth::client::OAuthSession; 13 + use jacquard_oauth::session::SessionRegistry; 14 + use jacquard_oauth::resolver::OAuthResolver; 15 + use jacquard_oauth::scopes::Scope; 16 + use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData}; 17 + use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet}; 18 + use tokio::sync::Mutex; 19 + 20 + #[derive(Clone, Default)] 21 + struct MockClient { 22 + queue: Arc<Mutex<VecDeque<http::Response<Vec<u8>>>>>, 23 + log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>, 24 + } 25 + 26 + impl MockClient { 27 + async fn push(&self, resp: http::Response<Vec<u8>>) { 28 + self.queue.lock().await.push_back(resp); 29 + } 30 + } 31 + 32 + impl HttpClient for MockClient { 33 + type Error = std::convert::Infallible; 34 + fn send_http( 35 + &self, 36 + request: http::Request<Vec<u8>>, 37 + ) -> impl core::future::Future< 38 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 39 + > + Send { 40 + let log = self.log.clone(); 41 + let queue = self.queue.clone(); 42 + async move { 43 + log.lock().await.push(request); 44 + Ok(queue 45 + .lock() 46 + .await 47 + .pop_front() 48 + .expect("no queued response")) 49 + } 50 + } 51 + } 52 + 53 + #[async_trait::async_trait] 54 + impl jacquard::identity::resolver::IdentityResolver for MockClient { 55 + fn options(&self) -> &jacquard::identity::resolver::ResolverOptions { 56 + use std::sync::LazyLock; 57 + static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> = 58 + LazyLock::new(jacquard::identity::resolver::ResolverOptions::default); 59 + &OPTS 60 + } 61 + async fn resolve_handle( 62 + &self, 63 + _handle: &jacquard::types::string::Handle<'_>, 64 + ) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> { 65 + Ok(Did::new_static("did:plc:alice").unwrap()) 66 + } 67 + async fn resolve_did_doc( 68 + &self, 69 + _did: &Did<'_>, 70 + ) -> std::result::Result<jacquard::identity::resolver::DidDocResponse, jacquard::identity::resolver::IdentityError> { 71 + let doc = serde_json::json!({ 72 + "id": "did:plc:alice", 73 + "service": [{ 74 + "id": "#pds", 75 + "type": "AtprotoPersonalDataServer", 76 + "serviceEndpoint": "https://pds" 77 + }] 78 + }); 79 + Ok(jacquard::identity::resolver::DidDocResponse { 80 + buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()), 81 + status: StatusCode::OK, 82 + requested: None, 83 + }) 84 + } 85 + } 86 + 87 + #[async_trait::async_trait] 88 + impl OAuthResolver for MockClient { 89 + async fn get_authorization_server_metadata( 90 + &self, 91 + issuer: &url::Url, 92 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, jacquard_oauth::resolver::ResolverError> { 93 + // Return minimal metadata with supported auth method "none" and DPoP support 94 + let mut md = OAuthAuthorizationServerMetadata::default(); 95 + md.issuer = jacquard::CowStr::from(issuer.as_str()); 96 + md.token_endpoint = jacquard::CowStr::from(format!("{}/token", issuer)); 97 + md.authorization_endpoint = jacquard::CowStr::from(format!("{}/authorize", issuer)); 98 + md.require_pushed_authorization_requests = Some(true); 99 + md.pushed_authorization_request_endpoint = 100 + Some(jacquard::CowStr::from(format!("{}/par", issuer))); 101 + md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]); 102 + md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]); 103 + use jacquard::IntoStatic; 104 + Ok(md.into_static()) 105 + } 106 + 107 + async fn get_resource_server_metadata( 108 + &self, 109 + _pds: &url::Url, 110 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, jacquard_oauth::resolver::ResolverError> { 111 + // Return metadata pointing to the same issuer as above 112 + let mut md = OAuthAuthorizationServerMetadata::default(); 113 + md.issuer = jacquard::CowStr::from("https://issuer"); 114 + md.token_endpoint = jacquard::CowStr::from("https://issuer/token"); 115 + md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize"); 116 + md.require_pushed_authorization_requests = Some(true); 117 + md.pushed_authorization_request_endpoint = Some(jacquard::CowStr::from("https://issuer/par")); 118 + md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]); 119 + md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]); 120 + Ok(md.into_static()) 121 + } 122 + 123 + async fn verify_issuer( 124 + &self, 125 + _server_metadata: &OAuthAuthorizationServerMetadata<'_>, 126 + _sub: &Did<'_>, 127 + ) -> Result<url::Url, jacquard_oauth::resolver::ResolverError> { 128 + Ok(url::Url::parse("https://pds").unwrap()) 129 + } 130 + } 131 + 132 + fn get_session_unauthorized() -> http::Response<Vec<u8>> { 133 + HttpResponse::builder() 134 + .status(StatusCode::UNAUTHORIZED) 135 + .header( 136 + http::header::WWW_AUTHENTICATE, 137 + HeaderValue::from_static("DPoP realm=\"pds\", error=\"invalid_token\""), 138 + ) 139 + .body(Vec::new()) 140 + .unwrap() 141 + } 142 + 143 + fn get_session_unauthorized_body() -> http::Response<Vec<u8>> { 144 + HttpResponse::builder() 145 + .status(StatusCode::UNAUTHORIZED) 146 + .header(http::header::CONTENT_TYPE, "application/json") 147 + .body( 148 + serde_json::to_vec(&serde_json::json!({ 149 + "error":"InvalidToken" 150 + })) 151 + .unwrap(), 152 + ) 153 + .unwrap() 154 + } 155 + 156 + fn token_use_dpop_nonce() -> http::Response<Vec<u8>> { 157 + HttpResponse::builder() 158 + .status(StatusCode::BAD_REQUEST) 159 + .header(http::header::CONTENT_TYPE, "application/json") 160 + .header("DPoP-Nonce", HeaderValue::from_static("n1")) 161 + .body(serde_json::to_vec(&serde_json::json!({"error":"use_dpop_nonce"})).unwrap()) 162 + .unwrap() 163 + } 164 + 165 + fn token_refresh_ok() -> http::Response<Vec<u8>> { 166 + HttpResponse::builder() 167 + .status(StatusCode::OK) 168 + .header(http::header::CONTENT_TYPE, "application/json") 169 + .body( 170 + serde_json::to_vec(&serde_json::json!({ 171 + "access_token":"newacc", 172 + "token_type":"DPoP", 173 + "refresh_token":"newref", 174 + "expires_in": 3600 175 + })) 176 + .unwrap(), 177 + ) 178 + .unwrap() 179 + } 180 + 181 + fn get_session_ok() -> http::Response<Vec<u8>> { 182 + HttpResponse::builder() 183 + .status(StatusCode::OK) 184 + .header(http::header::CONTENT_TYPE, "application/json") 185 + .body( 186 + serde_json::to_vec(&serde_json::json!({ 187 + "did":"did:plc:alice", 188 + "handle":"alice.bsky.social", 189 + "active":true 190 + })) 191 + .unwrap(), 192 + ) 193 + .unwrap() 194 + } 195 + 196 + impl jacquard_oauth::dpop::DpopExt for MockClient {} 197 + 198 + #[tokio::test(flavor = "multi_thread")] 199 + async fn oauth_xrpc_invalid_token_triggers_refresh_and_retries() { 200 + // (reopen test body since we inserted a trait impl) 201 + let client = Arc::new(MockClient::default()); 202 + 203 + client.push(get_session_unauthorized()).await; 204 + client.push(token_use_dpop_nonce()).await; 205 + client.push(token_refresh_ok()).await; 206 + client.push(get_session_ok()).await; 207 + 208 + let mut path = std::env::temp_dir(); 209 + path.push(format!("jacquard-oauth-test-{}.json", std::process::id())); 210 + std::fs::write(&path, "{}").unwrap(); 211 + let store = jacquard::client::FileAuthStore::new(&path); 212 + 213 + let client_data = ClientData { 214 + keyset: None, 215 + config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 216 + }; 217 + use jacquard::IntoStatic; 218 + let session_data = ClientSessionData { 219 + account_did: Did::new_static("did:plc:alice").unwrap(), 220 + session_id: jacquard::CowStr::from("state"), 221 + host_url: url::Url::parse("https://pds").unwrap(), 222 + authserver_url: url::Url::parse("https://issuer").unwrap(), 223 + authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"), 224 + authserver_revocation_endpoint: None, 225 + scopes: vec![Scope::Atproto], 226 + dpop_data: DpopClientData { 227 + dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")]) 228 + .unwrap(), 229 + dpop_authserver_nonce: jacquard::CowStr::from(""), 230 + dpop_host_nonce: jacquard::CowStr::from(""), 231 + }, 232 + token_set: TokenSet { 233 + iss: jacquard::CowStr::from("https://issuer"), 234 + sub: Did::new_static("did:plc:alice").unwrap(), 235 + aud: jacquard::CowStr::from("https://pds"), 236 + scope: None, 237 + refresh_token: Some(jacquard::CowStr::from("rt1")), 238 + access_token: jacquard::CowStr::from("atk1"), 239 + token_type: OAuthTokenType::DPoP, 240 + expires_at: None, 241 + }, 242 + } 243 + .into_static(); 244 + let client_arc = client.clone(); 245 + let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data)); 246 + // Seed the store so refresh can load the session 247 + let data_store = ClientSessionData { 248 + account_did: Did::new_static("did:plc:alice").unwrap(), 249 + session_id: jacquard::CowStr::from("state"), 250 + host_url: url::Url::parse("https://pds").unwrap(), 251 + authserver_url: url::Url::parse("https://issuer").unwrap(), 252 + authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"), 253 + authserver_revocation_endpoint: None, 254 + scopes: vec![Scope::Atproto], 255 + dpop_data: DpopClientData { 256 + dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")]) 257 + .unwrap(), 258 + dpop_authserver_nonce: jacquard::CowStr::from(""), 259 + dpop_host_nonce: jacquard::CowStr::from(""), 260 + }, 261 + token_set: TokenSet { 262 + iss: jacquard::CowStr::from("https://issuer"), 263 + sub: Did::new_static("did:plc:alice").unwrap(), 264 + aud: jacquard::CowStr::from("https://pds"), 265 + scope: None, 266 + refresh_token: Some(jacquard::CowStr::from("rt1")), 267 + access_token: jacquard::CowStr::from("atk1"), 268 + token_type: OAuthTokenType::DPoP, 269 + expires_at: None, 270 + }, 271 + } 272 + .into_static(); 273 + registry.set(data_store).await.unwrap(); 274 + let session = OAuthSession::new(registry, client_arc, session_data); 275 + 276 + let agent: Agent<_> = Agent::from(session); 277 + let resp = agent 278 + .send(&jacquard::api::com_atproto::server::get_session::GetSession) 279 + .await 280 + .expect("xrpc send ok after auto-refresh"); 281 + assert_eq!(resp.status(), StatusCode::OK); 282 + 283 + // Inspect the request log 284 + let log = client.log.lock().await; 285 + assert_eq!(log.len(), 4, "expected 4 HTTP calls"); 286 + // 0: getSession with old token 287 + assert_eq!(log[0].method(), Method::GET); 288 + assert!(log[0].headers().get(http::header::AUTHORIZATION).unwrap().to_str().unwrap().starts_with("DPoP ")); 289 + assert!(log[0] 290 + .uri() 291 + .to_string() 292 + .ends_with("/xrpc/com.atproto.server.getSession")); 293 + // 1 and 2: token refresh attempts 294 + assert_eq!(log[1].method(), Method::POST); 295 + assert!(log[1].uri().to_string().ends_with("/token")); 296 + assert!(log[1].headers().contains_key("DPoP")); 297 + assert_eq!(log[2].method(), Method::POST); 298 + assert!(log[2].uri().to_string().ends_with("/token")); 299 + assert!(log[2].headers().contains_key("DPoP")); 300 + // 3: retried getSession with new access token 301 + assert_eq!(log[3].method(), Method::GET); 302 + assert!(log[3] 303 + .headers() 304 + .get(http::header::AUTHORIZATION) 305 + .unwrap() 306 + .to_str() 307 + .unwrap() 308 + .starts_with("DPoP newacc")); 309 + 310 + // Cleanup temp file 311 + let _ = std::fs::remove_file(&path); 312 + } 313 + 314 + #[tokio::test(flavor = "multi_thread")] 315 + async fn oauth_xrpc_invalid_token_body_triggers_refresh_and_retries() { 316 + let client = Arc::new(MockClient::default()); 317 + 318 + // Queue responses: initial 401 with JSON body; token refresh 400(use_dpop_nonce); token refresh 200; retry getSession 200 319 + client.push(get_session_unauthorized_body()).await; 320 + client.push(token_use_dpop_nonce()).await; 321 + client.push(token_refresh_ok()).await; 322 + client.push(get_session_ok()).await; 323 + 324 + let mut path = std::env::temp_dir(); 325 + path.push(format!("jacquard-oauth-test-body-{}.json", std::process::id())); 326 + std::fs::write(&path, "{}").unwrap(); 327 + let store = jacquard::client::FileAuthStore::new(&path); 328 + 329 + let client_data = ClientData { 330 + keyset: None, 331 + config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 332 + }; 333 + use jacquard::IntoStatic; 334 + let session_data = ClientSessionData { 335 + account_did: Did::new_static("did:plc:alice").unwrap(), 336 + session_id: jacquard::CowStr::from("state"), 337 + host_url: url::Url::parse("https://pds").unwrap(), 338 + authserver_url: url::Url::parse("https://issuer").unwrap(), 339 + authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"), 340 + authserver_revocation_endpoint: None, 341 + scopes: vec![Scope::Atproto], 342 + dpop_data: DpopClientData { 343 + dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")]) 344 + .unwrap(), 345 + dpop_authserver_nonce: jacquard::CowStr::from(""), 346 + dpop_host_nonce: jacquard::CowStr::from(""), 347 + }, 348 + token_set: TokenSet { 349 + iss: jacquard::CowStr::from("https://issuer"), 350 + sub: Did::new_static("did:plc:alice").unwrap(), 351 + aud: jacquard::CowStr::from("https://pds"), 352 + scope: None, 353 + refresh_token: Some(jacquard::CowStr::from("rt1")), 354 + access_token: jacquard::CowStr::from("atk1"), 355 + token_type: OAuthTokenType::DPoP, 356 + expires_at: None, 357 + }, 358 + } 359 + .into_static(); 360 + let client_arc = client.clone(); 361 + let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data)); 362 + registry.set(session_data.clone()).await.unwrap(); 363 + let session = OAuthSession::new(registry, client_arc, session_data); 364 + 365 + let agent: Agent<_> = Agent::from(session); 366 + let resp = agent 367 + .send(&jacquard::api::com_atproto::server::get_session::GetSession) 368 + .await 369 + .expect("xrpc send ok after auto-refresh"); 370 + assert_eq!(resp.status(), StatusCode::OK); 371 + 372 + // Cleanup temp file 373 + let _ = std::fs::remove_file(&path); 374 + }
+293
crates/jacquard/tests/oauth_flow.rs
··· 1 + use std::collections::VecDeque; 2 + use std::sync::Arc; 3 + 4 + use bytes::Bytes; 5 + use http::{Response as HttpResponse, StatusCode}; 6 + use jacquard::IntoStatic; 7 + use jacquard::client::Agent; 8 + use jacquard::types::xrpc::XrpcClient; 9 + use jacquard_common::http_client::HttpClient; 10 + use jacquard_oauth::atproto::AtprotoClientMetadata; 11 + use jacquard_oauth::authstore::ClientAuthStore; 12 + use jacquard_oauth::client::OAuthClient; 13 + use jacquard_oauth::resolver::OAuthResolver; 14 + use jacquard_oauth::scopes::Scope; 15 + use jacquard_oauth::session::ClientData; 16 + 17 + #[derive(Clone, Default)] 18 + struct MockClient { 19 + queue: Arc<tokio::sync::Mutex<VecDeque<http::Response<Vec<u8>>>>>, 20 + } 21 + 22 + impl MockClient { 23 + async fn push(&self, resp: http::Response<Vec<u8>>) { 24 + self.queue.lock().await.push_back(resp); 25 + } 26 + } 27 + 28 + impl HttpClient for MockClient { 29 + type Error = std::convert::Infallible; 30 + fn send_http( 31 + &self, 32 + _request: http::Request<Vec<u8>>, 33 + ) -> impl core::future::Future< 34 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 35 + > + Send { 36 + let queue = self.queue.clone(); 37 + async move { Ok(queue.lock().await.pop_front().expect("no queued response")) } 38 + } 39 + } 40 + 41 + #[async_trait::async_trait] 42 + impl jacquard::identity::resolver::IdentityResolver for MockClient { 43 + fn options(&self) -> &jacquard::identity::resolver::ResolverOptions { 44 + use std::sync::LazyLock; 45 + static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> = 46 + LazyLock::new(jacquard::identity::resolver::ResolverOptions::default); 47 + &OPTS 48 + } 49 + async fn resolve_handle( 50 + &self, 51 + _handle: &jacquard::types::string::Handle<'_>, 52 + ) -> std::result::Result< 53 + jacquard::types::did::Did<'static>, 54 + jacquard::identity::resolver::IdentityError, 55 + > { 56 + Ok(jacquard::types::did::Did::new_static("did:plc:alice").unwrap()) 57 + } 58 + async fn resolve_did_doc( 59 + &self, 60 + _did: &jacquard::types::did::Did<'_>, 61 + ) -> std::result::Result< 62 + jacquard::identity::resolver::DidDocResponse, 63 + jacquard::identity::resolver::IdentityError, 64 + > { 65 + let doc = serde_json::json!({ 66 + "id": "did:plc:alice", 67 + "service": [{ 68 + "id": "#pds", 69 + "type": "AtprotoPersonalDataServer", 70 + "serviceEndpoint": "https://pds" 71 + }] 72 + }); 73 + Ok(jacquard::identity::resolver::DidDocResponse { 74 + buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()), 75 + status: StatusCode::OK, 76 + requested: None, 77 + }) 78 + } 79 + } 80 + 81 + #[async_trait::async_trait] 82 + impl OAuthResolver for MockClient { 83 + async fn resolve_oauth( 84 + &self, 85 + _input: &str, 86 + ) -> Result< 87 + ( 88 + jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>, 89 + Option<jacquard_common::types::did_doc::DidDocument<'static>>, 90 + ), 91 + jacquard_oauth::resolver::ResolverError, 92 + > { 93 + let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default(); 94 + md.issuer = jacquard::CowStr::from("https://issuer"); 95 + md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize"); 96 + md.token_endpoint = jacquard::CowStr::from("https://issuer/token"); 97 + md.require_pushed_authorization_requests = Some(true); 98 + md.pushed_authorization_request_endpoint = 99 + Some(jacquard::CowStr::from("https://issuer/par")); 100 + md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]); 101 + md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]); 102 + 103 + // Simple DID doc pointing to https://pds 104 + let doc = serde_json::json!({ 105 + "id": "did:plc:alice", 106 + "service": [{ 107 + "id": "#pds", 108 + "type": "AtprotoPersonalDataServer", 109 + "serviceEndpoint": "https://pds" 110 + }] 111 + }); 112 + let buf = Bytes::from(serde_json::to_vec(&doc).unwrap()); 113 + let did_doc_b: jacquard_common::types::did_doc::DidDocument<'_> = 114 + serde_json::from_slice(&buf).unwrap(); 115 + let did_doc = did_doc_b.into_static(); 116 + Ok((md.into_static(), Some(did_doc))) 117 + } 118 + async fn get_authorization_server_metadata( 119 + &self, 120 + issuer: &url::Url, 121 + ) -> Result< 122 + jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>, 123 + jacquard_oauth::resolver::ResolverError, 124 + > { 125 + let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default(); 126 + md.issuer = jacquard::CowStr::from(issuer.as_str()); 127 + md.authorization_endpoint = jacquard::CowStr::from(format!("{}/authorize", issuer)); 128 + md.token_endpoint = jacquard::CowStr::from(format!("{}/token", issuer)); 129 + md.require_pushed_authorization_requests = Some(true); 130 + md.pushed_authorization_request_endpoint = 131 + Some(jacquard::CowStr::from(format!("{}/par", issuer))); 132 + md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]); 133 + md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]); 134 + Ok(md.into_static()) 135 + } 136 + 137 + async fn get_resource_server_metadata( 138 + &self, 139 + _pds: &url::Url, 140 + ) -> Result< 141 + jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>, 142 + jacquard_oauth::resolver::ResolverError, 143 + > { 144 + let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default(); 145 + md.issuer = jacquard::CowStr::from("https://issuer/"); 146 + md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize"); 147 + md.token_endpoint = jacquard::CowStr::from("https://issuer/token"); 148 + md.require_pushed_authorization_requests = Some(true); 149 + md.pushed_authorization_request_endpoint = 150 + Some(jacquard::CowStr::from("https://issuer/par")); 151 + md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]); 152 + md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]); 153 + Ok(md.into_static()) 154 + } 155 + } 156 + 157 + impl jacquard_oauth::dpop::DpopExt for MockClient {} 158 + 159 + #[tokio::test(flavor = "multi_thread")] 160 + async fn oauth_end_to_end_mock_flow() { 161 + let client = Arc::new(MockClient::default()); 162 + // Queue responses: PAR 201, token 200, XRPC getSession 200 163 + client 164 + .push( 165 + HttpResponse::builder() 166 + .status(StatusCode::CREATED) 167 + .header(http::header::CONTENT_TYPE, "application/json") 168 + .body( 169 + serde_json::to_vec(&serde_json::json!({ 170 + "request_uri": "urn:par:abc", 171 + "expires_in": 60 172 + })) 173 + .unwrap(), 174 + ) 175 + .unwrap(), 176 + ) 177 + .await; 178 + client 179 + .push( 180 + HttpResponse::builder() 181 + .status(StatusCode::OK) 182 + .header(http::header::CONTENT_TYPE, "application/json") 183 + .header("DPoP-Nonce", http::HeaderValue::from_static("n1")) 184 + .body( 185 + serde_json::to_vec(&serde_json::json!({ 186 + "access_token": "atk1", 187 + "token_type": "DPoP", 188 + "refresh_token": "rt1", 189 + "sub": "did:plc:alice", 190 + "iss": "https://issuer", 191 + "aud": "https://pds", 192 + "expires_in": 3600 193 + })) 194 + .unwrap(), 195 + ) 196 + .unwrap(), 197 + ) 198 + .await; 199 + client 200 + .push( 201 + HttpResponse::builder() 202 + .status(StatusCode::OK) 203 + .header(http::header::CONTENT_TYPE, "application/json") 204 + .body( 205 + serde_json::to_vec(&serde_json::json!({ 206 + "did": "did:plc:alice", 207 + "handle": "alice.bsky.social", 208 + "active": true 209 + })) 210 + .unwrap(), 211 + ) 212 + .unwrap(), 213 + ) 214 + .await; 215 + 216 + // File-backed store for auth state/session 217 + let mut path = std::env::temp_dir(); 218 + path.push(format!("jacquard-oauth-flow-{}.json", std::process::id())); 219 + std::fs::write(&path, "{}").unwrap(); 220 + let store = jacquard::client::FileAuthStore::new(&path); 221 + 222 + let client_data: ClientData<'static> = ClientData { 223 + keyset: None, 224 + config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 225 + }; 226 + let client_arc = client.clone(); 227 + let oauth = OAuthClient::new_from_resolver(store, (*client_arc).clone(), client_data); 228 + 229 + // Build metadata and call PAR to get an AuthRequestData, then save in store 230 + let (server_metadata, identity) = client.resolve_oauth("alice.bsky.social").await.unwrap(); 231 + let metadata = jacquard_oauth::request::OAuthMetadata { 232 + server_metadata, 233 + client_metadata: jacquard_oauth::atproto::atproto_client_metadata( 234 + AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 235 + &None, 236 + ) 237 + .unwrap() 238 + .into_static(), 239 + keyset: None, 240 + }; 241 + let login_hint = identity.map(|_| jacquard::CowStr::from("alice.bsky.social")); 242 + let auth_req = jacquard_oauth::request::par(client.as_ref(), login_hint, None, &metadata) 243 + .await 244 + .unwrap(); 245 + // Construct authorization URL as OAuthClient::start_auth would do 246 + #[derive(serde::Serialize)] 247 + struct Parameters<'s> { 248 + client_id: url::Url, 249 + request_uri: jacquard::CowStr<'s>, 250 + } 251 + let auth_url = format!( 252 + "{}?{}", 253 + metadata.server_metadata.authorization_endpoint, 254 + serde_html_form::to_string(Parameters { 255 + client_id: metadata.client_metadata.client_id.clone(), 256 + request_uri: auth_req.request_uri.clone(), 257 + }) 258 + .unwrap() 259 + ); 260 + assert!(auth_url.contains("/authorize?")); 261 + assert!(auth_url.contains("request_uri")); 262 + // keep state for the callback 263 + let state = auth_req.state.clone(); 264 + oauth 265 + .registry 266 + .store 267 + .save_auth_req_info(&auth_req) 268 + .await 269 + .unwrap(); 270 + 271 + // callback: exchange code, create session 272 + use jacquard_oauth::types::CallbackParams; 273 + let session = oauth 274 + .callback(CallbackParams { 275 + code: jacquard::CowStr::from("code123"), 276 + state: Some(state.clone()), 277 + // Callback compares exact string with metadata.issuer (which is a URL string 278 + // including trailing slash). Use normalized form to match. 279 + iss: Some(jacquard::CowStr::from("https://issuer/")), 280 + }) 281 + .await 282 + .unwrap(); 283 + 284 + // Wrap in Agent and send a resource XRPC call to verify Authorization works 285 + let agent: Agent<_> = Agent::from(session); 286 + let resp = agent 287 + .send(&jacquard::api::com_atproto::server::get_session::GetSession) 288 + .await 289 + .unwrap(); 290 + assert_eq!(resp.status(), StatusCode::OK); 291 + 292 + let _ = std::fs::remove_file(&path); 293 + }
+125
crates/jacquard/tests/restore_pds_cache.rs
··· 1 + use std::sync::Arc; 2 + 3 + use bytes::Bytes; 4 + use http::{Response as HttpResponse, StatusCode}; 5 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 6 + use jacquard::client::{AtpSession, FileAuthStore}; 7 + use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions}; 8 + use jacquard::types::did::Did; 9 + use jacquard::types::string::Handle; 10 + use jacquard_common::http_client::HttpClient; 11 + use jacquard_common::session::SessionStore; 12 + use std::fs; 13 + use std::path::PathBuf; 14 + use tokio::sync::RwLock; 15 + use url::Url; 16 + 17 + #[derive(Clone, Default)] 18 + struct MockResolver { 19 + // Count calls to DID doc resolution 20 + did_doc_calls: Arc<RwLock<usize>>, 21 + } 22 + 23 + impl HttpClient for MockResolver { 24 + type Error = std::convert::Infallible; 25 + fn send_http( 26 + &self, 27 + _request: http::Request<Vec<u8>>, 28 + ) -> impl core::future::Future< 29 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 30 + > + Send { 31 + async { 32 + // Not used in this test 33 + Ok(HttpResponse::builder() 34 + .status(StatusCode::OK) 35 + .body(Vec::new()) 36 + .unwrap()) 37 + } 38 + } 39 + } 40 + 41 + #[async_trait::async_trait] 42 + impl IdentityResolver for MockResolver { 43 + fn options(&self) -> &ResolverOptions { 44 + use std::sync::LazyLock; 45 + static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default); 46 + &OPTS 47 + } 48 + async fn resolve_handle( 49 + &self, 50 + _handle: &Handle<'_>, 51 + ) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> { 52 + Ok(Did::new_static("did:plc:alice").unwrap()) 53 + } 54 + async fn resolve_did_doc( 55 + &self, 56 + _did: &Did<'_>, 57 + ) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> { 58 + *self.did_doc_calls.write().await += 1; 59 + let doc = serde_json::json!({ 60 + "id": "did:plc:alice", 61 + "service": [{ 62 + "id": "#pds", 63 + "type": "AtprotoPersonalDataServer", 64 + "serviceEndpoint": "https://pds-resolved" 65 + }] 66 + }); 67 + Ok(DidDocResponse { 68 + buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()), 69 + status: StatusCode::OK, 70 + requested: None, 71 + }) 72 + } 73 + } 74 + 75 + fn temp_file() -> PathBuf { 76 + let mut p = std::env::temp_dir(); 77 + p.push(format!("jacquard-test-restore-{}.json", std::process::id())); 78 + p 79 + } 80 + 81 + #[tokio::test] 82 + async fn restore_uses_cached_pds_when_present() { 83 + let path = temp_file(); 84 + fs::write(&path, "{}").unwrap(); 85 + let store = Arc::new(FileAuthStore::new(&path)); 86 + let resolver = Arc::new(MockResolver::default()); 87 + 88 + // Seed an app-password session in the file store 89 + let session = AtpSession { 90 + access_jwt: "acc".into(), 91 + refresh_jwt: "ref".into(), 92 + did: Did::new_static("did:plc:alice").unwrap(), 93 + handle: Handle::new_static("alice.bsky.social").unwrap(), 94 + }; 95 + let key: SessionKey = (session.did.clone(), "session".into()); 96 + jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), session) 97 + .await 98 + .unwrap(); 99 + // Verify it is persisted 100 + assert!(SessionStore::get(store.as_ref(), &key).await.is_some()); 101 + // Persist PDS endpoint cache to avoid DID resolution on restore 102 + store 103 + .set_atp_pds(&key, &Url::parse("https://pds-cached").unwrap()) 104 + .unwrap(); 105 + assert_eq!( 106 + store 107 + .get_atp_pds(&key) 108 + .ok() 109 + .flatten() 110 + .expect("pds cached") 111 + .as_str(), 112 + "https://pds-cached/" 113 + ); 114 + 115 + let session = CredentialSession::new(store.clone(), resolver.clone()); 116 + // Restore should pick cached PDS and NOT call resolve_did_doc 117 + session 118 + .restore(Did::new_static("did:plc:alice").unwrap(), "session".into()) 119 + .await 120 + .expect("restore ok"); 121 + assert_eq!(session.endpoint().await.as_str(), "https://pds-cached/"); 122 + 123 + // Cleanup 124 + let _ = fs::remove_file(&path); 125 + }
+6 -3
crates/jacquard-api/Cargo.toml
··· 11 11 exclude.workspace = true 12 12 license.workspace = true 13 13 14 + [package.metadata.docs.rs] 15 + features = [ "com_atproto", "app_bsky", "chat_bsky", "tools_ozone" ] 16 + 14 17 [features] 15 18 default = [ "com_atproto"] 16 19 app_bsky = [] ··· 19 22 tools_ozone = [] 20 23 21 24 [dependencies] 22 - bon = "3" 25 + bon.workspace = true 23 26 bytes = { workspace = true, features = ["serde"] } 24 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 25 - jacquard-derive = { version = "0.1.0", path = "../jacquard-derive" } 27 + jacquard-common = { version = "0.3.0", path = "../jacquard-common" } 28 + jacquard-derive = { version = "0.3.0", path = "../jacquard-derive" } 26 29 miette.workspace = true 27 30 serde.workspace = true 28 31 thiserror.workspace = true
+14 -6
crates/jacquard-common/Cargo.toml
··· 13 13 14 14 15 15 [dependencies] 16 - bon = "3" 17 - base64 = "0.22.1" 16 + bon.workspace = true 17 + base64.workspace = true 18 18 bytes.workspace = true 19 - chrono = "0.4.42" 19 + chrono.workspace = true 20 20 cid = { version = "0.11.1", features = ["serde", "std"] } 21 - enum_dispatch = "0.3.13" 22 21 ipld-core = { version = "0.4.2", features = ["serde"] } 23 22 langtag = { version = "0.4.0", features = ["serde"] } 24 23 miette.workspace = true ··· 35 34 smol_str.workspace = true 36 35 thiserror.workspace = true 37 36 url.workspace = true 37 + http.workspace = true 38 + async-trait.workspace = true 39 + tokio = { workspace = true, features = ["sync"] } 40 + reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 41 + serde_ipld_dagcbor.workspace = true 42 + trait-variant.workspace = true 38 43 39 44 [features] 40 45 default = [] ··· 42 47 crypto-ed25519 = ["crypto", "dep:ed25519-dalek"] 43 48 crypto-k256 = ["crypto", "dep:k256"] 44 49 crypto-p256 = ["crypto", "dep:p256"] 50 + reqwest-client = ["dep:reqwest"] 45 51 46 52 [dependencies.ed25519-dalek] 47 53 version = "2" ··· 56 62 features = ["arithmetic"] 57 63 58 64 [dependencies.p256] 59 - version = "0.13" 65 + workspace = true 60 66 optional = true 61 - default-features = false 62 67 features = ["arithmetic"] 68 + 69 + [package.metadata.docs.rs] 70 + features = [ "crypto-k256", "crypto-k256", "crypto-p256"]
+89 -1
crates/jacquard-common/src/cowstr.rs
··· 63 63 pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self { 64 64 unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) } 65 65 } 66 + 67 + /// Returns a reference to the underlying string slice. 68 + #[inline] 69 + pub fn as_str(&self) -> &str { 70 + match self { 71 + CowStr::Borrowed(s) => s, 72 + CowStr::Owned(s) => s.as_str(), 73 + } 74 + } 66 75 } 67 76 68 77 impl AsRef<str> for CowStr<'_> { ··· 105 114 } 106 115 } 107 116 117 + impl Default for CowStr<'_> { 118 + #[inline] 119 + fn default() -> Self { 120 + CowStr::new_static("") 121 + } 122 + } 123 + 108 124 impl From<String> for CowStr<'_> { 109 125 #[inline] 110 126 fn from(s: String) -> Self { ··· 138 154 } 139 155 } 140 156 157 + impl From<CowStr<'_>> for SmolStr { 158 + #[inline] 159 + fn from(s: CowStr<'_>) -> Self { 160 + match s { 161 + CowStr::Borrowed(s) => SmolStr::new(s), 162 + CowStr::Owned(s) => SmolStr::new(s), 163 + } 164 + } 165 + } 166 + 167 + impl From<SmolStr> for CowStr<'_> { 168 + #[inline] 169 + fn from(s: SmolStr) -> Self { 170 + CowStr::Owned(s) 171 + } 172 + } 173 + 141 174 impl From<CowStr<'_>> for Box<str> { 142 175 #[inline] 143 176 fn from(s: CowStr<'_>) -> Self { ··· 250 283 } 251 284 } 252 285 253 - impl<'de: 'a, 'a> Deserialize<'de> for CowStr<'a> { 286 + // impl<'de> Deserialize<'de> for CowStr<'_> { 287 + // #[inline] 288 + // fn deserialize<D>(deserializer: D) -> Result<CowStr<'static>, D::Error> 289 + // where 290 + // D: serde::Deserializer<'de>, 291 + // { 292 + // struct CowStrVisitor; 293 + 294 + // impl<'de> serde::de::Visitor<'de> for CowStrVisitor { 295 + // type Value = CowStr<'static>; 296 + 297 + // #[inline] 298 + // fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 299 + // write!(formatter, "a string") 300 + // } 301 + 302 + // #[inline] 303 + // fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 304 + // where 305 + // E: serde::de::Error, 306 + // { 307 + // Ok(CowStr::copy_from_str(v)) 308 + // } 309 + 310 + // #[inline] 311 + // fn visit_string<E>(self, v: String) -> Result<Self::Value, E> 312 + // where 313 + // E: serde::de::Error, 314 + // { 315 + // Ok(v.into()) 316 + // } 317 + // } 318 + 319 + // deserializer.deserialize_str(CowStrVisitor) 320 + // } 321 + // } 322 + 323 + impl<'de, 'a, 'b> Deserialize<'de> for CowStr<'a> 324 + where 325 + 'de: 'a, 326 + { 254 327 #[inline] 255 328 fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error> 256 329 where ··· 292 365 } 293 366 294 367 deserializer.deserialize_str(CowStrVisitor) 368 + } 369 + } 370 + 371 + /// Convert to a CowStr. 372 + pub trait ToCowStr { 373 + /// Convert to a CowStr. 374 + fn to_cowstr(&self) -> CowStr<'_>; 375 + } 376 + 377 + impl<T> ToCowStr for T 378 + where 379 + T: fmt::Display + ?Sized, 380 + { 381 + fn to_cowstr(&self) -> CowStr<'_> { 382 + CowStr::Owned(smol_str::format_smolstr!("{}", self)) 295 383 } 296 384 } 297 385
+158
crates/jacquard-common/src/error.rs
··· 1 + //! Error types for XRPC client operations 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 { 10 + /// HTTP transport error 11 + #[error("HTTP transport error: {0}")] 12 + Transport( 13 + #[from] 14 + #[diagnostic_source] 15 + TransportError, 16 + ), 17 + 18 + /// Request serialization failed 19 + #[error("{0}")] 20 + Encode( 21 + #[from] 22 + #[diagnostic_source] 23 + EncodeError, 24 + ), 25 + 26 + /// Response deserialization failed 27 + #[error("{0}")] 28 + Decode( 29 + #[from] 30 + #[diagnostic_source] 31 + DecodeError, 32 + ), 33 + 34 + /// HTTP error response 35 + #[error("HTTP {0}")] 36 + Http( 37 + #[from] 38 + #[diagnostic_source] 39 + HttpError, 40 + ), 41 + 42 + /// Authentication error 43 + #[error("Authentication error: {0}")] 44 + Auth( 45 + #[from] 46 + #[diagnostic_source] 47 + AuthError, 48 + ), 49 + } 50 + 51 + /// Transport-level errors that occur during HTTP communication 52 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 53 + pub enum TransportError { 54 + /// Failed to establish connection to server 55 + #[error("Connection error: {0}")] 56 + Connect(String), 57 + 58 + /// Request timed out 59 + #[error("Request timeout")] 60 + Timeout, 61 + 62 + /// Request construction failed (malformed URI, headers, etc.) 63 + #[error("Invalid request: {0}")] 64 + InvalidRequest(String), 65 + 66 + /// Other transport error 67 + #[error("Transport error: {0}")] 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 { 74 + /// JSON deserialization failed 75 + #[error("Failed to deserialize JSON: {0}")] 76 + Json( 77 + #[from] 78 + #[source] 79 + serde_json::Error, 80 + ), 81 + /// CBOR deserialization failed (local I/O) 82 + #[error("Failed to deserialize CBOR: {0}")] 83 + CborLocal( 84 + #[from] 85 + #[source] 86 + serde_ipld_dagcbor::DecodeError<std::io::Error>, 87 + ), 88 + /// CBOR deserialization failed (remote/reqwest) 89 + #[error("Failed to deserialize CBOR: {0}")] 90 + CborRemote( 91 + #[from] 92 + #[source] 93 + serde_ipld_dagcbor::DecodeError<HttpError>, 94 + ), 95 + } 96 + 97 + /// HTTP error response (non-200 status codes outside of XRPC error handling) 98 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 99 + pub struct HttpError { 100 + /// HTTP status code 101 + pub status: http::StatusCode, 102 + /// Response body if available 103 + pub body: Option<Bytes>, 104 + } 105 + 106 + impl std::fmt::Display for HttpError { 107 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 108 + write!(f, "HTTP {}", self.status)?; 109 + if let Some(body) = &self.body { 110 + if let Ok(s) = std::str::from_utf8(body) { 111 + write!(f, ":\n{}", s)?; 112 + } 113 + } 114 + Ok(()) 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 { 139 + /// Access token has expired (use refresh token to get a new one) 140 + #[error("Access token expired")] 141 + TokenExpired, 142 + 143 + /// Access token is invalid or malformed 144 + #[error("Invalid access token")] 145 + InvalidToken, 146 + 147 + /// Token refresh request failed 148 + #[error("Token refresh failed")] 149 + RefreshFailed, 150 + 151 + /// Request requires authentication but none was provided 152 + #[error("No authentication provided")] 153 + NotAuthenticated, 154 + 155 + /// Other authentication error 156 + #[error("Authentication error: {0:?}")] 157 + Other(http::HeaderValue), 158 + }
+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 + }
+25 -1
crates/jacquard-common/src/lib.rs
··· 6 6 pub use smol_str; 7 7 pub use url; 8 8 9 - /// A copy-on-write immutable string type that uses [`SmolStr`] for 9 + /// A copy-on-write immutable string type that uses [`smol_str::SmolStr`] for 10 10 /// the "owned" variant. 11 11 #[macro_use] 12 12 pub mod cowstr; 13 13 #[macro_use] 14 14 /// Trait for taking ownership of most borrowed types in jacquard. 15 15 pub mod into_static; 16 + pub mod error; 17 + /// HTTP client abstraction used by jacquard crates. 18 + pub mod http_client; 16 19 pub mod macros; 20 + /// Generic session storage traits and utilities. 21 + pub mod session; 17 22 /// Baseline fundamental AT Protocol data types. 18 23 pub mod types; 24 + 25 + /// Authorization token types for XRPC requests. 26 + #[derive(Debug, Clone)] 27 + pub enum AuthorizationToken<'s> { 28 + /// Bearer token (access JWT, refresh JWT to refresh the session) 29 + Bearer(CowStr<'s>), 30 + /// DPoP token (proof-of-possession) for OAuth 31 + Dpop(CowStr<'s>), 32 + } 33 + 34 + impl<'s> IntoStatic for AuthorizationToken<'s> { 35 + type Output = AuthorizationToken<'static>; 36 + fn into_static(self) -> AuthorizationToken<'static> { 37 + match self { 38 + AuthorizationToken::Bearer(token) => AuthorizationToken::Bearer(token.into_static()), 39 + AuthorizationToken::Dpop(token) => AuthorizationToken::Dpop(token.into_static()), 40 + } 41 + } 42 + }
+145
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 serde::Serialize; 6 + use serde::de::DeserializeOwned; 7 + use serde_json::Value; 8 + use std::collections::HashMap; 9 + use std::error::Error as StdError; 10 + use std::fmt::Display; 11 + use std::hash::Hash; 12 + use std::path::{Path, PathBuf}; 13 + use std::sync::Arc; 14 + use tokio::sync::RwLock; 15 + 16 + /// Errors emitted by session stores. 17 + #[derive(Debug, thiserror::Error, Diagnostic)] 18 + pub enum SessionStoreError { 19 + /// Filesystem or I/O error 20 + #[error("I/O error: {0}")] 21 + #[diagnostic(code(jacquard::session_store::io))] 22 + Io(#[from] std::io::Error), 23 + /// Serialization error (e.g., JSON) 24 + #[error("serialization error: {0}")] 25 + #[diagnostic(code(jacquard::session_store::serde))] 26 + Serde(#[from] serde_json::Error), 27 + /// Any other error from a backend implementation 28 + #[error(transparent)] 29 + #[diagnostic(code(jacquard::session_store::other))] 30 + Other(#[from] Box<dyn StdError + Send + Sync>), 31 + } 32 + 33 + /// Pluggable storage for arbitrary session records. 34 + #[async_trait] 35 + pub trait SessionStore<K, T>: Send + Sync 36 + where 37 + K: Eq + Hash, 38 + T: Clone, 39 + { 40 + /// Get the current session if present. 41 + async fn get(&self, key: &K) -> Option<T>; 42 + /// Persist the given session. 43 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; 44 + /// Delete the given session. 45 + async fn del(&self, key: &K) -> Result<(), SessionStoreError>; 46 + } 47 + 48 + /// In-memory session store suitable for short-lived sessions and tests. 49 + #[derive(Clone)] 50 + pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>); 51 + 52 + impl<K, T> Default for MemorySessionStore<K, T> { 53 + fn default() -> Self { 54 + Self(Arc::new(RwLock::new(HashMap::new()))) 55 + } 56 + } 57 + 58 + #[async_trait] 59 + impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T> 60 + where 61 + K: Eq + Hash + Send + Sync, 62 + T: Clone + Send + Sync + 'static, 63 + { 64 + async fn get(&self, key: &K) -> Option<T> { 65 + self.0.read().await.get(key).cloned() 66 + } 67 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { 68 + self.0.write().await.insert(key, session); 69 + Ok(()) 70 + } 71 + async fn del(&self, key: &K) -> Result<(), SessionStoreError> { 72 + self.0.write().await.remove(key); 73 + Ok(()) 74 + } 75 + } 76 + 77 + /// File-backed token store using a JSON file. 78 + /// 79 + /// NOT secure, only suitable for development. 80 + /// 81 + /// Example 82 + /// ```ignore 83 + /// use jacquard::client::{AtClient, FileTokenStore}; 84 + /// let base = url::Url::parse("https://bsky.social").unwrap(); 85 + /// let store = FileTokenStore::new("/tmp/jacquard-session.json"); 86 + /// let client = AtClient::new(reqwest::Client::new(), base, store); 87 + /// ``` 88 + #[derive(Clone, Debug)] 89 + pub struct FileTokenStore { 90 + /// Path to the JSON file. 91 + pub path: PathBuf, 92 + } 93 + 94 + impl FileTokenStore { 95 + /// Create a new file token store at the given path. 96 + pub fn new(path: impl AsRef<Path>) -> Self { 97 + Self { 98 + path: path.as_ref().to_path_buf(), 99 + } 100 + } 101 + } 102 + 103 + #[async_trait::async_trait] 104 + impl< 105 + K: Eq + Hash + Display + Send + Sync + 'static, 106 + T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static, 107 + > SessionStore<K, T> for FileTokenStore 108 + { 109 + /// Get the current session if present. 110 + async fn get(&self, key: &K) -> Option<T> { 111 + let file = std::fs::read_to_string(&self.path).ok()?; 112 + let store: Value = serde_json::from_str(&file).ok()?; 113 + 114 + let session = store.get(key.to_string())?; 115 + serde_json::from_value(session.clone()).ok() 116 + } 117 + /// Persist the given session. 118 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { 119 + let file = std::fs::read_to_string(&self.path)?; 120 + let mut store: Value = serde_json::from_str(&file)?; 121 + let key_string = key.to_string(); 122 + if let Some(store) = store.as_object_mut() { 123 + store.insert(key_string, serde_json::to_value(session.clone())?); 124 + 125 + std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 126 + Ok(()) 127 + } else { 128 + Err(SessionStoreError::Other("invalid store".into())) 129 + } 130 + } 131 + /// Delete the given session. 132 + async fn del(&self, key: &K) -> Result<(), SessionStoreError> { 133 + let file = std::fs::read_to_string(&self.path)?; 134 + let mut store: Value = serde_json::from_str(&file)?; 135 + let key_string = key.to_string(); 136 + if let Some(store) = store.as_object_mut() { 137 + store.remove(&key_string); 138 + 139 + std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 140 + Ok(()) 141 + } else { 142 + Err(SessionStoreError::Other("invalid store".into())) 143 + } 144 + } 145 + }
-2
crates/jacquard-common/src/types/crypto.rs
··· 22 22 23 23 /// Known multicodec key codecs for Multikey public keys 24 24 /// 25 - 26 - /// ``` 27 25 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 28 26 pub enum KeyCodec { 29 27 /// Ed25519
-6
crates/jacquard-common/src/types/datetime.rs
··· 203 203 } 204 204 } 205 205 206 - impl AsRef<str> for Datetime { 207 - fn as_ref(&self) -> &str { 208 - self.as_str() 209 - } 210 - } 211 - 212 206 impl fmt::Display for Datetime { 213 207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 214 208 f.write_str(self.as_str())
+1 -1
crates/jacquard-common/src/types/did.rs
··· 21 21 /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. 22 22 /// 23 23 /// See: <https://atproto.com/specs/did> 24 - #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 24 + #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 25 25 #[serde(transparent)] 26 26 #[repr(transparent)] 27 27 pub struct Did<'d>(CowStr<'d>);
+2 -2
crates/jacquard-common/src/types/did_doc.rs
··· 35 35 #[serde(borrow)] 36 36 pub id: Did<'a>, 37 37 38 - /// Alternate identifiers for the subject, such as at://<handle> 38 + /// Alternate identifiers for the subject, such as at://\<handle\> 39 39 #[serde(borrow)] 40 40 pub also_known_as: Option<Vec<CowStr<'a>>>, 41 41 ··· 66 66 } 67 67 68 68 impl<'a> DidDocument<'a> { 69 - /// Extract validated handles from `alsoKnownAs` entries like `at://<handle>`. 69 + /// Extract validated handles from `alsoKnownAs` entries like `at://\<handle\>`. 70 70 pub fn handles(&self) -> Vec<Handle<'static>> { 71 71 self.also_known_as 72 72 .as_ref()
+1 -1
crates/jacquard-common/src/types/nsid.rs
··· 24 24 /// - Case-sensitive 25 25 /// 26 26 /// See: <https://atproto.com/specs/nsid> 27 - #[derive(Clone, PartialEq, Eq, Serialize, Hash)] 27 + #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 28 28 #[serde(transparent)] 29 29 #[repr(transparent)] 30 30 pub struct Nsid<'n>(CowStr<'n>);
+2 -2
crates/jacquard-common/src/types/string.rs
··· 251 251 /// detailing the specification for the type 252 252 /// `source` is the source string, or part of it 253 253 /// `kind` is the type of parsing error: `[StrParseKind]` 254 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 254 + #[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] 255 255 #[error("error in `{source}`: {kind}")] 256 256 #[diagnostic( 257 257 url("https://atproto.com/specs/{spec}"), ··· 406 406 } 407 407 408 408 /// Kinds of parsing errors for AT Protocol string types 409 - #[derive(Debug, thiserror::Error, miette::Diagnostic)] 409 + #[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] 410 410 pub enum StrParseKind { 411 411 /// Regex pattern validation failed 412 412 #[error("regex failure - {message}")]
+640 -1
crates/jacquard-common/src/types/xrpc.rs
··· 1 + //! Stateless XRPC utilities and request/response mapping 2 + //! 3 + //! Mapping overview: 4 + //! - Success (2xx): parse body into the endpoint's typed output. 5 + //! - 400: try typed error; on failure, fall back to a generic XRPC error (with 6 + //! `nsid`, `method`, and `http_status`) and map common auth errors. 7 + //! - 401: if `WWW-Authenticate` is present, return 8 + //! `ClientError::Auth(AuthError::Other(header))` so higher layers (OAuth/DPoP) 9 + //! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry. 10 + //! If the header is absent, parse the body and map auth errors to 11 + //! `AuthError::TokenExpired`/`InvalidToken`. 12 + //! 13 + use bytes::Bytes; 14 + use http::{ 15 + HeaderName, HeaderValue, Request, StatusCode, 16 + header::{AUTHORIZATION, CONTENT_TYPE}, 17 + }; 1 18 use serde::{Deserialize, Serialize}; 2 - use std::error::Error; 19 + use smol_str::SmolStr; 3 20 use std::fmt::{self, Debug}; 21 + use std::{error::Error, marker::PhantomData}; 22 + use url::Url; 4 23 5 24 use crate::IntoStatic; 25 + use crate::error::TransportError; 26 + use crate::http_client::HttpClient; 6 27 use crate::types::value::Data; 28 + use crate::{AuthorizationToken, error::AuthError}; 29 + use crate::{CowStr, error::XrpcResult}; 7 30 8 31 /// Error type for encoding XRPC requests 9 32 #[derive(Debug, thiserror::Error, miette::Diagnostic)] ··· 103 126 GenericError(self.0.into_static()) 104 127 } 105 128 } 129 + 130 + /// Per-request options for XRPC calls. 131 + #[derive(Debug, Default, Clone)] 132 + pub struct CallOptions<'a> { 133 + /// Optional Authorization to apply (`Bearer` or `DPoP`). 134 + pub auth: Option<AuthorizationToken<'a>>, 135 + /// `atproto-proxy` header value. 136 + pub atproto_proxy: Option<CowStr<'a>>, 137 + /// `atproto-accept-labelers` header values. 138 + pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>, 139 + /// Extra headers to attach to this request. 140 + pub extra_headers: Vec<(HeaderName, HeaderValue)>, 141 + } 142 + 143 + impl IntoStatic for CallOptions<'_> { 144 + type Output = CallOptions<'static>; 145 + 146 + fn into_static(self) -> Self::Output { 147 + CallOptions { 148 + auth: self.auth.map(|auth| auth.into_static()), 149 + atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()), 150 + atproto_accept_labelers: self 151 + .atproto_accept_labelers 152 + .map(|labelers| labelers.into_static()), 153 + extra_headers: self.extra_headers, 154 + } 155 + } 156 + } 157 + 158 + /// Extension for stateless XRPC calls on any `HttpClient`. 159 + /// 160 + /// Example 161 + /// ```ignore 162 + /// use jacquard::client::XrpcExt; 163 + /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 164 + /// use jacquard::types::ident::AtIdentifier; 165 + /// use miette::IntoDiagnostic; 166 + /// 167 + /// #[tokio::main] 168 + /// async fn main() -> miette::Result<()> { 169 + /// let http = reqwest::Client::new(); 170 + /// let base = url::Url::parse("https://public.api.bsky.app")?; 171 + /// let resp = http 172 + /// .xrpc(base) 173 + /// .send( 174 + /// GetAuthorFeed::new() 175 + /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 176 + /// .limit(5) 177 + /// .build(), 178 + /// ) 179 + /// .await?; 180 + /// let out = resp.into_output()?; 181 + /// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 182 + /// Ok(()) 183 + /// } 184 + /// ``` 185 + pub trait XrpcExt: HttpClient { 186 + /// Start building an XRPC call for the given base URL. 187 + fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> 188 + where 189 + Self: Sized, 190 + { 191 + XrpcCall { 192 + client: self, 193 + base, 194 + opts: CallOptions::default(), 195 + } 196 + } 197 + } 198 + 199 + impl<T: HttpClient> XrpcExt for T {} 200 + 201 + /// Stateless XRPC call builder. 202 + /// 203 + /// Example (per-request overrides) 204 + /// ```ignore 205 + /// use jacquard::client::{XrpcExt, AuthorizationToken}; 206 + /// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 207 + /// use jacquard::types::ident::AtIdentifier; 208 + /// use jacquard::CowStr; 209 + /// use miette::IntoDiagnostic; 210 + /// 211 + /// #[tokio::main] 212 + /// async fn main() -> miette::Result<()> { 213 + /// let http = reqwest::Client::new(); 214 + /// let base = url::Url::parse("https://public.api.bsky.app")?; 215 + /// let resp = http 216 + /// .xrpc(base) 217 + /// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 218 + /// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 219 + /// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 220 + /// .send( 221 + /// GetAuthorFeed::new() 222 + /// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) 223 + /// .limit(5) 224 + /// .build(), 225 + /// ) 226 + /// .await?; 227 + /// let out = resp.into_output()?; 228 + /// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); 229 + /// Ok(()) 230 + /// } 231 + /// ``` 232 + pub struct XrpcCall<'a, C: HttpClient> { 233 + pub(crate) client: &'a C, 234 + pub(crate) base: Url, 235 + pub(crate) opts: CallOptions<'a>, 236 + } 237 + 238 + impl<'a, C: HttpClient> XrpcCall<'a, C> { 239 + /// Apply Authorization to this call. 240 + pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { 241 + self.opts.auth = Some(token); 242 + self 243 + } 244 + /// Set `atproto-proxy` header for this call. 245 + pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { 246 + self.opts.atproto_proxy = Some(proxy); 247 + self 248 + } 249 + /// Set `atproto-accept-labelers` header(s) for this call. 250 + pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self { 251 + self.opts.atproto_accept_labelers = Some(labelers); 252 + self 253 + } 254 + /// Add an extra header. 255 + pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { 256 + self.opts.extra_headers.push((name, value)); 257 + self 258 + } 259 + /// Replace the builder's options entirely. 260 + pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { 261 + self.opts = opts; 262 + self 263 + } 264 + 265 + /// Send the given typed XRPC request and return a response wrapper. 266 + /// 267 + /// Note on 401 handling: 268 + /// - When the server returns 401 with a `WWW-Authenticate` header, this surfaces as 269 + /// `ClientError::Auth(AuthError::Other(header))` so higher layers (e.g., OAuth/DPoP) can 270 + /// inspect the header for `error="invalid_token"` or `error="use_dpop_nonce"` and react 271 + /// (refresh/retry). If the header is absent, the 401 body flows through to `Response` and 272 + /// can be parsed/mapped to `AuthError` as appropriate. 273 + pub async fn send<R: XrpcRequest + Send>(self, request: &R) -> XrpcResult<Response<R>> { 274 + let http_request = build_http_request(&self.base, request, &self.opts) 275 + .map_err(crate::error::TransportError::from)?; 276 + 277 + let http_response = self 278 + .client 279 + .send_http(http_request) 280 + .await 281 + .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 282 + 283 + let status = http_response.status(); 284 + // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 285 + // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 286 + if status.as_u16() == 401 { 287 + if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 288 + return Err(crate::error::ClientError::Auth( 289 + crate::error::AuthError::Other(hv.clone()), 290 + )); 291 + } 292 + } 293 + let buffer = Bytes::from(http_response.into_body()); 294 + 295 + if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 296 + return Err(crate::error::HttpError { 297 + status, 298 + body: Some(buffer), 299 + } 300 + .into()); 301 + } 302 + 303 + Ok(Response::new(buffer, status)) 304 + } 305 + } 306 + 307 + /// HTTP headers commonly used in XRPC requests 308 + pub enum Header { 309 + /// Content-Type header 310 + ContentType, 311 + /// Authorization header 312 + Authorization, 313 + /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. 314 + /// 315 + /// See: <https://atproto.com/specs/xrpc#service-proxying> 316 + AtprotoProxy, 317 + /// `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. 318 + AtprotoAcceptLabelers, 319 + } 320 + 321 + impl From<Header> for HeaderName { 322 + fn from(value: Header) -> Self { 323 + match value { 324 + Header::ContentType => CONTENT_TYPE, 325 + Header::Authorization => AUTHORIZATION, 326 + Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), 327 + Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), 328 + } 329 + } 330 + } 331 + 332 + /// Build an HTTP request for an XRPC call given base URL and options 333 + pub fn build_http_request<R: XrpcRequest>( 334 + base: &Url, 335 + req: &R, 336 + opts: &CallOptions<'_>, 337 + ) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> { 338 + let mut url = base.clone(); 339 + let mut path = url.path().trim_end_matches('/').to_owned(); 340 + path.push_str("/xrpc/"); 341 + path.push_str(R::NSID); 342 + url.set_path(&path); 343 + 344 + if let XrpcMethod::Query = R::METHOD { 345 + let qs = serde_html_form::to_string(&req) 346 + .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; 347 + if !qs.is_empty() { 348 + url.set_query(Some(&qs)); 349 + } else { 350 + url.set_query(None); 351 + } 352 + } 353 + 354 + let method = match R::METHOD { 355 + XrpcMethod::Query => http::Method::GET, 356 + XrpcMethod::Procedure(_) => http::Method::POST, 357 + }; 358 + 359 + let mut builder = Request::builder().method(method).uri(url.as_str()); 360 + 361 + if let XrpcMethod::Procedure(encoding) = R::METHOD { 362 + builder = builder.header(Header::ContentType, encoding); 363 + } 364 + builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING); 365 + 366 + if let Some(token) = &opts.auth { 367 + let hv = match token { 368 + AuthorizationToken::Bearer(t) => { 369 + HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 370 + } 371 + AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), 372 + } 373 + .map_err(|e| { 374 + TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) 375 + })?; 376 + builder = builder.header(Header::Authorization, hv); 377 + } 378 + 379 + if let Some(proxy) = &opts.atproto_proxy { 380 + builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); 381 + } 382 + if let Some(labelers) = &opts.atproto_accept_labelers { 383 + if !labelers.is_empty() { 384 + let joined = labelers 385 + .iter() 386 + .map(|s| s.as_ref()) 387 + .collect::<Vec<_>>() 388 + .join(", "); 389 + builder = builder.header(Header::AtprotoAcceptLabelers, joined); 390 + } 391 + } 392 + for (name, value) in &opts.extra_headers { 393 + builder = builder.header(name, value); 394 + } 395 + 396 + let body = if let XrpcMethod::Procedure(_) = R::METHOD { 397 + req.encode_body() 398 + .map_err(|e| TransportError::InvalidRequest(e.to_string()))? 399 + } else { 400 + vec![] 401 + }; 402 + 403 + builder 404 + .body(body) 405 + .map_err(|e| TransportError::InvalidRequest(e.to_string())) 406 + } 407 + 408 + /// XRPC response wrapper that owns the response buffer 409 + /// 410 + /// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. 411 + /// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). 412 + pub struct Response<R: XrpcRequest> { 413 + buffer: Bytes, 414 + status: StatusCode, 415 + _marker: PhantomData<R>, 416 + } 417 + 418 + impl<R: XrpcRequest> Response<R> { 419 + /// Create a new response from a buffer and status code 420 + pub fn new(buffer: Bytes, status: StatusCode) -> Self { 421 + Self { 422 + buffer, 423 + status, 424 + _marker: PhantomData, 425 + } 426 + } 427 + 428 + /// Get the HTTP status code 429 + pub fn status(&self) -> StatusCode { 430 + self.status 431 + } 432 + 433 + /// Parse the response, borrowing from the internal buffer 434 + pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> { 435 + // Use a helper to make lifetime inference work 436 + fn parse_output<'b, R: XrpcRequest>( 437 + buffer: &'b [u8], 438 + ) -> Result<R::Output<'b>, serde_json::Error> { 439 + serde_json::from_slice(buffer) 440 + } 441 + 442 + fn parse_error<'b, R: XrpcRequest>( 443 + buffer: &'b [u8], 444 + ) -> Result<R::Err<'b>, serde_json::Error> { 445 + serde_json::from_slice(buffer) 446 + } 447 + 448 + // 200: parse as output 449 + if self.status.is_success() { 450 + match parse_output::<R>(&self.buffer) { 451 + Ok(output) => Ok(output), 452 + Err(e) => Err(XrpcError::Decode(e)), 453 + } 454 + // 400: try typed XRPC error, fallback to generic error 455 + } else if self.status.as_u16() == 400 { 456 + match parse_error::<R>(&self.buffer) { 457 + Ok(error) => Err(XrpcError::Xrpc(error)), 458 + Err(_) => { 459 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 460 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 461 + Ok(mut generic) => { 462 + generic.nsid = R::NSID; 463 + generic.method = R::METHOD.as_str(); 464 + generic.http_status = self.status; 465 + // Map auth-related errors to AuthError 466 + match generic.error.as_str() { 467 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 468 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 469 + _ => Err(XrpcError::Generic(generic)), 470 + } 471 + } 472 + Err(e) => Err(XrpcError::Decode(e)), 473 + } 474 + } 475 + } 476 + // 401: always auth error 477 + } else { 478 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 479 + Ok(mut generic) => { 480 + generic.nsid = R::NSID; 481 + generic.method = R::METHOD.as_str(); 482 + generic.http_status = self.status; 483 + match generic.error.as_str() { 484 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 485 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 486 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 487 + } 488 + } 489 + Err(e) => Err(XrpcError::Decode(e)), 490 + } 491 + } 492 + } 493 + 494 + /// Parse the response into an owned output 495 + pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>> 496 + where 497 + for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>, 498 + for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>, 499 + { 500 + // Use a helper to make lifetime inference work 501 + fn parse_output<'b, R: XrpcRequest>( 502 + buffer: &'b [u8], 503 + ) -> Result<R::Output<'b>, serde_json::Error> { 504 + serde_json::from_slice(buffer) 505 + } 506 + 507 + fn parse_error<'b, R: XrpcRequest>( 508 + buffer: &'b [u8], 509 + ) -> Result<R::Err<'b>, serde_json::Error> { 510 + serde_json::from_slice(buffer) 511 + } 512 + 513 + // 200: parse as output 514 + if self.status.is_success() { 515 + match parse_output::<R>(&self.buffer) { 516 + Ok(output) => Ok(output.into_static()), 517 + Err(e) => Err(XrpcError::Decode(e)), 518 + } 519 + // 400: try typed XRPC error, fallback to generic error 520 + } else if self.status.as_u16() == 400 { 521 + match parse_error::<R>(&self.buffer) { 522 + Ok(error) => Err(XrpcError::Xrpc(error.into_static())), 523 + Err(_) => { 524 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 525 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 526 + Ok(mut generic) => { 527 + generic.nsid = R::NSID; 528 + generic.method = R::METHOD.as_str(); 529 + generic.http_status = self.status; 530 + // Map auth-related errors to AuthError 531 + match generic.error.as_ref() { 532 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 533 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 534 + _ => Err(XrpcError::Generic(generic)), 535 + } 536 + } 537 + Err(e) => Err(XrpcError::Decode(e)), 538 + } 539 + } 540 + } 541 + // 401: always auth error 542 + } else { 543 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 544 + Ok(mut generic) => { 545 + let status = self.status; 546 + generic.nsid = R::NSID; 547 + generic.method = R::METHOD.as_str(); 548 + generic.http_status = status; 549 + match generic.error.as_ref() { 550 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 551 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 552 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 553 + } 554 + } 555 + Err(e) => Err(XrpcError::Decode(e)), 556 + } 557 + } 558 + } 559 + 560 + /// Get the raw buffer 561 + pub fn buffer(&self) -> &Bytes { 562 + &self.buffer 563 + } 564 + } 565 + 566 + /// Generic XRPC error format for untyped errors like InvalidRequest 567 + /// 568 + /// Used when the error doesn't match the endpoint's specific error enum 569 + #[derive(Debug, Clone, Deserialize)] 570 + pub struct GenericXrpcError { 571 + /// Error code (e.g., "InvalidRequest") 572 + pub error: SmolStr, 573 + /// Optional error message with details 574 + pub message: Option<SmolStr>, 575 + /// XRPC method NSID that produced this error (context only; not serialized) 576 + #[serde(skip)] 577 + pub nsid: &'static str, 578 + /// HTTP method used (GET/POST) (context only; not serialized) 579 + #[serde(skip)] 580 + pub method: &'static str, 581 + /// HTTP status code (context only; not serialized) 582 + #[serde(skip)] 583 + pub http_status: StatusCode, 584 + } 585 + 586 + impl std::fmt::Display for GenericXrpcError { 587 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 588 + if let Some(msg) = &self.message { 589 + write!( 590 + f, 591 + "{}: {} (nsid={}, method={}, status={})", 592 + self.error, msg, self.nsid, self.method, self.http_status 593 + ) 594 + } else { 595 + write!( 596 + f, 597 + "{} (nsid={}, method={}, status={})", 598 + self.error, self.nsid, self.method, self.http_status 599 + ) 600 + } 601 + } 602 + } 603 + 604 + impl std::error::Error for GenericXrpcError {} 605 + 606 + /// XRPC-specific errors returned from endpoints 607 + /// 608 + /// Represents errors returned in the response body 609 + /// Type parameter `E` is the endpoint's specific error enum type. 610 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 611 + pub enum XrpcError<E: std::error::Error + IntoStatic> { 612 + /// Typed XRPC error from the endpoint's specific error enum 613 + #[error("XRPC error: {0}")] 614 + #[diagnostic(code(jacquard_common::xrpc::typed))] 615 + Xrpc(E), 616 + 617 + /// Authentication error (ExpiredToken, InvalidToken, etc.) 618 + #[error("Authentication error: {0}")] 619 + #[diagnostic(code(jacquard_common::xrpc::auth))] 620 + Auth(#[from] AuthError), 621 + 622 + /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) 623 + #[error("XRPC error: {0}")] 624 + #[diagnostic(code(jacquard_common::xrpc::generic))] 625 + Generic(GenericXrpcError), 626 + 627 + /// Failed to decode the response body 628 + #[error("Failed to decode response: {0}")] 629 + #[diagnostic(code(jacquard_common::xrpc::decode))] 630 + Decode(#[from] serde_json::Error), 631 + } 632 + 633 + #[cfg(test)] 634 + mod tests { 635 + use super::*; 636 + use serde::{Deserialize, Serialize}; 637 + 638 + #[derive(Serialize)] 639 + struct DummyReq; 640 + 641 + #[derive(Deserialize, Debug, thiserror::Error)] 642 + #[error("{0}")] 643 + struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>); 644 + 645 + impl IntoStatic for DummyErr<'_> { 646 + type Output = DummyErr<'static>; 647 + fn into_static(self) -> Self::Output { 648 + DummyErr(self.0.into_static()) 649 + } 650 + } 651 + 652 + impl XrpcRequest for DummyReq { 653 + const NSID: &'static str = "test.dummy"; 654 + const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json"); 655 + const OUTPUT_ENCODING: &'static str = "application/json"; 656 + type Output<'de> = (); 657 + type Err<'de> = DummyErr<'de>; 658 + } 659 + 660 + #[test] 661 + fn generic_error_carries_context() { 662 + let body = serde_json::json!({"error":"InvalidRequest","message":"missing"}); 663 + let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 664 + let resp: Response<DummyReq> = Response::new(buf, StatusCode::BAD_REQUEST); 665 + match resp.parse().unwrap_err() { 666 + XrpcError::Generic(g) => { 667 + assert_eq!(g.error.as_str(), "InvalidRequest"); 668 + assert_eq!(g.message.as_deref(), Some("missing")); 669 + assert_eq!(g.nsid, DummyReq::NSID); 670 + assert_eq!(g.method, DummyReq::METHOD.as_str()); 671 + assert_eq!(g.http_status, StatusCode::BAD_REQUEST); 672 + } 673 + other => panic!("unexpected: {other:?}"), 674 + } 675 + } 676 + 677 + #[test] 678 + fn auth_error_mapping() { 679 + for (code, expect) in [ 680 + ("ExpiredToken", AuthError::TokenExpired), 681 + ("InvalidToken", AuthError::InvalidToken), 682 + ] { 683 + let body = serde_json::json!({"error": code}); 684 + let buf = Bytes::from(serde_json::to_vec(&body).unwrap()); 685 + let resp: Response<DummyReq> = Response::new(buf, StatusCode::UNAUTHORIZED); 686 + match resp.parse().unwrap_err() { 687 + XrpcError::Auth(e) => match (e, expect) { 688 + (AuthError::TokenExpired, AuthError::TokenExpired) => {} 689 + (AuthError::InvalidToken, AuthError::InvalidToken) => {} 690 + other => panic!("mismatch: {other:?}"), 691 + }, 692 + other => panic!("unexpected: {other:?}"), 693 + } 694 + } 695 + } 696 + 697 + #[test] 698 + fn no_double_slash_in_path() { 699 + #[derive(Serialize)] 700 + struct Req; 701 + #[derive(Deserialize, Debug, thiserror::Error)] 702 + #[error("{0}")] 703 + struct Err<'a>(#[serde(borrow)] CowStr<'a>); 704 + impl IntoStatic for Err<'_> { 705 + type Output = Err<'static>; 706 + fn into_static(self) -> Self::Output { Err(self.0.into_static()) } 707 + } 708 + impl XrpcRequest for Req { 709 + const NSID: &'static str = "com.example.test"; 710 + const METHOD: XrpcMethod = XrpcMethod::Query; 711 + const OUTPUT_ENCODING: &'static str = "application/json"; 712 + type Output<'de> = (); 713 + type Err<'de> = Err<'de>; 714 + } 715 + 716 + let opts = CallOptions::default(); 717 + for base in [ 718 + Url::parse("https://pds").unwrap(), 719 + Url::parse("https://pds/").unwrap(), 720 + Url::parse("https://pds/base/").unwrap(), 721 + ] { 722 + let req = build_http_request(&base, &Req, &opts).unwrap(); 723 + let uri = req.uri().to_string(); 724 + assert!(uri.contains("/xrpc/com.example.test")); 725 + assert!(!uri.contains("//xrpc")); 726 + } 727 + } 728 + } 729 + 730 + /// Stateful XRPC call trait 731 + pub trait XrpcClient: HttpClient { 732 + /// Get the base URI for the client. 733 + fn base_uri(&self) -> Url; 734 + 735 + /// Get the call options for the client. 736 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 737 + async { CallOptions::default() } 738 + } 739 + /// Send an XRPC request and parse the response 740 + fn send<R: XrpcRequest + Send>( 741 + self, 742 + request: &R, 743 + ) -> impl Future<Output = XrpcResult<Response<R>>>; 744 + }
+1 -1
crates/jacquard-derive/Cargo.toml
··· 28 28 29 29 30 30 [dev-dependencies] 31 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 31 + jacquard-common = { version = "0.3.0", path = "../jacquard-common" }
+35
crates/jacquard-identity/Cargo.toml
··· 1 + [package] 2 + name = "jacquard-identity" 3 + edition.workspace = true 4 + version.workspace = true 5 + authors.workspace = true 6 + repository.workspace = true 7 + keywords.workspace = true 8 + categories.workspace = true 9 + readme.workspace = true 10 + exclude.workspace = true 11 + homepage.workspace = true 12 + license.workspace = true 13 + description.workspace = true 14 + 15 + [features] 16 + dns = ["dep:hickory-resolver"] 17 + 18 + [dependencies] 19 + async-trait.workspace = true 20 + bon.workspace = true 21 + bytes.workspace = true 22 + jacquard-common = { version = "0.3", path = "../jacquard-common" } 23 + percent-encoding.workspace = true 24 + reqwest.workspace = true 25 + url.workspace = true 26 + tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } 27 + hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]} 28 + serde.workspace = true 29 + serde_json.workspace = true 30 + thiserror.workspace = true 31 + miette.workspace = true 32 + http.workspace = true 33 + jacquard-api = { version = "0.3.0", path = "../jacquard-api" } 34 + serde_html_form.workspace = true 35 + urlencoding.workspace = true
+630
crates/jacquard-identity/src/lib.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 + //! Identity resolution: handle โ†’ DID and DID โ†’ document, with smart fallbacks. 4 + //! 5 + //! Fallback order (default): 6 + //! - Handle โ†’ DID: DNS TXT (if `dns` feature) โ†’ HTTPS well-known โ†’ PDS XRPC 7 + //! `resolveHandle` (when `pds_fallback` is configured) โ†’ public API fallback โ†’ Slingshot `resolveHandle` (if configured). 8 + //! - DID โ†’ Doc: did:web well-known โ†’ PLC/Slingshot HTTP โ†’ PDS XRPC `resolveDid` (when configured), 9 + //! then Slingshot miniโ€‘doc (partial) if configured. 10 + //! 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 + pub mod resolver; 16 + 17 + use crate::resolver::{ 18 + DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource, 19 + ResolverOptions, 20 + }; 21 + use bytes::Bytes; 22 + use jacquard_api::com_atproto::identity::resolve_did; 23 + use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; 24 + use jacquard_common::error::TransportError; 25 + use jacquard_common::http_client::HttpClient; 26 + use jacquard_common::types::did::Did; 27 + use jacquard_common::types::did_doc::DidDocument; 28 + use jacquard_common::types::ident::AtIdentifier; 29 + use jacquard_common::types::xrpc::XrpcExt; 30 + use jacquard_common::{IntoStatic, types::string::Handle}; 31 + use percent_encoding::percent_decode_str; 32 + use reqwest::StatusCode; 33 + use url::{ParseError, Url}; 34 + 35 + #[cfg(feature = "dns")] 36 + use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 37 + 38 + /// Default resolver implementation with configurable fallback order. 39 + pub struct JacquardResolver { 40 + http: reqwest::Client, 41 + opts: ResolverOptions, 42 + #[cfg(feature = "dns")] 43 + dns: Option<TokioAsyncResolver>, 44 + } 45 + 46 + impl JacquardResolver { 47 + /// Create a new instance of the default resolver with all options (except DNS) up front 48 + pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self { 49 + Self { 50 + http, 51 + opts, 52 + #[cfg(feature = "dns")] 53 + dns: None, 54 + } 55 + } 56 + 57 + #[cfg(feature = "dns")] 58 + /// Create a new instance of the default resolver with all options, plus default DNS, up front 59 + pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self { 60 + Self { 61 + http, 62 + opts, 63 + dns: Some(TokioAsyncResolver::tokio( 64 + ResolverConfig::default(), 65 + Default::default(), 66 + )), 67 + } 68 + } 69 + 70 + #[cfg(feature = "dns")] 71 + /// Add default DNS resolution to the resolver 72 + pub fn with_system_dns(mut self) -> Self { 73 + self.dns = Some(TokioAsyncResolver::tokio( 74 + ResolverConfig::default(), 75 + Default::default(), 76 + )); 77 + self 78 + } 79 + 80 + /// Set PLC source (PLC directory or Slingshot) 81 + pub fn with_plc_source(mut self, source: PlcSource) -> Self { 82 + self.opts.plc_source = source; 83 + self 84 + } 85 + 86 + /// Enable/disable public unauthenticated fallback for resolveHandle 87 + pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self { 88 + self.opts.public_fallback_for_handle = enable; 89 + self 90 + } 91 + 92 + /// Enable/disable doc id validation 93 + pub fn with_validate_doc_id(mut self, enable: bool) -> Self { 94 + self.opts.validate_doc_id = enable; 95 + self 96 + } 97 + 98 + /// Construct the well-known HTTPS URL for a `did:web` DID. 99 + /// 100 + /// - `did:web:example.com` โ†’ `https://example.com/.well-known/did.json` 101 + /// - `did:web:example.com:user:alice` โ†’ `https://example.com/user/alice/did.json` 102 + fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 103 + // did:web:example.com[:path:segments] 104 + let s = did.as_str(); 105 + let rest = s 106 + .strip_prefix("did:web:") 107 + .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 108 + let mut parts = rest.split(':'); 109 + let host = parts 110 + .next() 111 + .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 112 + let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; 113 + let path: Vec<&str> = parts.collect(); 114 + if path.is_empty() { 115 + url.set_path(".well-known/did.json"); 116 + } else { 117 + // Append path segments and did.json 118 + let mut segments = url 119 + .path_segments_mut() 120 + .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; 121 + for seg in path { 122 + // Minimally percent-decode each segment per spec guidance 123 + let decoded = percent_decode_str(seg).decode_utf8_lossy(); 124 + segments.push(&decoded); 125 + } 126 + segments.push("did.json"); 127 + // drop segments 128 + } 129 + Ok(url) 130 + } 131 + 132 + #[cfg(test)] 133 + fn test_did_web_url_raw(&self, s: &str) -> String { 134 + let did = Did::new(s).unwrap(); 135 + self.did_web_url(&did).unwrap().to_string() 136 + } 137 + 138 + async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 139 + let resp = self 140 + .http 141 + .get(url) 142 + .send() 143 + .await 144 + .map_err(TransportError::from)?; 145 + let status = resp.status(); 146 + let buf = resp.bytes().await.map_err(TransportError::from)?; 147 + Ok((buf, status)) 148 + } 149 + 150 + async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 151 + let resp = self 152 + .http 153 + .get(url) 154 + .send() 155 + .await 156 + .map_err(TransportError::from)?; 157 + if resp.status() == StatusCode::OK { 158 + Ok(resp.text().await.map_err(TransportError::from)?) 159 + } else { 160 + Err(IdentityError::Http( 161 + resp.error_for_status().unwrap_err().into(), 162 + )) 163 + } 164 + } 165 + 166 + #[cfg(feature = "dns")] 167 + async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> { 168 + let Some(dns) = &self.dns else { 169 + return Ok(vec![]); 170 + }; 171 + let fqdn = format!("_atproto.{name}."); 172 + let response = dns.txt_lookup(fqdn).await?; 173 + let mut out = Vec::new(); 174 + for txt in response.iter() { 175 + for data in txt.txt_data().iter() { 176 + out.push(String::from_utf8_lossy(data).to_string()); 177 + } 178 + } 179 + Ok(out) 180 + } 181 + 182 + fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> { 183 + let line = body 184 + .lines() 185 + .find(|l| !l.trim().is_empty()) 186 + .ok_or(IdentityError::InvalidWellKnown)?; 187 + let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; 188 + Ok(did.into_static()) 189 + } 190 + } 191 + 192 + impl JacquardResolver { 193 + /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default) 194 + pub async fn resolve_handle_via_pds( 195 + &self, 196 + handle: &Handle<'_>, 197 + ) -> Result<Did<'static>, IdentityError> { 198 + let pds = match &self.opts.pds_fallback { 199 + Some(u) => u.clone(), 200 + None => return Err(IdentityError::InvalidWellKnown), 201 + }; 202 + let req = ResolveHandle::new().handle((*handle).clone()).build(); 203 + let resp = self 204 + .http 205 + .xrpc(pds) 206 + .send(&req) 207 + .await 208 + .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 209 + let out = resp 210 + .into_output() 211 + .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 212 + Did::new_owned(out.did.as_str()) 213 + .map(|d| d.into_static()) 214 + .map_err(|_| IdentityError::InvalidWellKnown) 215 + } 216 + 217 + /// Fetch DID document via PDS resolveDid (returns owned DidDocument) 218 + pub async fn fetch_did_doc_via_pds_owned( 219 + &self, 220 + did: &Did<'_>, 221 + ) -> Result<DidDocument<'static>, IdentityError> { 222 + let pds = match &self.opts.pds_fallback { 223 + Some(u) => u.clone(), 224 + None => return Err(IdentityError::InvalidWellKnown), 225 + }; 226 + let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 227 + let resp = self 228 + .http 229 + .xrpc(pds) 230 + .send(&req) 231 + .await 232 + .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 233 + let out = resp 234 + .into_output() 235 + .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 236 + let doc_json = serde_json::to_value(&out.did_doc)?; 237 + let s = serde_json::to_string(&doc_json)?; 238 + let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; 239 + Ok(doc_borrowed.into_static()) 240 + } 241 + 242 + /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot. 243 + /// Returns the raw response wrapper for borrowed parsing and validation. 244 + pub async fn fetch_mini_doc_via_slingshot( 245 + &self, 246 + did: &Did<'_>, 247 + ) -> Result<DidDocResponse, IdentityError> { 248 + let base = match &self.opts.plc_source { 249 + PlcSource::Slingshot { base } => base.clone(), 250 + _ => { 251 + return Err(IdentityError::UnsupportedDidMethod( 252 + "mini-doc requires Slingshot source".into(), 253 + )); 254 + } 255 + }; 256 + let mut url = base; 257 + url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 258 + if let Ok(qs) = 259 + serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build()) 260 + { 261 + url.set_query(Some(&qs)); 262 + } 263 + let (buf, status) = self.get_json_bytes(url).await?; 264 + Ok(DidDocResponse { 265 + buffer: buf, 266 + status, 267 + requested: Some(did.clone().into_static()), 268 + }) 269 + } 270 + } 271 + 272 + #[async_trait::async_trait] 273 + impl IdentityResolver for JacquardResolver { 274 + fn options(&self) -> &ResolverOptions { 275 + &self.opts 276 + } 277 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 278 + let host = handle.as_str(); 279 + for step in &self.opts.handle_order { 280 + match step { 281 + HandleStep::DnsTxt => { 282 + #[cfg(feature = "dns")] 283 + { 284 + if let Ok(txts) = self.dns_txt(host).await { 285 + for txt in txts { 286 + if let Some(did_str) = txt.strip_prefix("did=") { 287 + if let Ok(did) = Did::new(did_str) { 288 + return Ok(did.into_static()); 289 + } 290 + } 291 + } 292 + } 293 + } 294 + } 295 + HandleStep::HttpsWellKnown => { 296 + let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 297 + if let Ok(text) = self.get_text(url).await { 298 + if let Ok(did) = Self::parse_atproto_did_body(&text) { 299 + return Ok(did); 300 + } 301 + } 302 + } 303 + HandleStep::PdsResolveHandle => { 304 + // Prefer PDS XRPC via stateless client 305 + if let Ok(did) = self.resolve_handle_via_pds(handle).await { 306 + return Ok(did); 307 + } 308 + // Public unauth fallback 309 + if self.opts.public_fallback_for_handle { 310 + if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { 311 + url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 312 + if let Ok(qs) = serde_html_form::to_string( 313 + &ResolveHandle::new().handle((*handle).clone()).build(), 314 + ) { 315 + url.set_query(Some(&qs)); 316 + } else { 317 + continue; 318 + } 319 + if let Ok((buf, status)) = self.get_json_bytes(url).await { 320 + if status.is_success() { 321 + if let Ok(val) = 322 + serde_json::from_slice::<serde_json::Value>(&buf) 323 + { 324 + if let Some(did_str) = 325 + val.get("did").and_then(|v| v.as_str()) 326 + { 327 + if let Ok(did) = Did::new_owned(did_str) { 328 + return Ok(did.into_static()); 329 + } 330 + } 331 + } 332 + } 333 + } 334 + } 335 + } 336 + // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint. 337 + if let PlcSource::Slingshot { base } = &self.opts.plc_source { 338 + let mut url = base.clone(); 339 + url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 340 + if let Ok(qs) = serde_html_form::to_string( 341 + &ResolveHandle::new().handle((*handle).clone()).build(), 342 + ) { 343 + url.set_query(Some(&qs)); 344 + } else { 345 + continue; 346 + } 347 + if let Ok((buf, status)) = self.get_json_bytes(url).await { 348 + if status.is_success() { 349 + if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) { 350 + if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) { 351 + if let Ok(did) = Did::new_owned(did_str) { 352 + return Ok(did.into_static()); 353 + } 354 + } 355 + } 356 + } 357 + } 358 + } 359 + } 360 + } 361 + } 362 + Err(IdentityError::InvalidWellKnown) 363 + } 364 + 365 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 366 + let s = did.as_str(); 367 + for step in &self.opts.did_order { 368 + match step { 369 + DidStep::DidWebHttps if s.starts_with("did:web:") => { 370 + let url = self.did_web_url(did)?; 371 + if let Ok((buf, status)) = self.get_json_bytes(url).await { 372 + return Ok(DidDocResponse { 373 + buffer: buf, 374 + status, 375 + requested: Some(did.clone().into_static()), 376 + }); 377 + } 378 + } 379 + DidStep::PlcHttp if s.starts_with("did:plc:") => { 380 + let url = match &self.opts.plc_source { 381 + PlcSource::PlcDirectory { base } => base.join(did.as_str())?, 382 + PlcSource::Slingshot { base } => base.join(did.as_str())?, 383 + }; 384 + if let Ok((buf, status)) = self.get_json_bytes(url).await { 385 + return Ok(DidDocResponse { 386 + buffer: buf, 387 + status, 388 + requested: Some(did.clone().into_static()), 389 + }); 390 + } 391 + } 392 + DidStep::PdsResolveDid => { 393 + // Try PDS XRPC for full DID doc 394 + if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await { 395 + let buf = serde_json::to_vec(&doc).unwrap_or_default(); 396 + return Ok(DidDocResponse { 397 + buffer: Bytes::from(buf), 398 + status: StatusCode::OK, 399 + requested: Some(did.clone().into_static()), 400 + }); 401 + } 402 + // Fallback: if Slingshot configured, return mini-doc response (partial doc) 403 + if let PlcSource::Slingshot { base } = &self.opts.plc_source { 404 + let url = self.slingshot_mini_doc_url(base, did.as_str())?; 405 + let (buf, status) = self.get_json_bytes(url).await?; 406 + return Ok(DidDocResponse { 407 + buffer: buf, 408 + status, 409 + requested: Some(did.clone().into_static()), 410 + }); 411 + } 412 + } 413 + _ => {} 414 + } 415 + } 416 + Err(IdentityError::UnsupportedDidMethod(s.to_string())) 417 + } 418 + } 419 + 420 + impl HttpClient for JacquardResolver { 421 + async fn send_http( 422 + &self, 423 + request: http::Request<Vec<u8>>, 424 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 425 + self.http.send_http(request).await 426 + } 427 + 428 + type Error = reqwest::Error; 429 + } 430 + 431 + /// Warnings produced during identity checks that are not fatal 432 + #[derive(Debug, Clone, PartialEq, Eq)] 433 + pub enum IdentityWarning { 434 + /// The DID doc did not contain the expected handle alias under alsoKnownAs 435 + HandleAliasMismatch { 436 + #[allow(missing_docs)] 437 + expected: Handle<'static>, 438 + }, 439 + } 440 + 441 + impl JacquardResolver { 442 + /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings. 443 + /// This applies the default equality check on the document id (error with doc if mismatch). 444 + pub async fn resolve_handle_and_doc( 445 + &self, 446 + handle: &Handle<'_>, 447 + ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> { 448 + let did = self.resolve_handle(handle).await?; 449 + let resp = self.resolve_did_doc(&did).await?; 450 + let resp_for_parse = resp.clone(); 451 + let doc_borrowed = resp_for_parse.parse()?; 452 + if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { 453 + return Err(IdentityError::DocIdMismatch { 454 + expected: did.clone().into_static(), 455 + doc: doc_borrowed.clone().into_static(), 456 + }); 457 + } 458 + let mut warnings = Vec::new(); 459 + // Check handle alias presence (soft warning) 460 + let expected_alias = format!("at://{}", handle.as_str()); 461 + let has_alias = doc_borrowed 462 + .also_known_as 463 + .as_ref() 464 + .map(|v| v.iter().any(|s| s.as_ref() == expected_alias)) 465 + .unwrap_or(false); 466 + if !has_alias { 467 + warnings.push(IdentityWarning::HandleAliasMismatch { 468 + expected: handle.clone().into_static(), 469 + }); 470 + } 471 + Ok((did, resp, warnings)) 472 + } 473 + 474 + /// Build Slingshot mini-doc URL for an identifier (handle or DID) 475 + fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> { 476 + let mut url = base.clone(); 477 + url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 478 + url.set_query(Some(&format!( 479 + "identifier={}", 480 + urlencoding::Encoded::new(identifier) 481 + ))); 482 + Ok(url) 483 + } 484 + 485 + /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier 486 + pub async fn fetch_mini_doc_via_slingshot_identifier( 487 + &self, 488 + identifier: &AtIdentifier<'_>, 489 + ) -> Result<MiniDocResponse, IdentityError> { 490 + let base = match &self.opts.plc_source { 491 + PlcSource::Slingshot { base } => base.clone(), 492 + _ => { 493 + return Err(IdentityError::UnsupportedDidMethod( 494 + "mini-doc requires Slingshot source".into(), 495 + )); 496 + } 497 + }; 498 + let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; 499 + let (buf, status) = self.get_json_bytes(url).await?; 500 + Ok(MiniDocResponse { 501 + buffer: buf, 502 + status, 503 + }) 504 + } 505 + } 506 + 507 + /// Slingshot mini-doc JSON response wrapper 508 + #[derive(Clone)] 509 + pub struct MiniDocResponse { 510 + buffer: Bytes, 511 + status: StatusCode, 512 + } 513 + 514 + impl MiniDocResponse { 515 + /// Parse borrowed MiniDoc 516 + pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> { 517 + if self.status.is_success() { 518 + serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 519 + } else { 520 + Err(IdentityError::HttpStatus(self.status)) 521 + } 522 + } 523 + } 524 + 525 + /// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC 526 + pub type PublicResolver = JacquardResolver; 527 + 528 + impl Default for PublicResolver { 529 + /// Build a resolver with: 530 + /// - reqwest HTTP client 531 + /// - Public fallbacks enabled for handle resolution 532 + /// - default options (DNS enabled if compiled, public fallback for handles enabled) 533 + /// 534 + /// Example 535 + /// ```ignore 536 + /// use jacquard::identity::resolver::PublicResolver; 537 + /// let resolver = PublicResolver::default(); 538 + /// ``` 539 + fn default() -> Self { 540 + let http = reqwest::Client::new(); 541 + let opts = ResolverOptions::default(); 542 + let resolver = JacquardResolver::new(http, opts); 543 + #[cfg(feature = "dns")] 544 + let resolver = resolver.with_system_dns(); 545 + resolver 546 + } 547 + } 548 + 549 + /// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 550 + /// mini-doc fallbacks, unauthenticated by default. 551 + pub fn slingshot_resolver_default() -> PublicResolver { 552 + let http = reqwest::Client::new(); 553 + let mut opts = ResolverOptions::default(); 554 + opts.plc_source = PlcSource::slingshot_default(); 555 + let resolver = JacquardResolver::new(http, opts); 556 + #[cfg(feature = "dns")] 557 + let resolver = resolver.with_system_dns(); 558 + resolver 559 + } 560 + 561 + #[cfg(test)] 562 + mod tests { 563 + use super::*; 564 + 565 + #[test] 566 + fn did_web_urls() { 567 + let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 568 + assert_eq!( 569 + r.test_did_web_url_raw("did:web:example.com"), 570 + "https://example.com/.well-known/did.json" 571 + ); 572 + assert_eq!( 573 + r.test_did_web_url_raw("did:web:example.com:user:alice"), 574 + "https://example.com/user/alice/did.json" 575 + ); 576 + } 577 + 578 + #[test] 579 + fn slingshot_mini_doc_url_build() { 580 + let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 581 + let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); 582 + let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap(); 583 + assert_eq!( 584 + url.as_str(), 585 + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com" 586 + ); 587 + } 588 + 589 + #[test] 590 + fn slingshot_mini_doc_parse_success() { 591 + let buf = Bytes::from_static( 592 + br#"{ 593 + "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid", 594 + "handle": "bad-example.com", 595 + "pds": "https://porcini.us-east.host.bsky.network", 596 + "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j" 597 + }"#, 598 + ); 599 + let resp = MiniDocResponse { 600 + buffer: buf, 601 + status: StatusCode::OK, 602 + }; 603 + let doc = resp.parse().expect("parse mini-doc"); 604 + assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); 605 + assert_eq!(doc.handle.as_str(), "bad-example.com"); 606 + assert_eq!( 607 + doc.pds.as_ref(), 608 + "https://porcini.us-east.host.bsky.network" 609 + ); 610 + assert!(doc.signing_key.as_ref().starts_with('z')); 611 + } 612 + 613 + #[test] 614 + fn slingshot_mini_doc_parse_error_status() { 615 + let buf = Bytes::from_static( 616 + br#"{ 617 + "error": "RecordNotFound", 618 + "message": "This record was deleted" 619 + }"#, 620 + ); 621 + let resp = MiniDocResponse { 622 + buffer: buf, 623 + status: StatusCode::BAD_REQUEST, 624 + }; 625 + match resp.parse() { 626 + Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), 627 + other => panic!("unexpected: {:?}", other), 628 + } 629 + } 630 + }
+428
crates/jacquard-identity/src/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 bon::Builder; 16 + use bytes::Bytes; 17 + use http::StatusCode; 18 + use jacquard_common::error::TransportError; 19 + use jacquard_common::types::did::Did; 20 + use jacquard_common::types::did_doc::{DidDocument, Service}; 21 + use jacquard_common::types::ident::AtIdentifier; 22 + use jacquard_common::types::string::{AtprotoStr, Handle}; 23 + use jacquard_common::types::uri::Uri; 24 + use jacquard_common::types::value::{AtDataError, Data}; 25 + use jacquard_common::{CowStr, IntoStatic}; 26 + use miette::Diagnostic; 27 + use thiserror::Error; 28 + use url::Url; 29 + 30 + /// Errors that can occur during identity resolution. 31 + /// 32 + /// Note: when validating a fetched DID document against a requested DID, a 33 + /// `DocIdMismatch` error is returned that includes the owned document so callers 34 + /// can inspect it and decide how to proceed. 35 + #[derive(Debug, Error, Diagnostic)] 36 + #[allow(missing_docs)] 37 + pub enum IdentityError { 38 + #[error("unsupported DID method: {0}")] 39 + #[diagnostic(code(jacquard_identity::unsupported_did_method), help("supported DID methods: did:web, did:plc"))] 40 + UnsupportedDidMethod(String), 41 + #[error("invalid well-known atproto-did content")] 42 + #[diagnostic(code(jacquard_identity::invalid_well_known), help("expected first non-empty line to be a DID"))] 43 + InvalidWellKnown, 44 + #[error("missing PDS endpoint in DID document")] 45 + #[diagnostic(code(jacquard_identity::missing_pds_endpoint))] 46 + MissingPdsEndpoint, 47 + #[error("HTTP error: {0}")] 48 + #[diagnostic(code(jacquard_identity::http), help("check network connectivity and TLS configuration"))] 49 + Http(#[from] TransportError), 50 + #[error("HTTP status {0}")] 51 + #[diagnostic(code(jacquard_identity::http_status), help("verify well-known paths or PDS XRPC endpoints"))] 52 + HttpStatus(StatusCode), 53 + #[error("XRPC error: {0}")] 54 + #[diagnostic(code(jacquard_identity::xrpc), help("enable PDS fallback or public resolver if needed"))] 55 + Xrpc(String), 56 + #[error("URL parse error: {0}")] 57 + #[diagnostic(code(jacquard_identity::url))] 58 + Url(#[from] url::ParseError), 59 + #[error("DNS error: {0}")] 60 + #[cfg(feature = "dns")] 61 + #[diagnostic(code(jacquard_identity::dns))] 62 + Dns(#[from] hickory_resolver::error::ResolveError), 63 + #[error("serialize/deserialize error: {0}")] 64 + #[diagnostic(code(jacquard_identity::serde))] 65 + Serde(#[from] serde_json::Error), 66 + #[error("invalid DID document: {0}")] 67 + #[diagnostic(code(jacquard_identity::invalid_doc), help("validate keys and services; ensure AtprotoPersonalDataServer service exists"))] 68 + InvalidDoc(String), 69 + #[error(transparent)] 70 + #[diagnostic(code(jacquard_identity::data))] 71 + Data(#[from] AtDataError), 72 + /// DID document id did not match requested DID; includes the fetched document 73 + #[error("DID doc id mismatch")] 74 + #[diagnostic(code(jacquard_identity::doc_id_mismatch), help("document id differs from requested DID; do not trust this document"))] 75 + DocIdMismatch { 76 + expected: Did<'static>, 77 + doc: DidDocument<'static>, 78 + }, 79 + } 80 + 81 + /// Source to fetch PLC (did:plc) documents from. 82 + /// 83 + /// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). 84 + /// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as 85 + /// `com.atproto.identity.resolveHandle` and a "mini-doc" 86 + /// endpoint (`com.bad-example.identity.resolveMiniDoc`). 87 + #[derive(Debug, Clone, PartialEq, Eq)] 88 + pub enum PlcSource { 89 + /// Use the public PLC directory 90 + PlcDirectory { 91 + /// Base URL for the PLC directory 92 + base: Url, 93 + }, 94 + /// Use the slingshot mini-docs service 95 + Slingshot { 96 + /// Base URL for the Slingshot service 97 + base: Url, 98 + }, 99 + } 100 + 101 + impl Default for PlcSource { 102 + fn default() -> Self { 103 + Self::PlcDirectory { 104 + base: Url::parse("https://plc.directory/").expect("valid url"), 105 + } 106 + } 107 + } 108 + 109 + impl PlcSource { 110 + /// Default Slingshot source (`https://slingshot.microcosm.blue`) 111 + pub fn slingshot_default() -> Self { 112 + PlcSource::Slingshot { 113 + base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), 114 + } 115 + } 116 + } 117 + 118 + /// DID Document fetch response for borrowed/owned parsing. 119 + /// 120 + /// Carries the raw response bytes and the HTTP status, plus the requested DID 121 + /// (if supplied) to enable validation. Use `parse()` to borrow from the buffer 122 + /// or `parse_validated()` to also enforce that the doc `id` matches the 123 + /// requested DID (returns a `DocIdMismatch` containing the fetched doc on 124 + /// mismatch). Use `into_owned()` to parse into an owned document. 125 + #[derive(Clone)] 126 + pub struct DidDocResponse { 127 + #[allow(missing_docs)] 128 + pub buffer: Bytes, 129 + #[allow(missing_docs)] 130 + pub status: StatusCode, 131 + /// Optional DID we intended to resolve; used for validation helpers 132 + pub requested: Option<Did<'static>>, 133 + } 134 + 135 + impl DidDocResponse { 136 + /// Parse as borrowed DidDocument<'_> 137 + pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 138 + if self.status.is_success() { 139 + if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 140 + Ok(doc) 141 + } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) { 142 + Ok(DidDocument { 143 + id: mini_doc.did, 144 + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 145 + verification_method: None, 146 + service: Some(vec![Service { 147 + id: CowStr::new_static("#atproto_pds"), 148 + r#type: CowStr::new_static("AtprotoPersonalDataServer"), 149 + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 150 + Url::from_str(&mini_doc.pds).unwrap(), 151 + )))), 152 + extra_data: BTreeMap::new(), 153 + }]), 154 + extra_data: BTreeMap::new(), 155 + }) 156 + } else { 157 + Err(IdentityError::MissingPdsEndpoint) 158 + } 159 + } else { 160 + Err(IdentityError::HttpStatus(self.status)) 161 + } 162 + } 163 + 164 + /// Parse and validate that the DID in the document matches the requested DID if present. 165 + /// 166 + /// On mismatch, returns an error that contains the owned document for inspection. 167 + pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 168 + let doc = self.parse()?; 169 + if let Some(expected) = &self.requested { 170 + if doc.id.as_str() != expected.as_str() { 171 + return Err(IdentityError::DocIdMismatch { 172 + expected: expected.clone(), 173 + doc: doc.clone().into_static(), 174 + }); 175 + } 176 + } 177 + Ok(doc) 178 + } 179 + 180 + /// Parse as owned DidDocument<'static> 181 + pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 182 + if self.status.is_success() { 183 + if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 184 + Ok(doc.into_static()) 185 + } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) { 186 + Ok(DidDocument { 187 + id: mini_doc.did, 188 + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 189 + verification_method: None, 190 + service: Some(vec![Service { 191 + id: CowStr::new_static("#atproto_pds"), 192 + r#type: CowStr::new_static("AtprotoPersonalDataServer"), 193 + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 194 + Url::from_str(&mini_doc.pds).unwrap(), 195 + )))), 196 + extra_data: BTreeMap::new(), 197 + }]), 198 + extra_data: BTreeMap::new(), 199 + } 200 + .into_static()) 201 + } else { 202 + Err(IdentityError::MissingPdsEndpoint) 203 + } 204 + } else { 205 + Err(IdentityError::HttpStatus(self.status)) 206 + } 207 + } 208 + } 209 + 210 + /// Slingshot mini-doc data (subset of DID doc info) 211 + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 212 + #[serde(rename_all = "camelCase")] 213 + #[allow(missing_docs)] 214 + pub struct MiniDoc<'a> { 215 + #[serde(borrow)] 216 + pub did: Did<'a>, 217 + #[serde(borrow)] 218 + pub handle: Handle<'a>, 219 + #[serde(borrow)] 220 + pub pds: CowStr<'a>, 221 + #[serde(borrow, rename = "signingKey", alias = "signing_key")] 222 + pub signing_key: CowStr<'a>, 223 + } 224 + 225 + /// Handle โ†’ DID fallback step. 226 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 227 + pub enum HandleStep { 228 + /// DNS TXT _atproto.\<handle\> 229 + DnsTxt, 230 + /// HTTPS GET https://\<handle\>/.well-known/atproto-did 231 + HttpsWellKnown, 232 + /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 233 + PdsResolveHandle, 234 + } 235 + 236 + /// DID โ†’ Doc fallback step. 237 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 238 + pub enum DidStep { 239 + /// For did:web: fetch from the well-known location 240 + DidWebHttps, 241 + /// For did:plc: fetch from PLC source 242 + PlcHttp, 243 + /// If a PDS base is known, ask it for the DID doc 244 + PdsResolveDid, 245 + } 246 + 247 + /// Configurable resolver options. 248 + /// 249 + /// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 250 + /// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless 251 + /// XRPC over reqwest; authentication can be layered as needed). 252 + /// - `handle_order`/`did_order`: ordered strategies for resolution. 253 + /// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 254 + /// returning `DocIdMismatch` with the fetched document on mismatch. 255 + /// - `public_fallback_for_handle`: if true (default), attempt 256 + /// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 257 + /// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC 258 + /// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 259 + #[derive(Debug, Clone, Builder)] 260 + #[builder(start_fn = new)] 261 + pub struct ResolverOptions { 262 + /// PLC data source (directory or slingshot) 263 + pub plc_source: PlcSource, 264 + /// Optional PDS base to use for fallbacks 265 + pub pds_fallback: Option<Url>, 266 + /// Order of attempts for handle โ†’ DID resolution 267 + pub handle_order: Vec<HandleStep>, 268 + /// Order of attempts for DID โ†’ Doc resolution 269 + pub did_order: Vec<DidStep>, 270 + /// Validate that fetched DID document id matches the requested DID 271 + pub validate_doc_id: bool, 272 + /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 273 + pub public_fallback_for_handle: bool, 274 + } 275 + 276 + impl Default for ResolverOptions { 277 + fn default() -> Self { 278 + // By default, prefer DNS then HTTPS for handles, then PDS fallback 279 + // For DID documents, prefer method-native sources, then PDS fallback 280 + Self::new() 281 + .plc_source(PlcSource::default()) 282 + .handle_order(vec![ 283 + HandleStep::DnsTxt, 284 + HandleStep::HttpsWellKnown, 285 + HandleStep::PdsResolveHandle, 286 + ]) 287 + .did_order(vec![ 288 + DidStep::DidWebHttps, 289 + DidStep::PlcHttp, 290 + DidStep::PdsResolveDid, 291 + ]) 292 + .validate_doc_id(true) 293 + .public_fallback_for_handle(true) 294 + .build() 295 + } 296 + } 297 + 298 + /// Trait for identity resolution, for pluggable implementations. 299 + /// 300 + /// The provided `DefaultResolver` supports: 301 + /// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 302 + /// - HTTPS well-known for handles and `did:web` 303 + /// - PLC directory or Slingshot for `did:plc` 304 + /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 305 + /// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 306 + #[async_trait::async_trait()] 307 + pub trait IdentityResolver { 308 + /// Access options for validation decisions in default methods 309 + fn options(&self) -> &ResolverOptions; 310 + 311 + /// Resolve handle 312 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>; 313 + 314 + /// Resolve DID document 315 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>; 316 + 317 + /// Resolve DID doc from an identifier 318 + async fn resolve_ident( 319 + &self, 320 + actor: &AtIdentifier<'_>, 321 + ) -> Result<DidDocResponse, IdentityError> { 322 + match actor { 323 + AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, 324 + AtIdentifier::Handle(handle) => { 325 + let did = self.resolve_handle(&handle).await?; 326 + self.resolve_did_doc(&did).await 327 + } 328 + } 329 + } 330 + 331 + /// Resolve DID doc from an identifier 332 + async fn resolve_ident_owned( 333 + &self, 334 + actor: &AtIdentifier<'_>, 335 + ) -> Result<DidDocument<'static>, IdentityError> { 336 + match actor { 337 + AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, 338 + AtIdentifier::Handle(handle) => { 339 + let did = self.resolve_handle(&handle).await?; 340 + self.resolve_did_doc_owned(&did).await 341 + } 342 + } 343 + } 344 + 345 + /// Resolve the DID document and return an owned version 346 + async fn resolve_did_doc_owned( 347 + &self, 348 + did: &Did<'_>, 349 + ) -> Result<DidDocument<'static>, IdentityError> { 350 + self.resolve_did_doc(did).await?.into_owned() 351 + } 352 + /// Return the PDS url for a DID 353 + async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 354 + let resp = self.resolve_did_doc(did).await?; 355 + let doc = resp.parse()?; 356 + // Default-on doc id equality check 357 + if self.options().validate_doc_id { 358 + if doc.id.as_str() != did.as_str() { 359 + return Err(IdentityError::DocIdMismatch { 360 + expected: did.clone().into_static(), 361 + doc: doc.clone().into_static(), 362 + }); 363 + } 364 + } 365 + doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 366 + } 367 + /// Return the DIS and PDS url for a handle 368 + async fn pds_for_handle( 369 + &self, 370 + handle: &Handle<'_>, 371 + ) -> Result<(Did<'static>, Url), IdentityError> { 372 + let did = self.resolve_handle(handle).await?; 373 + let pds = self.pds_for_did(&did).await?; 374 + Ok((did, pds)) 375 + } 376 + } 377 + 378 + #[async_trait::async_trait] 379 + impl<T: IdentityResolver + Sync + Send> IdentityResolver for std::sync::Arc<T> { 380 + fn options(&self) -> &ResolverOptions { 381 + self.as_ref().options() 382 + } 383 + 384 + /// Resolve handle 385 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 386 + self.as_ref().resolve_handle(handle).await 387 + } 388 + 389 + /// Resolve DID document 390 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 391 + self.as_ref().resolve_did_doc(did).await 392 + } 393 + } 394 + 395 + #[cfg(test)] 396 + mod tests { 397 + use super::*; 398 + 399 + #[test] 400 + fn parse_validated_ok() { 401 + let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 402 + let requested = Did::new_owned("did:plc:alice").unwrap(); 403 + let resp = DidDocResponse { 404 + buffer: buf, 405 + status: StatusCode::OK, 406 + requested: Some(requested), 407 + }; 408 + let _doc = resp.parse_validated().expect("valid"); 409 + } 410 + 411 + #[test] 412 + fn parse_validated_mismatch() { 413 + let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 414 + let requested = Did::new_owned("did:plc:alice").unwrap(); 415 + let resp = DidDocResponse { 416 + buffer: buf, 417 + status: StatusCode::OK, 418 + requested: Some(requested), 419 + }; 420 + match resp.parse_validated() { 421 + Err(IdentityError::DocIdMismatch { expected, doc }) => { 422 + assert_eq!(expected.as_str(), "did:plc:alice"); 423 + assert_eq!(doc.id.as_str(), "did:plc:bob"); 424 + } 425 + other => panic!("unexpected result: {:?}", other), 426 + } 427 + } 428 + }
+1 -1
crates/jacquard-lexicon/Cargo.toml
··· 19 19 clap.workspace = true 20 20 heck.workspace = true 21 21 itertools.workspace = true 22 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 22 + jacquard-common = { version = "0.3.0", path = "../jacquard-common" } 23 23 miette = { workspace = true, features = ["fancy"] } 24 24 prettyplease.workspace = true 25 25 proc-macro2.workspace = true
+35
crates/jacquard-oauth/Cargo.toml
··· 1 + [package] 2 + name = "jacquard-oauth" 3 + version = "0.3.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.3.0", path = "../jacquard-common" } 10 + jacquard-identity = { version = "0.3.0", path = "../jacquard-identity" } 11 + serde = { workspace = true, features = ["derive"] } 12 + serde_json = { workspace = true } 13 + url = { workspace = true } 14 + smol_str = { workspace = true } 15 + base64.workspace = true 16 + sha2 = { version = "0.10" } 17 + thiserror = { workspace = true } 18 + serde_html_form = { workspace = true } 19 + miette = { workspace = true } 20 + uuid = { version = "1", features = ["v4","std"] } 21 + p256 = { workspace = true, features = ["ecdsa"] } 22 + signature = "2" 23 + rand_core.workspace = true 24 + jose-jwa = "0.1" 25 + jose-jwk = { workspace = true, features = ["p256"] } 26 + chrono.workspace = true 27 + elliptic-curve = "0.13.8" 28 + http.workspace = true 29 + bytes.workspace = true 30 + rand = { version = "0.8.5", features = ["small_rng"] } 31 + async-trait.workspace = true 32 + dashmap = "6.1.0" 33 + tokio = { workspace = true, features = ["sync"] } 34 + reqwest.workspace = true 35 + trait-variant.workspace = true
+414
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::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 + #[derive(Clone, Debug, PartialEq, Eq)] 80 + pub struct AtprotoClientMetadata<'m> { 81 + pub client_id: Url, 82 + pub client_uri: Option<Url>, 83 + pub redirect_uris: Vec<Url>, 84 + pub grant_types: Vec<GrantType>, 85 + pub scopes: Vec<Scope<'m>>, 86 + pub jwks_uri: Option<Url>, 87 + } 88 + 89 + impl<'m> AtprotoClientMetadata<'m> { 90 + pub fn new( 91 + client_id: Url, 92 + client_uri: Option<Url>, 93 + redirect_uris: Vec<Url>, 94 + grant_types: Vec<GrantType>, 95 + scopes: Vec<Scope<'m>>, 96 + jwks_uri: Option<Url>, 97 + ) -> Self { 98 + Self { 99 + client_id, 100 + client_uri, 101 + redirect_uris, 102 + grant_types, 103 + scopes, 104 + jwks_uri, 105 + } 106 + } 107 + 108 + pub fn new_localhost( 109 + mut redirect_uris: Option<Vec<Url>>, 110 + scopes: Option<Vec<Scope<'m>>>, 111 + ) -> Self { 112 + // Coerce provided redirect URIs to http://localhost while preserving path 113 + if let Some(redirect_uris) = &mut redirect_uris { 114 + for redirect_uri in redirect_uris { 115 + let _ = redirect_uri.set_scheme("http"); 116 + redirect_uri.set_host(Some("localhost")).unwrap(); 117 + let _ = redirect_uri.set_port(None); 118 + } 119 + } 120 + // determine client_id 121 + #[derive(serde::Serialize)] 122 + struct Parameters<'a> { 123 + #[serde(skip_serializing_if = "Option::is_none")] 124 + redirect_uri: Option<Vec<Url>>, 125 + #[serde(skip_serializing_if = "Option::is_none")] 126 + scope: Option<CowStr<'a>>, 127 + } 128 + let query = serde_html_form::to_string(Parameters { 129 + redirect_uri: redirect_uris.clone(), 130 + scope: scopes 131 + .as_ref() 132 + .map(|s| Scope::serialize_multiple(s.as_slice())), 133 + }) 134 + .ok(); 135 + let mut client_id = String::from("http://localhost"); 136 + if let Some(query) = query 137 + && !query.is_empty() 138 + { 139 + client_id.push_str(&format!("?{query}")); 140 + } 141 + Self { 142 + client_id: Url::parse(&client_id).unwrap(), 143 + client_uri: None, 144 + redirect_uris: redirect_uris.unwrap_or(vec![ 145 + Url::from_str("http://127.0.0.1/").unwrap(), 146 + Url::from_str("http://[::1]/").unwrap(), 147 + ]), 148 + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 149 + scopes: scopes.unwrap_or(vec![Scope::Atproto]), 150 + jwks_uri: None, 151 + } 152 + } 153 + } 154 + 155 + pub fn atproto_client_metadata<'m>( 156 + metadata: AtprotoClientMetadata<'m>, 157 + keyset: &Option<Keyset>, 158 + ) -> Result<OAuthClientMetadata<'m>> { 159 + // For non-loopback clients, require a keyset/JWKs. 160 + let is_loopback = metadata.client_id.scheme() == "http" 161 + && metadata.client_id.host_str() == Some("localhost"); 162 + if !is_loopback && keyset.is_none() { 163 + return Err(Error::EmptyJwks); 164 + } 165 + if metadata.redirect_uris.is_empty() { 166 + return Err(Error::EmptyRedirectUris); 167 + } 168 + if !metadata.grant_types.contains(&GrantType::AuthorizationCode) { 169 + return Err(Error::InvalidGrantTypes); 170 + } 171 + if !metadata.scopes.contains(&Scope::Atproto) { 172 + return Err(Error::InvalidScope); 173 + } 174 + let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset { 175 + let jwks = if metadata.jwks_uri.is_none() { 176 + Some(keyset.public_jwks()) 177 + } else { 178 + None 179 + }; 180 + (AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks) 181 + } else { 182 + (AuthMethod::None, None, None) 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(auth_method.into()), 190 + grant_types: if keyset.is_some() { 191 + Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) 192 + } else { 193 + None 194 + }, 195 + scope: if keyset.is_some() { 196 + Some(Scope::serialize_multiple(metadata.scopes.as_slice())) 197 + } else { 198 + None 199 + }, 200 + dpop_bound_access_tokens: if keyset.is_some() { Some(true) } else { None }, 201 + jwks_uri, 202 + jwks, 203 + token_endpoint_auth_signing_alg: if keyset.is_some() { 204 + Some(CowStr::new_static("ES256")) 205 + } else { 206 + None 207 + }, 208 + }) 209 + } 210 + 211 + #[cfg(test)] 212 + mod tests { 213 + use std::str::FromStr; 214 + 215 + use crate::scopes::TransitionScope; 216 + 217 + use super::*; 218 + use elliptic_curve::SecretKey; 219 + use jose_jwk::{Jwk, Key, Parameters}; 220 + use p256::pkcs8::DecodePrivateKey; 221 + 222 + const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- 223 + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T 224 + 4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P 225 + gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 226 + -----END PRIVATE KEY-----"#; 227 + 228 + #[test] 229 + fn test_localhost_client_metadata_default() { 230 + assert_eq!( 231 + atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None) 232 + .unwrap(), 233 + OAuthClientMetadata { 234 + client_id: Url::from_str("http://localhost").unwrap(), 235 + client_uri: None, 236 + redirect_uris: vec![ 237 + Url::from_str("http://127.0.0.1/").unwrap(), 238 + Url::from_str("http://[::1]/").unwrap(), 239 + ], 240 + scope: None, 241 + grant_types: None, 242 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 243 + dpop_bound_access_tokens: None, 244 + jwks_uri: None, 245 + jwks: None, 246 + token_endpoint_auth_signing_alg: None, 247 + } 248 + ); 249 + } 250 + 251 + #[test] 252 + fn test_localhost_client_metadata_custom() { 253 + assert_eq!( 254 + atproto_client_metadata(AtprotoClientMetadata::new_localhost( 255 + Some(vec![ 256 + Url::from_str("http://127.0.0.1/callback").unwrap(), 257 + Url::from_str("http://[::1]/callback").unwrap(), 258 + ]), 259 + Some( 260 + vec![ 261 + Scope::Atproto, 262 + Scope::Transition(TransitionScope::Generic), 263 + Scope::parse("account:email").unwrap() 264 + ] 265 + ) 266 + ), &None) 267 + .expect("failed to convert metadata"), 268 + OAuthClientMetadata { 269 + client_id: Url::from_str( 270 + "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" 271 + ).unwrap(), 272 + client_uri: None, 273 + redirect_uris: vec![ 274 + Url::from_str("http://localhost/callback").unwrap(), 275 + Url::from_str("http://localhost/callback").unwrap(), 276 + ], 277 + scope: None, 278 + grant_types: None, 279 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 280 + dpop_bound_access_tokens: None, 281 + jwks_uri: None, 282 + jwks: None, 283 + token_endpoint_auth_signing_alg: None, 284 + } 285 + ); 286 + } 287 + 288 + #[test] 289 + fn test_localhost_client_metadata_invalid() { 290 + // Invalid inputs are coerced to http://localhost rather than failing 291 + { 292 + let out = atproto_client_metadata( 293 + AtprotoClientMetadata::new_localhost( 294 + Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]), 295 + None, 296 + ), 297 + &None, 298 + ) 299 + .expect("should coerce to localhost"); 300 + assert_eq!( 301 + out, 302 + OAuthClientMetadata { 303 + client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 304 + client_uri: None, 305 + redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 306 + scope: None, 307 + grant_types: None, 308 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 309 + dpop_bound_access_tokens: None, 310 + jwks_uri: None, 311 + jwks: None, 312 + token_endpoint_auth_signing_alg: None, 313 + } 314 + ); 315 + } 316 + { 317 + let out = atproto_client_metadata( 318 + AtprotoClientMetadata::new_localhost( 319 + Some(vec![Url::from_str("http://localhost:8000/").unwrap()]), 320 + None, 321 + ), 322 + &None, 323 + ) 324 + .expect("should coerce to localhost"); 325 + assert_eq!( 326 + out, 327 + OAuthClientMetadata { 328 + client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 329 + client_uri: None, 330 + redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 331 + scope: None, 332 + grant_types: None, 333 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 334 + dpop_bound_access_tokens: None, 335 + jwks_uri: None, 336 + jwks: None, 337 + token_endpoint_auth_signing_alg: None, 338 + } 339 + ); 340 + } 341 + { 342 + let out = atproto_client_metadata( 343 + AtprotoClientMetadata::new_localhost( 344 + Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]), 345 + None, 346 + ), 347 + &None, 348 + ) 349 + .expect("should coerce to localhost"); 350 + assert_eq!( 351 + out, 352 + OAuthClientMetadata { 353 + client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 354 + client_uri: None, 355 + redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 356 + scope: None, 357 + grant_types: None, 358 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 359 + dpop_bound_access_tokens: None, 360 + jwks_uri: None, 361 + jwks: None, 362 + token_endpoint_auth_signing_alg: None, 363 + } 364 + ); 365 + } 366 + } 367 + 368 + #[test] 369 + fn test_client_metadata() { 370 + let metadata = AtprotoClientMetadata { 371 + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), 372 + client_uri: Some(Url::from_str("https://example.com").unwrap()), 373 + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], 374 + grant_types: vec![GrantType::AuthorizationCode], 375 + scopes: vec![Scope::Atproto], 376 + jwks_uri: None, 377 + }; 378 + { 379 + // Non-loopback clients without a keyset should fail (must provide JWKS) 380 + let metadata = metadata.clone(); 381 + let err = atproto_client_metadata(metadata, &None).expect_err("expected to fail"); 382 + assert!(matches!(err, Error::EmptyJwks)); 383 + } 384 + { 385 + let metadata = metadata.clone(); 386 + let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY) 387 + .expect("failed to parse private key"); 388 + let keys = vec![Jwk { 389 + key: Key::from(&secret_key.into()), 390 + prm: Parameters { 391 + kid: Some(String::from("kid00")), 392 + ..Default::default() 393 + }, 394 + }]; 395 + let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset"); 396 + assert_eq!( 397 + atproto_client_metadata(metadata, &Some(keyset.clone())) 398 + .expect("failed to convert metadata"), 399 + OAuthClientMetadata { 400 + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), 401 + client_uri: Some(Url::from_str("https://example.com").unwrap()), 402 + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], 403 + scope: Some(CowStr::new_static("atproto")), 404 + grant_types: Some(vec![CowStr::new_static("authorization_code")]), 405 + token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 406 + dpop_bound_access_tokens: Some(true), 407 + jwks_uri: None, 408 + jwks: Some(keyset.public_jwks()), 409 + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 410 + } 411 + ); 412 + } 413 + } 414 + }
+138
crates/jacquard-oauth/src/authstore.rs
··· 1 + use std::sync::Arc; 2 + 3 + use dashmap::DashMap; 4 + use jacquard_common::{ 5 + IntoStatic, 6 + session::{SessionStore, SessionStoreError}, 7 + types::did::Did, 8 + }; 9 + use smol_str::{SmolStr, ToSmolStr, format_smolstr}; 10 + 11 + use crate::session::{AuthRequestData, ClientSessionData}; 12 + 13 + #[async_trait::async_trait] 14 + pub trait ClientAuthStore { 15 + async fn get_session( 16 + &self, 17 + did: &Did<'_>, 18 + session_id: &str, 19 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>; 20 + 21 + async fn upsert_session(&self, session: ClientSessionData<'_>) 22 + -> Result<(), SessionStoreError>; 23 + 24 + async fn delete_session( 25 + &self, 26 + did: &Did<'_>, 27 + session_id: &str, 28 + ) -> Result<(), SessionStoreError>; 29 + 30 + async fn get_auth_req_info( 31 + &self, 32 + state: &str, 33 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>; 34 + 35 + async fn save_auth_req_info( 36 + &self, 37 + auth_req_info: &AuthRequestData<'_>, 38 + ) -> Result<(), SessionStoreError>; 39 + 40 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>; 41 + } 42 + 43 + pub struct MemoryAuthStore { 44 + sessions: DashMap<SmolStr, ClientSessionData<'static>>, 45 + auth_reqs: DashMap<SmolStr, AuthRequestData<'static>>, 46 + } 47 + 48 + impl MemoryAuthStore { 49 + pub fn new() -> Self { 50 + Self { 51 + sessions: DashMap::new(), 52 + auth_reqs: DashMap::new(), 53 + } 54 + } 55 + } 56 + 57 + #[async_trait::async_trait] 58 + impl ClientAuthStore for MemoryAuthStore { 59 + async fn get_session( 60 + &self, 61 + did: &Did<'_>, 62 + session_id: &str, 63 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 64 + let key = format_smolstr!("{}_{}", did, session_id); 65 + Ok(self.sessions.get(&key).map(|v| v.clone())) 66 + } 67 + 68 + async fn upsert_session( 69 + &self, 70 + session: ClientSessionData<'_>, 71 + ) -> Result<(), SessionStoreError> { 72 + let key = format_smolstr!("{}_{}", session.account_did, session.session_id); 73 + self.sessions.insert(key, session.into_static()); 74 + Ok(()) 75 + } 76 + 77 + async fn delete_session( 78 + &self, 79 + did: &Did<'_>, 80 + session_id: &str, 81 + ) -> Result<(), SessionStoreError> { 82 + let key = format_smolstr!("{}_{}", did, session_id); 83 + self.sessions.remove(&key); 84 + Ok(()) 85 + } 86 + 87 + async fn get_auth_req_info( 88 + &self, 89 + state: &str, 90 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 91 + Ok(self.auth_reqs.get(state).map(|v| v.clone())) 92 + } 93 + 94 + async fn save_auth_req_info( 95 + &self, 96 + auth_req_info: &AuthRequestData<'_>, 97 + ) -> Result<(), SessionStoreError> { 98 + self.auth_reqs.insert( 99 + auth_req_info.state.clone().to_smolstr(), 100 + auth_req_info.clone().into_static(), 101 + ); 102 + Ok(()) 103 + } 104 + 105 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 106 + self.auth_reqs.remove(state); 107 + Ok(()) 108 + } 109 + } 110 + 111 + #[async_trait::async_trait] 112 + impl<T: ClientAuthStore + Send + Sync> 113 + SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T> 114 + { 115 + /// Get the current session if present. 116 + async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> { 117 + let (did, session_id) = key; 118 + self.as_ref() 119 + .get_session(did, session_id) 120 + .await 121 + .ok() 122 + .flatten() 123 + .into_static() 124 + } 125 + /// Persist the given session. 126 + async fn set( 127 + &self, 128 + _key: (Did<'static>, SmolStr), 129 + session: ClientSessionData<'static>, 130 + ) -> Result<(), SessionStoreError> { 131 + self.as_ref().upsert_session(session).await 132 + } 133 + /// Delete the given session. 134 + async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> { 135 + let (did, session_id) = key; 136 + self.as_ref().delete_session(did, session_id).await 137 + } 138 + }
+358
crates/jacquard-oauth/src/client.rs
··· 1 + use crate::{ 2 + atproto::atproto_client_metadata, 3 + authstore::ClientAuthStore, 4 + dpop::DpopExt, 5 + error::{CallbackError, Result}, 6 + request::{OAuthMetadata, exchange_code, par}, 7 + resolver::OAuthResolver, 8 + scopes::Scope, 9 + session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry}, 10 + types::{AuthorizeOptions, CallbackParams}, 11 + }; 12 + use jacquard_common::{ 13 + AuthorizationToken, CowStr, IntoStatic, 14 + error::{AuthError, ClientError, TransportError, XrpcResult}, 15 + http_client::HttpClient, 16 + types::{ 17 + did::Did, 18 + xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest}, 19 + }, 20 + }; 21 + use jacquard_identity::JacquardResolver; 22 + use jose_jwk::JwkSet; 23 + use std::sync::Arc; 24 + use tokio::sync::RwLock; 25 + use url::Url; 26 + 27 + pub struct OAuthClient<T, S> 28 + where 29 + T: OAuthResolver, 30 + S: ClientAuthStore, 31 + { 32 + pub registry: Arc<SessionRegistry<T, S>>, 33 + pub client: Arc<T>, 34 + } 35 + 36 + impl<S: ClientAuthStore> OAuthClient<JacquardResolver, S> { 37 + pub fn new(store: S, client_data: ClientData<'static>) -> Self { 38 + let client = JacquardResolver::default(); 39 + Self::new_from_resolver(store, client, client_data) 40 + } 41 + } 42 + 43 + impl<T, S> OAuthClient<T, S> 44 + where 45 + T: OAuthResolver, 46 + S: ClientAuthStore, 47 + { 48 + pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self { 49 + let client = Arc::new(client); 50 + let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); 51 + Self { registry, client } 52 + } 53 + } 54 + 55 + impl<T, S> OAuthClient<T, S> 56 + where 57 + S: ClientAuthStore + Send + Sync + 'static, 58 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 59 + { 60 + pub fn jwks(&self) -> JwkSet { 61 + self.registry 62 + .client_data 63 + .keyset 64 + .as_ref() 65 + .map(|keyset| keyset.public_jwks()) 66 + .unwrap_or_default() 67 + } 68 + pub async fn start_auth( 69 + &self, 70 + input: impl AsRef<str>, 71 + options: AuthorizeOptions<'_>, 72 + ) -> Result<String> { 73 + let client_metadata = atproto_client_metadata( 74 + self.registry.client_data.config.clone(), 75 + &self.registry.client_data.keyset, 76 + )?; 77 + 78 + let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?; 79 + let login_hint = if identity.is_some() { 80 + Some(input.as_ref().into()) 81 + } else { 82 + None 83 + }; 84 + let metadata = OAuthMetadata { 85 + server_metadata, 86 + client_metadata, 87 + keyset: self.registry.client_data.keyset.clone(), 88 + }; 89 + let auth_req_info = 90 + par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; 91 + 92 + #[derive(serde::Serialize)] 93 + struct Parameters<'s> { 94 + client_id: Url, 95 + request_uri: CowStr<'s>, 96 + } 97 + Ok(metadata.server_metadata.authorization_endpoint.to_string() 98 + + "?" 99 + + &serde_html_form::to_string(Parameters { 100 + client_id: metadata.client_metadata.client_id.clone(), 101 + request_uri: auth_req_info.request_uri, 102 + }) 103 + .unwrap()) 104 + } 105 + 106 + pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> { 107 + let Some(state_key) = params.state else { 108 + return Err(CallbackError::MissingState.into()); 109 + }; 110 + 111 + let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else { 112 + return Err(CallbackError::MissingState.into()); 113 + }; 114 + 115 + self.registry.store.delete_auth_req_info(&state_key).await?; 116 + 117 + let metadata = self 118 + .client 119 + .get_authorization_server_metadata(&auth_req_info.authserver_url) 120 + .await?; 121 + 122 + if let Some(iss) = params.iss { 123 + if !crate::resolver::issuer_equivalent(&iss, &metadata.issuer) { 124 + return Err(CallbackError::IssuerMismatch { expected: metadata.issuer.to_string(), got: iss.to_string() }.into()); 125 + } 126 + } else if metadata.authorization_response_iss_parameter_supported == Some(true) { 127 + return Err(CallbackError::MissingIssuer.into()); 128 + } 129 + let metadata = OAuthMetadata { 130 + server_metadata: metadata, 131 + client_metadata: atproto_client_metadata( 132 + self.registry.client_data.config.clone(), 133 + &self.registry.client_data.keyset, 134 + )?, 135 + keyset: self.registry.client_data.keyset.clone(), 136 + }; 137 + let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone(); 138 + 139 + match exchange_code( 140 + self.client.as_ref(), 141 + &mut auth_req_info.dpop_data.clone(), 142 + &params.code, 143 + &auth_req_info.pkce_verifier, 144 + &metadata, 145 + ) 146 + .await 147 + { 148 + Ok(token_set) => { 149 + let scopes = if let Some(scope) = &token_set.scope { 150 + Scope::parse_multiple_reduced(&scope) 151 + .expect("Failed to parse scopes") 152 + .into_static() 153 + } else { 154 + vec![] 155 + }; 156 + let client_data = ClientSessionData { 157 + account_did: token_set.sub.clone(), 158 + session_id: auth_req_info.state, 159 + host_url: Url::parse(&token_set.iss).expect("Failed to parse host URL"), 160 + authserver_url: auth_req_info.authserver_url, 161 + authserver_token_endpoint: auth_req_info.authserver_token_endpoint, 162 + authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint, 163 + scopes, 164 + dpop_data: DpopClientData { 165 + dpop_key: auth_req_info.dpop_data.dpop_key.clone(), 166 + dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()), 167 + dpop_host_nonce: auth_req_info 168 + .dpop_data 169 + .dpop_authserver_nonce 170 + .unwrap_or(CowStr::default()), 171 + }, 172 + token_set, 173 + }; 174 + 175 + self.create_session(client_data).await 176 + } 177 + Err(e) => Err(e.into()), 178 + } 179 + } 180 + 181 + async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> { 182 + Ok(OAuthSession::new( 183 + self.registry.clone(), 184 + self.client.clone(), 185 + data.into_static(), 186 + )) 187 + } 188 + 189 + pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> { 190 + self.create_session(self.registry.get(did, session_id, false).await?) 191 + .await 192 + } 193 + 194 + pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> { 195 + Ok(self.registry.del(did, session_id).await?) 196 + } 197 + } 198 + 199 + pub struct OAuthSession<T, S> 200 + where 201 + T: OAuthResolver, 202 + S: ClientAuthStore, 203 + { 204 + pub registry: Arc<SessionRegistry<T, S>>, 205 + pub client: Arc<T>, 206 + pub data: RwLock<ClientSessionData<'static>>, 207 + pub options: RwLock<CallOptions<'static>>, 208 + } 209 + 210 + impl<T, S> OAuthSession<T, S> 211 + where 212 + T: OAuthResolver, 213 + S: ClientAuthStore, 214 + { 215 + pub fn new( 216 + registry: Arc<SessionRegistry<T, S>>, 217 + client: Arc<T>, 218 + data: ClientSessionData<'static>, 219 + ) -> Self { 220 + Self { 221 + registry, 222 + client, 223 + data: RwLock::new(data), 224 + options: RwLock::new(CallOptions::default()), 225 + } 226 + } 227 + 228 + pub fn with_options(self, options: CallOptions<'_>) -> Self { 229 + Self { 230 + registry: self.registry, 231 + client: self.client, 232 + data: self.data, 233 + options: RwLock::new(options.into_static()), 234 + } 235 + } 236 + 237 + pub async fn set_options(&self, options: CallOptions<'_>) { 238 + *self.options.write().await = options.into_static(); 239 + } 240 + 241 + pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) { 242 + let data = self.data.read().await; 243 + (data.account_did.clone(), data.session_id.clone()) 244 + } 245 + 246 + pub async fn endpoint(&self) -> Url { 247 + self.data.read().await.host_url.clone() 248 + } 249 + 250 + pub async fn access_token(&self) -> AuthorizationToken<'_> { 251 + AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone()) 252 + } 253 + 254 + pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> { 255 + self.data.read().await.token_set.refresh_token.as_ref().map(|t| AuthorizationToken::Dpop(t.clone())) 256 + } 257 + } 258 + impl<T, S> OAuthSession<T, S> 259 + where 260 + S: ClientAuthStore + Send + Sync + 'static, 261 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 262 + { 263 + pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> { 264 + // Read identifiers without holding the lock across await 265 + let (did, sid) = { 266 + let data = self.data.read().await; 267 + (data.account_did.clone(), data.session_id.clone()) 268 + }; 269 + let refreshed = self 270 + .registry 271 + .as_ref() 272 + .get(&did, &sid, true) 273 + .await?; 274 + let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone()); 275 + // Write back updated session 276 + *self.data.write().await = refreshed.into_static(); 277 + Ok(token) 278 + } 279 + } 280 + 281 + impl<T, S> HttpClient for OAuthSession<T, S> 282 + where 283 + S: ClientAuthStore + Send + Sync + 'static, 284 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 285 + { 286 + type Error = T::Error; 287 + 288 + async fn send_http( 289 + &self, 290 + request: http::Request<Vec<u8>>, 291 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 292 + self.client.send_http(request).await 293 + } 294 + } 295 + 296 + impl<T, S> XrpcClient for OAuthSession<T, S> 297 + where 298 + S: ClientAuthStore + Send + Sync + 'static, 299 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 300 + { 301 + fn base_uri(&self) -> Url { 302 + // base_uri is a synchronous trait method; we must avoid async `.read().await`. 303 + // Use `block_in_place` under Tokio to perform a blocking RwLock read safely. 304 + if tokio::runtime::Handle::try_current().is_ok() { 305 + tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone()) 306 + } else { 307 + self.data.blocking_read().host_url.clone() 308 + } 309 + } 310 + 311 + async fn opts(&self) -> CallOptions<'_> { 312 + self.options.read().await.clone() 313 + } 314 + 315 + async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>( 316 + self, 317 + request: &R, 318 + ) -> XrpcResult<Response<R>> { 319 + let base_uri = self.base_uri(); 320 + let auth = self.access_token().await; 321 + let mut opts = self.options.read().await.clone(); 322 + opts.auth = Some(auth); 323 + let res = self 324 + .client 325 + .xrpc(base_uri.clone()) 326 + .with_options(opts.clone()) 327 + .send(request) 328 + .await; 329 + if is_invalid_token_response(&res) { 330 + opts.auth = Some( 331 + self.refresh() 332 + .await 333 + .map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?, 334 + ); 335 + self.client 336 + .xrpc(base_uri) 337 + .with_options(opts) 338 + .send(request) 339 + .await 340 + } else { 341 + res 342 + } 343 + } 344 + } 345 + 346 + fn is_invalid_token_response<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool { 347 + match response { 348 + Err(ClientError::Auth(AuthError::InvalidToken)) => true, 349 + Err(ClientError::Auth(AuthError::Other(value))) => value 350 + .to_str() 351 + .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), 352 + Ok(resp) => match resp.parse() { 353 + Err(jacquard_common::types::xrpc::XrpcError::Auth(AuthError::InvalidToken)) => true, 354 + _ => false, 355 + }, 356 + _ => false, 357 + } 358 + }
+247
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::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient}; 5 + use jose_jwa::{Algorithm, Signing}; 6 + use jose_jwk::{Jwk, Key, crypto}; 7 + use p256::ecdsa::SigningKey; 8 + use rand::{RngCore, SeedableRng}; 9 + use sha2::Digest; 10 + 11 + use crate::{ 12 + jose::{ 13 + create_signed_jwt, 14 + jws::RegisteredHeader, 15 + jwt::{Claims, PublicClaims, RegisteredClaims}, 16 + }, 17 + session::DpopDataSource, 18 + }; 19 + 20 + pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; 21 + 22 + #[derive(serde::Deserialize)] 23 + struct ErrorResponse { 24 + error: String, 25 + } 26 + 27 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 28 + pub enum Error { 29 + #[error(transparent)] 30 + InvalidHeaderValue(#[from] InvalidHeaderValue), 31 + #[error("crypto error: {0:?}")] 32 + JwkCrypto(crypto::Error), 33 + #[error("key does not match any alg supported by the server")] 34 + UnsupportedKey, 35 + #[error(transparent)] 36 + SerdeJson(#[from] serde_json::Error), 37 + #[error("Inner: {0}")] 38 + Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 39 + } 40 + 41 + type Result<T> = core::result::Result<T, Error>; 42 + 43 + #[async_trait::async_trait] 44 + pub trait DpopClient: HttpClient { 45 + async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 46 + async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 47 + async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 48 + } 49 + 50 + pub trait DpopExt: HttpClient { 51 + fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D> 52 + where 53 + Self: Sized, 54 + D: DpopDataSource, 55 + { 56 + DpopCall::server(self, data_source) 57 + } 58 + 59 + fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N> 60 + where 61 + Self: Sized, 62 + N: DpopDataSource, 63 + { 64 + DpopCall::client(self, data_source) 65 + } 66 + } 67 + 68 + pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> { 69 + pub client: &'r C, 70 + pub is_to_auth_server: bool, 71 + pub data_source: &'r mut D, 72 + } 73 + 74 + impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> { 75 + pub fn server(client: &'r C, data_source: &'r mut N) -> Self { 76 + Self { 77 + client, 78 + is_to_auth_server: true, 79 + data_source, 80 + } 81 + } 82 + 83 + pub fn client(client: &'r C, data_source: &'r mut N) -> Self { 84 + Self { 85 + client, 86 + is_to_auth_server: false, 87 + data_source, 88 + } 89 + } 90 + 91 + pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> { 92 + wrap_request_with_dpop( 93 + self.client, 94 + self.data_source, 95 + self.is_to_auth_server, 96 + request, 97 + ) 98 + .await 99 + } 100 + } 101 + 102 + pub async fn wrap_request_with_dpop<T, N>( 103 + client: &T, 104 + data_source: &mut N, 105 + is_to_auth_server: bool, 106 + mut request: Request<Vec<u8>>, 107 + ) -> Result<Response<Vec<u8>>> 108 + where 109 + T: HttpClient, 110 + N: DpopDataSource, 111 + { 112 + let uri = request.uri().clone(); 113 + let method = request.method().to_cowstr().into_static(); 114 + let uri = uri.to_cowstr(); 115 + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 116 + let ath = request 117 + .headers() 118 + .get("Authorization") 119 + .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 120 + .map(|auth| { 121 + URL_SAFE_NO_PAD 122 + .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 123 + .into() 124 + }); 125 + 126 + let init_nonce = if is_to_auth_server { 127 + data_source.authserver_nonce() 128 + } else { 129 + data_source.host_nonce() 130 + }; 131 + let init_proof = build_dpop_proof( 132 + data_source.key(), 133 + method.clone(), 134 + uri.clone(), 135 + init_nonce.clone(), 136 + ath.clone(), 137 + )?; 138 + request.headers_mut().insert("DPoP", init_proof.parse()?); 139 + let response = client 140 + .send_http(request.clone()) 141 + .await 142 + .map_err(|e| Error::Inner(e.into()))?; 143 + 144 + let next_nonce = response 145 + .headers() 146 + .get("DPoP-Nonce") 147 + .and_then(|v| v.to_str().ok()) 148 + .map(|c| c.to_cowstr()); 149 + match &next_nonce { 150 + Some(s) if next_nonce != init_nonce => { 151 + // Store the fresh nonce for future requests 152 + if is_to_auth_server { 153 + data_source.set_authserver_nonce(s.clone()); 154 + } else { 155 + data_source.set_host_nonce(s.clone()); 156 + } 157 + } 158 + _ => { 159 + // No nonce was returned or it is the same as the one we sent. No need to 160 + // update the nonce store, or retry the request. 161 + return Ok(response); 162 + } 163 + } 164 + 165 + if !is_use_dpop_nonce_error(is_to_auth_server, &response) { 166 + return Ok(response); 167 + } 168 + let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 169 + request.headers_mut().insert("DPoP", next_proof.parse()?); 170 + let response = client 171 + .send_http(request) 172 + .await 173 + .map_err(|e| Error::Inner(e.into()))?; 174 + Ok(response) 175 + } 176 + 177 + #[inline] 178 + fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool { 179 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 180 + if is_to_auth_server { 181 + if response.status() == 400 { 182 + if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 183 + return res.error == "use_dpop_nonce"; 184 + }; 185 + } 186 + } 187 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 188 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 189 + else if response.status() == 401 { 190 + if let Some(www_auth) = response 191 + .headers() 192 + .get("WWW-Authenticate") 193 + .and_then(|v| v.to_str().ok()) 194 + { 195 + return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#); 196 + } 197 + } 198 + false 199 + } 200 + 201 + #[inline] 202 + pub(crate) fn generate_jti() -> CowStr<'static> { 203 + let mut rng = rand::rngs::SmallRng::from_entropy(); 204 + let mut bytes = [0u8; 12]; 205 + rng.fill_bytes(&mut bytes); 206 + URL_SAFE_NO_PAD.encode(bytes).into() 207 + } 208 + 209 + /// Build a compact JWS (ES256) for DPoP with embedded public JWK. 210 + #[inline] 211 + pub fn build_dpop_proof<'s>( 212 + key: &Key, 213 + method: CowStr<'s>, 214 + url: CowStr<'s>, 215 + nonce: Option<CowStr<'s>>, 216 + ath: Option<CowStr<'s>>, 217 + ) -> Result<CowStr<'s>> { 218 + let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 219 + crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 220 + _ => return Err(Error::UnsupportedKey), 221 + }; 222 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 223 + header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 224 + header.jwk = Some(Jwk { 225 + key: Key::from(&crypto::Key::from(secret.public_key())), 226 + prm: Default::default(), 227 + }); 228 + 229 + let claims = Claims { 230 + registered: RegisteredClaims { 231 + jti: Some(generate_jti()), 232 + iat: Some(Utc::now().timestamp()), 233 + ..Default::default() 234 + }, 235 + public: PublicClaims { 236 + htm: Some(method), 237 + htu: Some(url), 238 + ath: ath, 239 + nonce: nonce, 240 + }, 241 + }; 242 + Ok(create_signed_jwt( 243 + SigningKey::from(secret.clone()), 244 + header.into(), 245 + claims, 246 + )?) 247 + }
+69
crates/jacquard-oauth/src/error.rs
··· 1 + use jacquard_common::session::SessionStoreError; 2 + use miette::Diagnostic; 3 + 4 + use crate::request::RequestError; 5 + use crate::resolver::ResolverError; 6 + 7 + /// High-level errors emitted by OAuth helpers. 8 + #[derive(Debug, thiserror::Error, Diagnostic)] 9 + pub enum OAuthError { 10 + #[error(transparent)] 11 + #[diagnostic(code(jacquard_oauth::resolver))] 12 + Resolver(#[from] ResolverError), 13 + 14 + #[error(transparent)] 15 + #[diagnostic(code(jacquard_oauth::request))] 16 + Request(#[from] RequestError), 17 + 18 + #[error(transparent)] 19 + #[diagnostic(code(jacquard_oauth::storage))] 20 + Storage(#[from] SessionStoreError), 21 + 22 + #[error(transparent)] 23 + #[diagnostic(code(jacquard_oauth::dpop))] 24 + Dpop(#[from] crate::dpop::Error), 25 + 26 + #[error(transparent)] 27 + #[diagnostic(code(jacquard_oauth::keyset))] 28 + Keyset(#[from] crate::keyset::Error), 29 + 30 + #[error(transparent)] 31 + #[diagnostic(code(jacquard_oauth::atproto))] 32 + Atproto(#[from] crate::atproto::Error), 33 + 34 + #[error(transparent)] 35 + #[diagnostic(code(jacquard_oauth::session))] 36 + Session(#[from] crate::session::Error), 37 + 38 + #[error(transparent)] 39 + #[diagnostic(code(jacquard_oauth::serde_json))] 40 + SerdeJson(#[from] serde_json::Error), 41 + 42 + #[error(transparent)] 43 + #[diagnostic(code(jacquard_oauth::url))] 44 + Url(#[from] url::ParseError), 45 + 46 + #[error(transparent)] 47 + #[diagnostic(code(jacquard_oauth::form))] 48 + Form(#[from] serde_html_form::ser::Error), 49 + 50 + #[error(transparent)] 51 + #[diagnostic(code(jacquard_oauth::callback))] 52 + Callback(#[from] CallbackError), 53 + } 54 + 55 + /// Typed callback validation errors (redirect handling). 56 + #[derive(Debug, thiserror::Error, Diagnostic)] 57 + pub enum CallbackError { 58 + #[error("missing state parameter in callback")] 59 + #[diagnostic(code(jacquard_oauth::callback::missing_state))] 60 + MissingState, 61 + #[error("missing `iss` parameter")] 62 + #[diagnostic(code(jacquard_oauth::callback::missing_iss))] 63 + MissingIssuer, 64 + #[error("issuer mismatch: expected {expected}, got {got}")] 65 + #[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))] 66 + IssuerMismatch { expected: String, got: String }, 67 + } 68 + 69 + pub type Result<T> = core::result::Result<T, OAuthError>;
+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 + }
+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;
+127
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, IntoStatic}; 5 + use jose_jwa::{Algorithm, Signing}; 6 + use jose_jwk::{Class, EcCurves, crypto}; 7 + use jose_jwk::{Jwk, JwkSet, Key}; 8 + use std::collections::HashSet; 9 + use thiserror::Error; 10 + 11 + #[derive(Error, Debug)] 12 + pub enum Error { 13 + #[error("duplicate kid: {0}")] 14 + DuplicateKid(String), 15 + #[error("keys must not be empty")] 16 + EmptyKeys, 17 + #[error("key must have a `kid`")] 18 + EmptyKid, 19 + #[error("no signing key found for algorithms: {0:?}")] 20 + NotFound(Vec<CowStr<'static>>), 21 + #[error("key for signing must be a secret key")] 22 + PublicKey, 23 + #[error("crypto error: {0:?}")] 24 + JwkCrypto(crypto::Error), 25 + #[error(transparent)] 26 + SerdeJson(#[from] serde_json::Error), 27 + } 28 + 29 + pub type Result<T> = core::result::Result<T, Error>; 30 + 31 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 32 + pub struct Keyset(Vec<Jwk>); 33 + 34 + impl Keyset { 35 + const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [ 36 + "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", 37 + ]; 38 + pub fn public_jwks(&self) -> JwkSet { 39 + let mut keys = Vec::with_capacity(self.0.len()); 40 + for mut key in self.0.clone() { 41 + match key.key { 42 + Key::Ec(ref mut ec) => { 43 + ec.d = None; 44 + } 45 + _ => unimplemented!(), 46 + } 47 + keys.push(key); 48 + } 49 + JwkSet { keys } 50 + } 51 + pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> { 52 + let Some(jwk) = self.find_key(algs, Class::Signing) else { 53 + return Err(Error::NotFound(algs.to_vec().into_static())); 54 + }; 55 + self.create_jwt_with_key(jwk, claims) 56 + } 57 + fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> { 58 + let candidates = self 59 + .0 60 + .iter() 61 + .filter_map(|key| { 62 + if key.prm.cls.is_some_and(|c| c != cls) { 63 + return None; 64 + } 65 + let alg = match &key.key { 66 + Key::Ec(ec) => match ec.crv { 67 + EcCurves::P256 => "ES256", 68 + _ => unimplemented!(), 69 + }, 70 + _ => unimplemented!(), 71 + }; 72 + Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg))) 73 + }) 74 + .collect::<Vec<_>>(); 75 + for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { 76 + for (alg, key) in &candidates { 77 + if alg == &pref_alg { 78 + return Some(key); 79 + } 80 + } 81 + } 82 + None 83 + } 84 + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> { 85 + let kid = key.prm.kid.clone().unwrap(); 86 + match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 87 + crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { 88 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 89 + header.kid = Some(kid.into()); 90 + Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) 91 + } 92 + _ => unimplemented!(), 93 + } 94 + } 95 + } 96 + 97 + impl TryFrom<Vec<Jwk>> for Keyset { 98 + type Error = Error; 99 + 100 + fn try_from(keys: Vec<Jwk>) -> Result<Self> { 101 + if keys.is_empty() { 102 + return Err(Error::EmptyKeys); 103 + } 104 + let mut v = Vec::with_capacity(keys.len()); 105 + let mut hs = HashSet::with_capacity(keys.len()); 106 + for key in keys { 107 + if let Some(kid) = key.prm.kid.clone() { 108 + if hs.contains(&kid) { 109 + return Err(Error::DuplicateKid(kid)); 110 + } 111 + hs.insert(kid); 112 + // ensure that the key is a secret key 113 + if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 114 + crypto::Key::P256(crypto::Kind::Public(_)) => true, 115 + crypto::Key::P256(crypto::Kind::Secret(_)) => false, 116 + _ => unimplemented!(), 117 + } { 118 + return Err(Error::PublicKey); 119 + } 120 + v.push(key); 121 + } else { 122 + return Err(Error::EmptyKid); 123 + } 124 + } 125 + Ok(Self(v)) 126 + } 127 + }
+18
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 authstore; 6 + pub mod client; 7 + pub mod dpop; 8 + pub mod error; 9 + pub mod jose; 10 + pub mod keyset; 11 + pub mod request; 12 + pub mod resolver; 13 + pub mod scopes; 14 + pub mod session; 15 + pub mod types; 16 + pub mod utils; 17 + 18 + pub const FALLBACK_ALG: &str = "ES256";
+736
crates/jacquard-oauth/src/request.rs
··· 1 + use chrono::{TimeDelta, Utc}; 2 + use http::{Method, Request, StatusCode}; 3 + use jacquard_common::{ 4 + CowStr, IntoStatic, 5 + cowstr::ToCowStr, 6 + http_client::HttpClient, 7 + session::SessionStoreError, 8 + types::{ 9 + did::Did, 10 + string::{AtStrError, Datetime}, 11 + }, 12 + }; 13 + use jacquard_identity::resolver::IdentityError; 14 + use serde::Serialize; 15 + use serde_json::Value; 16 + use smol_str::ToSmolStr; 17 + use thiserror::Error; 18 + 19 + use crate::{ 20 + FALLBACK_ALG, 21 + atproto::atproto_client_metadata, 22 + dpop::DpopExt, 23 + jose::jwt::{RegisteredClaims, RegisteredClaimsAud}, 24 + keyset::Keyset, 25 + resolver::OAuthResolver, 26 + scopes::Scope, 27 + session::{ 28 + AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData, 29 + }, 30 + types::{ 31 + AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt, 32 + OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse, 33 + OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters, 34 + TokenGrantType, TokenRequestParameters, TokenSet, 35 + }, 36 + utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce}, 37 + }; 38 + 39 + // https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 40 + const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = 41 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 42 + 43 + #[derive(Error, Debug, miette::Diagnostic)] 44 + pub enum RequestError { 45 + #[error("no {0} endpoint available")] 46 + #[diagnostic( 47 + code(jacquard_oauth::request::no_endpoint), 48 + help("server does not advertise this endpoint") 49 + )] 50 + NoEndpoint(CowStr<'static>), 51 + #[error("token response verification failed")] 52 + #[diagnostic(code(jacquard_oauth::request::token_verification))] 53 + TokenVerification, 54 + #[error("unsupported authentication method")] 55 + #[diagnostic( 56 + code(jacquard_oauth::request::unsupported_auth_method), 57 + help( 58 + "server must support `private_key_jwt` or `none`; configure client metadata accordingly" 59 + ) 60 + )] 61 + UnsupportedAuthMethod, 62 + #[error("no refresh token available")] 63 + #[diagnostic(code(jacquard_oauth::request::no_refresh_token))] 64 + NoRefreshToken, 65 + #[error("failed to parse DID: {0}")] 66 + #[diagnostic(code(jacquard_oauth::request::invalid_did))] 67 + InvalidDid(#[from] AtStrError), 68 + #[error(transparent)] 69 + #[diagnostic(code(jacquard_oauth::request::dpop))] 70 + DpopClient(#[from] crate::dpop::Error), 71 + #[error(transparent)] 72 + #[diagnostic(code(jacquard_oauth::request::storage))] 73 + Storage(#[from] SessionStoreError), 74 + 75 + #[error(transparent)] 76 + #[diagnostic(code(jacquard_oauth::request::resolver))] 77 + ResolverError(#[from] crate::resolver::ResolverError), 78 + // #[error(transparent)] 79 + // OAuthSession(#[from] crate::oauth_session::Error), 80 + #[error(transparent)] 81 + #[diagnostic(code(jacquard_oauth::request::http_build))] 82 + Http(#[from] http::Error), 83 + #[error("http status: {0}")] 84 + #[diagnostic( 85 + code(jacquard_oauth::request::http_status), 86 + help("see server response for details") 87 + )] 88 + HttpStatus(StatusCode), 89 + #[error("http status: {0}, body: {1:?}")] 90 + #[diagnostic( 91 + code(jacquard_oauth::request::http_status_body), 92 + help("server returned error JSON; inspect fields like `error`, `error_description`") 93 + )] 94 + HttpStatusWithBody(StatusCode, Value), 95 + #[error(transparent)] 96 + #[diagnostic(code(jacquard_oauth::request::identity))] 97 + Identity(#[from] IdentityError), 98 + #[error(transparent)] 99 + #[diagnostic(code(jacquard_oauth::request::keyset))] 100 + Keyset(#[from] crate::keyset::Error), 101 + #[error(transparent)] 102 + #[diagnostic(code(jacquard_oauth::request::serde_form))] 103 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 104 + #[error(transparent)] 105 + #[diagnostic(code(jacquard_oauth::request::serde_json))] 106 + SerdeJson(#[from] serde_json::Error), 107 + #[error(transparent)] 108 + #[diagnostic(code(jacquard_oauth::request::atproto))] 109 + Atproto(#[from] crate::atproto::Error), 110 + } 111 + 112 + pub type Result<T> = core::result::Result<T, RequestError>; 113 + 114 + #[allow(dead_code)] 115 + pub enum OAuthRequest<'a> { 116 + Token(TokenRequestParameters<'a>), 117 + Refresh(RefreshRequestParameters<'a>), 118 + Revocation(RevocationRequestParameters<'a>), 119 + Introspection, 120 + PushedAuthorizationRequest(ParParameters<'a>), 121 + } 122 + 123 + impl OAuthRequest<'_> { 124 + pub fn name(&self) -> CowStr<'static> { 125 + CowStr::new_static(match self { 126 + Self::Token(_) => "token", 127 + Self::Refresh(_) => "refresh", 128 + Self::Revocation(_) => "revocation", 129 + Self::Introspection => "introspection", 130 + Self::PushedAuthorizationRequest(_) => "pushed_authorization_request", 131 + }) 132 + } 133 + pub fn expected_status(&self) -> StatusCode { 134 + match self { 135 + Self::Token(_) | Self::Refresh(_) => StatusCode::OK, 136 + Self::PushedAuthorizationRequest(_) => StatusCode::CREATED, 137 + // Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`. 138 + Self::Revocation(_) => StatusCode::NO_CONTENT, 139 + _ => unimplemented!(), 140 + } 141 + } 142 + } 143 + 144 + #[cfg(test)] 145 + mod tests { 146 + use super::*; 147 + use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata}; 148 + use bytes::Bytes; 149 + use http::{Response as HttpResponse, StatusCode}; 150 + use jacquard_common::http_client::HttpClient; 151 + use jacquard_identity::resolver::IdentityResolver; 152 + use std::sync::Arc; 153 + use tokio::sync::Mutex; 154 + 155 + #[derive(Clone, Default)] 156 + struct MockClient { 157 + resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>, 158 + } 159 + 160 + impl HttpClient for MockClient { 161 + type Error = std::convert::Infallible; 162 + fn send_http( 163 + &self, 164 + _request: http::Request<Vec<u8>>, 165 + ) -> impl core::future::Future< 166 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 167 + > + Send { 168 + let resp = self.resp.clone(); 169 + async move { Ok(resp.lock().await.take().unwrap()) } 170 + } 171 + } 172 + 173 + // IdentityResolver methods won't be called in these tests; provide stubs. 174 + #[async_trait::async_trait] 175 + impl IdentityResolver for MockClient { 176 + fn options(&self) -> &jacquard_identity::resolver::ResolverOptions { 177 + use std::sync::LazyLock; 178 + static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> = 179 + LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default()); 180 + &OPTS 181 + } 182 + async fn resolve_handle( 183 + &self, 184 + _handle: &jacquard_common::types::string::Handle<'_>, 185 + ) -> std::result::Result< 186 + jacquard_common::types::string::Did<'static>, 187 + jacquard_identity::resolver::IdentityError, 188 + > { 189 + Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap()) 190 + } 191 + async fn resolve_did_doc( 192 + &self, 193 + _did: &jacquard_common::types::string::Did<'_>, 194 + ) -> std::result::Result< 195 + jacquard_identity::resolver::DidDocResponse, 196 + jacquard_identity::resolver::IdentityError, 197 + > { 198 + let doc = serde_json::json!({ 199 + "id": "did:plc:alice", 200 + "service": [{ 201 + "id": "#pds", 202 + "type": "AtprotoPersonalDataServer", 203 + "serviceEndpoint": "https://pds" 204 + }] 205 + }); 206 + let buf = Bytes::from(serde_json::to_vec(&doc).unwrap()); 207 + Ok(jacquard_identity::resolver::DidDocResponse { 208 + buffer: buf, 209 + status: StatusCode::OK, 210 + requested: None, 211 + }) 212 + } 213 + } 214 + 215 + // Allow using DPoP helpers on MockClient 216 + impl crate::dpop::DpopExt for MockClient {} 217 + impl crate::resolver::OAuthResolver for MockClient {} 218 + 219 + fn base_metadata() -> OAuthMetadata { 220 + let mut server = OAuthAuthorizationServerMetadata::default(); 221 + server.issuer = CowStr::from("https://issuer"); 222 + server.authorization_endpoint = CowStr::from("https://issuer/authorize"); 223 + server.token_endpoint = CowStr::from("https://issuer/token"); 224 + OAuthMetadata { 225 + server_metadata: server, 226 + client_metadata: OAuthClientMetadata { 227 + client_id: url::Url::parse("https://client").unwrap(), 228 + client_uri: None, 229 + redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()], 230 + scope: Some(CowStr::from("atproto")), 231 + grant_types: None, 232 + token_endpoint_auth_method: Some(CowStr::from("none")), 233 + dpop_bound_access_tokens: None, 234 + jwks_uri: None, 235 + jwks: None, 236 + token_endpoint_auth_signing_alg: None, 237 + }, 238 + keyset: None, 239 + } 240 + } 241 + 242 + #[tokio::test] 243 + async fn par_missing_endpoint() { 244 + let mut meta = base_metadata(); 245 + meta.server_metadata.require_pushed_authorization_requests = Some(true); 246 + meta.server_metadata.pushed_authorization_request_endpoint = None; 247 + // require_pushed_authorization_requests is true and no endpoint 248 + let err = super::par(&MockClient::default(), None, None, &meta) 249 + .await 250 + .unwrap_err(); 251 + match err { 252 + RequestError::NoEndpoint(name) => { 253 + assert_eq!(name.as_ref(), "pushed_authorization_request"); 254 + } 255 + other => panic!("unexpected: {other:?}"), 256 + } 257 + } 258 + 259 + #[tokio::test] 260 + async fn refresh_no_refresh_token() { 261 + let client = MockClient::default(); 262 + let meta = base_metadata(); 263 + let session = ClientSessionData { 264 + account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(), 265 + session_id: CowStr::from("state"), 266 + host_url: url::Url::parse("https://pds").unwrap(), 267 + authserver_url: url::Url::parse("https://issuer").unwrap(), 268 + authserver_token_endpoint: CowStr::from("https://issuer/token"), 269 + authserver_revocation_endpoint: None, 270 + scopes: vec![], 271 + dpop_data: DpopClientData { 272 + dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 273 + dpop_authserver_nonce: CowStr::from(""), 274 + dpop_host_nonce: CowStr::from(""), 275 + }, 276 + token_set: crate::types::TokenSet { 277 + iss: CowStr::from("https://issuer"), 278 + sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(), 279 + aud: CowStr::from("https://pds"), 280 + scope: None, 281 + refresh_token: None, 282 + access_token: CowStr::from("abc"), 283 + token_type: crate::types::OAuthTokenType::DPoP, 284 + expires_at: None, 285 + }, 286 + }; 287 + let err = super::refresh(&client, session, &meta).await.unwrap_err(); 288 + matches!(err, RequestError::NoRefreshToken); 289 + } 290 + 291 + #[tokio::test] 292 + async fn exchange_code_missing_sub() { 293 + let client = MockClient::default(); 294 + // set mock HTTP response body: token response without `sub` 295 + *client.resp.lock().await = Some( 296 + HttpResponse::builder() 297 + .status(StatusCode::OK) 298 + .body( 299 + serde_json::to_vec(&serde_json::json!({ 300 + "access_token":"tok", 301 + "token_type":"DPoP", 302 + "expires_in": 3600 303 + })) 304 + .unwrap(), 305 + ) 306 + .unwrap(), 307 + ); 308 + let meta = base_metadata(); 309 + let mut dpop = DpopReqData { 310 + dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 311 + dpop_authserver_nonce: None, 312 + }; 313 + let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta) 314 + .await 315 + .unwrap_err(); 316 + matches!(err, RequestError::TokenVerification); 317 + } 318 + } 319 + 320 + #[derive(Debug, Serialize)] 321 + pub struct RequestPayload<'a, T> 322 + where 323 + T: Serialize, 324 + { 325 + client_id: CowStr<'a>, 326 + #[serde(skip_serializing_if = "Option::is_none")] 327 + client_assertion_type: Option<CowStr<'a>>, 328 + #[serde(skip_serializing_if = "Option::is_none")] 329 + client_assertion: Option<CowStr<'a>>, 330 + #[serde(flatten)] 331 + parameters: T, 332 + } 333 + 334 + #[derive(Debug, Clone)] 335 + pub struct OAuthMetadata { 336 + pub server_metadata: OAuthAuthorizationServerMetadata<'static>, 337 + pub client_metadata: OAuthClientMetadata<'static>, 338 + pub keyset: Option<Keyset>, 339 + } 340 + 341 + impl OAuthMetadata { 342 + pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>( 343 + client: &T, 344 + ClientData { keyset, config }: &ClientData<'r>, 345 + session_data: &ClientSessionData<'r>, 346 + ) -> Result<Self> { 347 + Ok(OAuthMetadata { 348 + server_metadata: client 349 + .get_authorization_server_metadata(&session_data.authserver_url) 350 + .await?, 351 + client_metadata: atproto_client_metadata(config.clone(), &keyset) 352 + .unwrap() 353 + .into_static(), 354 + keyset: keyset.clone(), 355 + }) 356 + } 357 + } 358 + 359 + pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( 360 + client: &T, 361 + login_hint: Option<CowStr<'r>>, 362 + prompt: Option<AuthorizeOptionPrompt>, 363 + metadata: &OAuthMetadata, 364 + ) -> crate::request::Result<AuthRequestData<'r>> { 365 + let state = generate_nonce(); 366 + let (code_challenge, verifier) = generate_pkce(); 367 + 368 + let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { 369 + return Err(RequestError::TokenVerification); 370 + }; 371 + let mut dpop_data = DpopReqData { 372 + dpop_key, 373 + dpop_authserver_nonce: None, 374 + }; 375 + let parameters = ParParameters { 376 + response_type: AuthorizationResponseType::Code, 377 + redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(), 378 + state: state.clone(), 379 + scope: metadata.client_metadata.scope.clone(), 380 + response_mode: None, 381 + code_challenge, 382 + code_challenge_method: AuthorizationCodeChallengeMethod::S256, 383 + login_hint: login_hint, 384 + prompt: prompt.map(CowStr::from), 385 + }; 386 + if metadata 387 + .server_metadata 388 + .pushed_authorization_request_endpoint 389 + .is_some() 390 + { 391 + let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>( 392 + &client, 393 + &mut dpop_data, 394 + OAuthRequest::PushedAuthorizationRequest(parameters), 395 + metadata, 396 + ) 397 + .await?; 398 + 399 + let scopes = if let Some(scope) = &metadata.client_metadata.scope { 400 + Scope::parse_multiple_reduced(&scope) 401 + .expect("Failed to parse scopes") 402 + .into_static() 403 + } else { 404 + vec![] 405 + }; 406 + let auth_req_data = AuthRequestData { 407 + state, 408 + authserver_url: url::Url::parse(&metadata.server_metadata.issuer) 409 + .expect("Failed to parse issuer URL"), 410 + account_did: None, 411 + scopes, 412 + request_uri: par_response.request_uri.to_cowstr().into_static(), 413 + authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(), 414 + authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(), 415 + pkce_verifier: verifier, 416 + dpop_data, 417 + }; 418 + 419 + Ok(auth_req_data) 420 + } else if metadata 421 + .server_metadata 422 + .require_pushed_authorization_requests 423 + == Some(true) 424 + { 425 + Err(RequestError::NoEndpoint(CowStr::new_static( 426 + "pushed_authorization_request", 427 + ))) 428 + } else { 429 + todo!("use of PAR is mandatory") 430 + } 431 + } 432 + 433 + pub async fn refresh<'r, T>( 434 + client: &T, 435 + mut session_data: ClientSessionData<'r>, 436 + metadata: &OAuthMetadata, 437 + ) -> Result<ClientSessionData<'r>> 438 + where 439 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 440 + { 441 + let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { 442 + return Err(RequestError::NoRefreshToken); 443 + }; 444 + 445 + // /!\ IMPORTANT /!\ 446 + // 447 + // The "sub" MUST be a DID, whose issuer authority is indeed the server we 448 + // are trying to obtain credentials from. Note that we are doing this 449 + // *before* we actually try to refresh the token: 450 + // 1) To avoid unnecessary refresh 451 + // 2) So that the refresh is the last async operation, ensuring as few 452 + // async operations happen before the result gets a chance to be stored. 453 + let aud = client 454 + .verify_issuer(&metadata.server_metadata, &session_data.token_set.sub) 455 + .await?; 456 + let iss = metadata.server_metadata.issuer.clone(); 457 + 458 + let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>( 459 + client, 460 + &mut session_data.dpop_data, 461 + OAuthRequest::Refresh(RefreshRequestParameters { 462 + grant_type: TokenGrantType::RefreshToken, 463 + refresh_token: refresh_token.clone(), 464 + scope: None, 465 + }), 466 + metadata, 467 + ) 468 + .await?; 469 + 470 + let expires_at = response.expires_in.and_then(|expires_in| { 471 + let now = Datetime::now(); 472 + now.as_ref() 473 + .checked_add_signed(TimeDelta::seconds(expires_in)) 474 + .map(Datetime::new) 475 + }); 476 + 477 + session_data.update_with_tokens(TokenSet { 478 + iss, 479 + sub: session_data.token_set.sub.clone(), 480 + aud: CowStr::Owned(aud.to_smolstr()), 481 + scope: response.scope.map(CowStr::Owned), 482 + access_token: CowStr::Owned(response.access_token), 483 + refresh_token: response.refresh_token.map(CowStr::Owned), 484 + token_type: response.token_type, 485 + expires_at, 486 + }); 487 + 488 + Ok(session_data) 489 + } 490 + 491 + pub async fn exchange_code<'r, T, D>( 492 + client: &T, 493 + data_source: &'r mut D, 494 + code: &str, 495 + verifier: &str, 496 + metadata: &OAuthMetadata, 497 + ) -> Result<TokenSet<'r>> 498 + where 499 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 500 + D: DpopDataSource, 501 + { 502 + let token_response = oauth_request::<OAuthTokenResponse, T, D>( 503 + client, 504 + data_source, 505 + OAuthRequest::Token(TokenRequestParameters { 506 + grant_type: TokenGrantType::AuthorizationCode, 507 + code: code.into(), 508 + redirect_uri: CowStr::Owned( 509 + metadata.client_metadata.redirect_uris[0] 510 + .clone() 511 + .to_smolstr(), 512 + ), // ? 513 + code_verifier: verifier.into(), 514 + }), 515 + metadata, 516 + ) 517 + .await?; 518 + let Some(sub) = token_response.sub else { 519 + return Err(RequestError::TokenVerification); 520 + }; 521 + let sub = Did::new_owned(sub)?; 522 + let iss = metadata.server_metadata.issuer.clone(); 523 + // /!\ IMPORTANT /!\ 524 + // 525 + // The token_response MUST always be valid before the "sub" it contains 526 + // can be trusted (see Atproto's OAuth spec for details). 527 + let aud = client 528 + .verify_issuer(&metadata.server_metadata, &sub) 529 + .await?; 530 + 531 + let expires_at = token_response.expires_in.and_then(|expires_in| { 532 + Datetime::now() 533 + .as_ref() 534 + .checked_add_signed(TimeDelta::seconds(expires_in)) 535 + .map(Datetime::new) 536 + }); 537 + Ok(TokenSet { 538 + iss, 539 + sub, 540 + aud: CowStr::Owned(aud.to_smolstr()), 541 + scope: token_response.scope.map(CowStr::Owned), 542 + access_token: CowStr::Owned(token_response.access_token), 543 + refresh_token: token_response.refresh_token.map(CowStr::Owned), 544 + token_type: token_response.token_type, 545 + expires_at, 546 + }) 547 + } 548 + 549 + pub async fn revoke<'r, T, D>( 550 + client: &T, 551 + data_source: &'r mut D, 552 + token: &str, 553 + metadata: &OAuthMetadata, 554 + ) -> Result<()> 555 + where 556 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 557 + D: DpopDataSource, 558 + { 559 + oauth_request::<(), T, D>( 560 + client, 561 + data_source, 562 + OAuthRequest::Revocation(RevocationRequestParameters { 563 + token: token.into(), 564 + }), 565 + metadata, 566 + ) 567 + .await?; 568 + Ok(()) 569 + } 570 + 571 + pub async fn oauth_request<'de: 'r, 'r, O, T, D>( 572 + client: &T, 573 + data_source: &'r mut D, 574 + request: OAuthRequest<'r>, 575 + metadata: &OAuthMetadata, 576 + ) -> Result<O> 577 + where 578 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 579 + O: serde::de::DeserializeOwned, 580 + D: DpopDataSource, 581 + { 582 + let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { 583 + return Err(RequestError::NoEndpoint(request.name())); 584 + }; 585 + let client_assertions = build_auth( 586 + metadata.keyset.as_ref(), 587 + &metadata.server_metadata, 588 + &metadata.client_metadata, 589 + )?; 590 + let body = match &request { 591 + OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?, 592 + OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?, 593 + OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?, 594 + OAuthRequest::PushedAuthorizationRequest(params) => { 595 + build_oauth_req_body(client_assertions, params)? 596 + } 597 + _ => unimplemented!(), 598 + }; 599 + let req = Request::builder() 600 + .uri(url.to_string()) 601 + .method(Method::POST) 602 + .header("Content-Type", "application/x-www-form-urlencoded") 603 + .body(body.into_bytes())?; 604 + let res = client 605 + .dpop_server_call(data_source) 606 + .send(req) 607 + .await 608 + .map_err(RequestError::DpopClient)?; 609 + if res.status() == request.expected_status() { 610 + let body = res.body(); 611 + if body.is_empty() { 612 + // since an empty body cannot be deserialized, use โ€œnullโ€ temporarily to allow deserialization to `()`. 613 + Ok(serde_json::from_slice(b"null")?) 614 + } else { 615 + let output: O = serde_json::from_slice(body)?; 616 + Ok(output) 617 + } 618 + } else if res.status().is_client_error() { 619 + Err(RequestError::HttpStatusWithBody( 620 + res.status(), 621 + serde_json::from_slice(res.body())?, 622 + )) 623 + } else { 624 + Err(RequestError::HttpStatus(res.status())) 625 + } 626 + } 627 + 628 + #[inline] 629 + fn endpoint_for_req<'a, 'r>( 630 + server_metadata: &'r OAuthAuthorizationServerMetadata<'a>, 631 + request: &'r OAuthRequest, 632 + ) -> Option<&'r CowStr<'a>> { 633 + match request { 634 + OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint), 635 + OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(), 636 + OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(), 637 + OAuthRequest::PushedAuthorizationRequest(_) => server_metadata 638 + .pushed_authorization_request_endpoint 639 + .as_ref(), 640 + } 641 + } 642 + 643 + #[inline] 644 + fn build_oauth_req_body<'a, S>(client_assertions: ClientAuth<'a>, parameters: S) -> Result<String> 645 + where 646 + S: Serialize, 647 + { 648 + Ok(serde_html_form::to_string(RequestPayload { 649 + client_id: client_assertions.client_id, 650 + client_assertion_type: client_assertions.assertion_type, 651 + client_assertion: client_assertions.assertion, 652 + parameters, 653 + })?) 654 + } 655 + 656 + #[derive(Debug, Clone, Default)] 657 + pub struct ClientAuth<'a> { 658 + client_id: CowStr<'a>, 659 + assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER` 660 + assertion: Option<CowStr<'a>>, 661 + } 662 + 663 + impl<'s> ClientAuth<'s> { 664 + pub fn new_id(client_id: CowStr<'s>) -> Self { 665 + Self { 666 + client_id, 667 + assertion_type: None, 668 + assertion: None, 669 + } 670 + } 671 + } 672 + 673 + fn build_auth<'a>( 674 + keyset: Option<&Keyset>, 675 + server_metadata: &OAuthAuthorizationServerMetadata<'a>, 676 + client_metadata: &OAuthClientMetadata<'a>, 677 + ) -> Result<ClientAuth<'a>> { 678 + let method_supported = server_metadata 679 + .token_endpoint_auth_methods_supported 680 + .as_ref(); 681 + 682 + let client_id = client_metadata.client_id.to_cowstr().into_static(); 683 + if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() { 684 + match (*method).as_ref() { 685 + "private_key_jwt" 686 + if method_supported 687 + .as_ref() 688 + .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => 689 + { 690 + if let Some(keyset) = &keyset { 691 + let mut algs = server_metadata 692 + .token_endpoint_auth_signing_alg_values_supported 693 + .clone() 694 + .unwrap_or(vec![FALLBACK_ALG.into()]); 695 + algs.sort_by(compare_algos); 696 + let iat = Utc::now().timestamp(); 697 + return Ok(ClientAuth { 698 + client_id: client_id.clone(), 699 + assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)), 700 + assertion: Some( 701 + keyset.create_jwt( 702 + &algs, 703 + // https://datatracker.ietf.org/doc/html/rfc7523#section-3 704 + RegisteredClaims { 705 + iss: Some(client_id.clone()), 706 + sub: Some(client_id), 707 + aud: Some(RegisteredClaimsAud::Single( 708 + server_metadata.issuer.clone(), 709 + )), 710 + exp: Some(iat + 60), 711 + // "iat" is required and **MUST** be less than one minute 712 + // https://datatracker.ietf.org/doc/html/rfc9101 713 + iat: Some(iat), 714 + // atproto oauth-provider requires "jti" to be present 715 + jti: Some(generate_nonce()), 716 + ..Default::default() 717 + } 718 + .into(), 719 + )?, 720 + ), 721 + }); 722 + } 723 + } 724 + "none" 725 + if method_supported 726 + .as_ref() 727 + .is_some_and(|v| v.contains(&CowStr::new_static("none"))) => 728 + { 729 + return Ok(ClientAuth::new_id(client_id)); 730 + } 731 + _ => {} 732 + } 733 + } 734 + 735 + Err(RequestError::UnsupportedAuthMethod) 736 + }
+342
crates/jacquard-oauth/src/resolver.rs
··· 1 + use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 2 + use http::{Request, StatusCode}; 3 + use jacquard_common::{IntoStatic, error::TransportError}; 4 + use jacquard_common::types::did_doc::DidDocument; 5 + use jacquard_common::types::ident::AtIdentifier; 6 + use jacquard_common::{http_client::HttpClient, types::did::Did}; 7 + use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 8 + use url::Url; 9 + 10 + /// Compare two issuer strings strictly but without spuriously failing on trivial differences. 11 + /// 12 + /// Rules: 13 + /// - Schemes must match exactly. 14 + /// - Hostnames and effective ports must match (treat missing port the same as default port). 15 + /// - Path must match, except that an empty path and `/` are equivalent. 16 + /// - Query/fragment are not considered; if present on either side, the comparison fails. 17 + pub(crate) fn issuer_equivalent(a: &str, b: &str) -> bool { 18 + fn normalize(url: &Url) -> Option<(String, String, u16, String)> { 19 + if url.query().is_some() || url.fragment().is_some() { 20 + return None; 21 + } 22 + let scheme = url.scheme().to_string(); 23 + let host = url.host_str()?.to_string(); 24 + let port = url.port_or_known_default()?; 25 + let path = match url.path() { 26 + "" => "/".to_string(), 27 + "/" => "/".to_string(), 28 + other => other.to_string(), 29 + }; 30 + Some((scheme, host, port, path)) 31 + } 32 + 33 + match (Url::parse(a), Url::parse(b)) { 34 + (Ok(ua), Ok(ub)) => match (normalize(&ua), normalize(&ub)) { 35 + (Some((sa, ha, pa, pa_path)), Some((sb, hb, pb, pb_path))) => { 36 + if sa != sb || ha != hb || pa != pb { 37 + return false; 38 + } 39 + if pa_path == "/" && pb_path == "/" { 40 + return true; 41 + } 42 + pa_path == pb_path 43 + } 44 + _ => false, 45 + }, 46 + _ => a == b, 47 + } 48 + } 49 + 50 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 51 + pub enum ResolverError { 52 + #[error("resource not found")] 53 + #[diagnostic(code(jacquard_oauth::resolver::not_found), help("check the base URL or identifier"))] 54 + NotFound, 55 + #[error("invalid at identifier: {0}")] 56 + #[diagnostic(code(jacquard_oauth::resolver::at_identifier), help("ensure a valid handle or DID was provided"))] 57 + AtIdentifier(String), 58 + #[error("invalid did: {0}")] 59 + #[diagnostic(code(jacquard_oauth::resolver::did), help("ensure DID is correctly formed (did:plc or did:web)"))] 60 + Did(String), 61 + #[error("invalid did document: {0}")] 62 + #[diagnostic(code(jacquard_oauth::resolver::did_document), help("verify the DID document structure and service entries"))] 63 + DidDocument(String), 64 + #[error("protected resource metadata is invalid: {0}")] 65 + #[diagnostic(code(jacquard_oauth::resolver::protected_resource_metadata), help("PDS must advertise an authorization server in its protected resource metadata"))] 66 + ProtectedResourceMetadata(String), 67 + #[error("authorization server metadata is invalid: {0}")] 68 + #[diagnostic(code(jacquard_oauth::resolver::authorization_server_metadata), help("issuer must match and include the PDS resource"))] 69 + AuthorizationServerMetadata(String), 70 + #[error("error resolving identity: {0}")] 71 + #[diagnostic(code(jacquard_oauth::resolver::identity))] 72 + IdentityResolverError(#[from] IdentityError), 73 + #[error("unsupported did method: {0:?}")] 74 + #[diagnostic(code(jacquard_oauth::resolver::unsupported_did_method), help("supported DID methods: did:web, did:plc"))] 75 + UnsupportedDidMethod(Did<'static>), 76 + #[error(transparent)] 77 + #[diagnostic(code(jacquard_oauth::resolver::transport))] 78 + Transport(#[from] TransportError), 79 + #[error("http status: {0:?}")] 80 + #[diagnostic(code(jacquard_oauth::resolver::http_status), help("check well-known paths and server configuration"))] 81 + HttpStatus(StatusCode), 82 + #[error(transparent)] 83 + #[diagnostic(code(jacquard_oauth::resolver::serde_json))] 84 + SerdeJson(#[from] serde_json::Error), 85 + #[error(transparent)] 86 + #[diagnostic(code(jacquard_oauth::resolver::serde_form))] 87 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 88 + #[error(transparent)] 89 + #[diagnostic(code(jacquard_oauth::resolver::url))] 90 + Uri(#[from] url::ParseError), 91 + } 92 + 93 + #[async_trait::async_trait] 94 + pub trait OAuthResolver: IdentityResolver + HttpClient { 95 + async fn verify_issuer( 96 + &self, 97 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 98 + sub: &Did<'_>, 99 + ) -> Result<Url, ResolverError> { 100 + let (metadata, identity) = self.resolve_from_identity(sub).await?; 101 + if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 102 + return Err(ResolverError::AuthorizationServerMetadata( 103 + "issuer mismatch".to_string(), 104 + )); 105 + } 106 + Ok(identity 107 + .pds_endpoint() 108 + .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 109 + } 110 + async fn resolve_oauth( 111 + &self, 112 + input: &str, 113 + ) -> Result< 114 + ( 115 + OAuthAuthorizationServerMetadata<'static>, 116 + Option<DidDocument<'static>>, 117 + ), 118 + ResolverError, 119 + > { 120 + // Allow using an entryway, or PDS url, directly as login input (e.g. 121 + // when the user forgot their handle, or when the handle does not 122 + // resolve to a DID) 123 + Ok(if input.starts_with("https://") { 124 + let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 125 + (self.resolve_from_service(&url).await?, None) 126 + } else { 127 + let (metadata, identity) = self.resolve_from_identity(input).await?; 128 + (metadata, Some(identity)) 129 + }) 130 + } 131 + async fn resolve_from_service( 132 + &self, 133 + input: &Url, 134 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 135 + // Assume first that input is a PDS URL (as required by ATPROTO) 136 + if let Ok(metadata) = self.get_resource_server_metadata(input).await { 137 + return Ok(metadata); 138 + } 139 + // Fallback to trying to fetch as an issuer (Entryway) 140 + self.get_authorization_server_metadata(input).await 141 + } 142 + async fn resolve_from_identity( 143 + &self, 144 + input: &str, 145 + ) -> Result< 146 + ( 147 + OAuthAuthorizationServerMetadata<'static>, 148 + DidDocument<'static>, 149 + ), 150 + ResolverError, 151 + > { 152 + let actor = AtIdentifier::new(input) 153 + .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 154 + let identity = self.resolve_ident_owned(&actor).await?; 155 + if let Some(pds) = &identity.pds_endpoint() { 156 + let metadata = self.get_resource_server_metadata(pds).await?; 157 + Ok((metadata, identity)) 158 + } else { 159 + Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 160 + } 161 + } 162 + async fn get_authorization_server_metadata( 163 + &self, 164 + issuer: &Url, 165 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 166 + let mut md = resolve_authorization_server(self, issuer).await?; 167 + // Normalize issuer string to the input URL representation to avoid slash quirks 168 + md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 169 + Ok(md) 170 + } 171 + async fn get_resource_server_metadata( 172 + &self, 173 + pds: &Url, 174 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 175 + let rs_metadata = resolve_protected_resource_info(self, pds).await?; 176 + // ATPROTO requires one, and only one, authorization server entry 177 + // > That document MUST contain a single item in the authorization_servers array. 178 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 179 + let issuer = match &rs_metadata.authorization_servers { 180 + Some(servers) if !servers.is_empty() => { 181 + if servers.len() > 1 { 182 + return Err(ResolverError::ProtectedResourceMetadata(format!( 183 + "unable to determine authorization server for PDS: {pds}" 184 + ))); 185 + } 186 + &servers[0] 187 + } 188 + _ => { 189 + return Err(ResolverError::ProtectedResourceMetadata(format!( 190 + "no authorization server found for PDS: {pds}" 191 + ))); 192 + } 193 + }; 194 + let as_metadata = self.get_authorization_server_metadata(issuer).await?; 195 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 196 + if let Some(protected_resources) = &as_metadata.protected_resources { 197 + if !protected_resources.contains(&rs_metadata.resource) { 198 + return Err(ResolverError::AuthorizationServerMetadata(format!( 199 + "pds {pds} does not protected by issuer: {issuer}", 200 + ))); 201 + } 202 + } 203 + 204 + // TODO: atproot specific validation? 205 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 206 + // 207 + // eg. 208 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 209 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 210 + // return Err(Error::AuthorizationServerMetadata(format!( 211 + // "authorization server does not support client_id_metadata_document: {issuer}" 212 + // ))); 213 + // } 214 + 215 + Ok(as_metadata) 216 + } 217 + } 218 + 219 + pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 220 + client: &T, 221 + server: &Url, 222 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 223 + let url = server 224 + .join("/.well-known/oauth-authorization-server") 225 + .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 226 + 227 + let req = Request::builder() 228 + .uri(url.to_string()) 229 + .body(Vec::new()) 230 + .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 231 + let res = client 232 + .send_http(req) 233 + .await 234 + .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 235 + if res.status() == StatusCode::OK { 236 + let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 237 + .map_err(ResolverError::SerdeJson)?; 238 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 239 + // Accept semantically equivalent issuer (normalize to the requested URL form) 240 + if issuer_equivalent(&metadata.issuer, server.as_str()) { 241 + metadata.issuer = server.as_str().into(); 242 + Ok(metadata.into_static()) 243 + } else { 244 + Err(ResolverError::AuthorizationServerMetadata(format!( 245 + "invalid issuer: {}", 246 + metadata.issuer 247 + ))) 248 + } 249 + } else { 250 + Err(ResolverError::HttpStatus(res.status())) 251 + } 252 + } 253 + 254 + pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 255 + client: &T, 256 + server: &Url, 257 + ) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 258 + let url = server 259 + .join("/.well-known/oauth-protected-resource") 260 + .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 261 + 262 + let req = Request::builder() 263 + .uri(url.to_string()) 264 + .body(Vec::new()) 265 + .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 266 + let res = client 267 + .send_http(req) 268 + .await 269 + .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 270 + if res.status() == StatusCode::OK { 271 + let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 272 + .map_err(ResolverError::SerdeJson)?; 273 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 274 + // Accept semantically equivalent resource URL (normalize to the requested URL form) 275 + if issuer_equivalent(&metadata.resource, server.as_str()) { 276 + metadata.resource = server.as_str().into(); 277 + Ok(metadata.into_static()) 278 + } else { 279 + Err(ResolverError::AuthorizationServerMetadata(format!( 280 + "invalid resource: {}", 281 + metadata.resource 282 + ))) 283 + } 284 + } else { 285 + Err(ResolverError::HttpStatus(res.status())) 286 + } 287 + } 288 + 289 + #[async_trait::async_trait] 290 + impl OAuthResolver for jacquard_identity::JacquardResolver {} 291 + 292 + #[cfg(test)] 293 + mod tests { 294 + use super::*; 295 + use http::{Request as HttpRequest, Response as HttpResponse, StatusCode}; 296 + use jacquard_common::http_client::HttpClient; 297 + 298 + #[derive(Default, Clone)] 299 + struct MockHttp { 300 + next: std::sync::Arc<tokio::sync::Mutex<Option<HttpResponse<Vec<u8>>>>>, 301 + } 302 + 303 + impl HttpClient for MockHttp { 304 + type Error = std::convert::Infallible; 305 + fn send_http( 306 + &self, 307 + _request: HttpRequest<Vec<u8>>, 308 + ) -> impl core::future::Future< 309 + Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>, 310 + > + Send { 311 + let next = self.next.clone(); 312 + async move { Ok(next.lock().await.take().unwrap()) } 313 + } 314 + } 315 + 316 + #[tokio::test] 317 + async fn authorization_server_http_status() { 318 + let client = MockHttp::default(); 319 + *client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::NOT_FOUND).body(Vec::new()).unwrap()); 320 + let issuer = url::Url::parse("https://issuer").unwrap(); 321 + let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err(); 322 + matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); 323 + } 324 + 325 + #[tokio::test] 326 + async fn authorization_server_bad_json() { 327 + let client = MockHttp::default(); 328 + *client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::OK).body(b"{not json}".to_vec()).unwrap()); 329 + let issuer = url::Url::parse("https://issuer").unwrap(); 330 + let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err(); 331 + matches!(err, ResolverError::SerdeJson(_)); 332 + } 333 + 334 + #[test] 335 + fn issuer_equivalence_rules() { 336 + assert!(super::issuer_equivalent("https://issuer", "https://issuer/")); 337 + assert!(super::issuer_equivalent("https://issuer:443/", "https://issuer/")); 338 + assert!(!super::issuer_equivalent("http://issuer/", "https://issuer/")); 339 + assert!(!super::issuer_equivalent("https://issuer/foo", "https://issuer/")); 340 + assert!(!super::issuer_equivalent("https://issuer/?q=1", "https://issuer/")); 341 + } 342 + }
+2009
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 serde::de::Visitor; 30 + use serde::{Deserialize, Serialize}; 31 + use smol_str::{SmolStr, ToSmolStr}; 32 + 33 + /// Represents an AT Protocol OAuth scope 34 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 35 + pub enum Scope<'s> { 36 + /// Account scope for accessing account information 37 + Account(AccountScope), 38 + /// Identity scope for accessing identity information 39 + Identity(IdentityScope), 40 + /// Blob scope for blob operations with mime type constraints 41 + Blob(BlobScope<'s>), 42 + /// Repository scope for collection operations 43 + Repo(RepoScope<'s>), 44 + /// RPC scope for method access 45 + Rpc(RpcScope<'s>), 46 + /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 47 + Atproto, 48 + /// Transition scope for migration operations 49 + Transition(TransitionScope), 50 + /// OpenID Connect scope - required for OpenID Connect authentication 51 + OpenId, 52 + /// Profile scope - access to user profile information 53 + Profile, 54 + /// Email scope - access to user email address 55 + Email, 56 + } 57 + 58 + impl Serialize for Scope<'_> { 59 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 60 + where 61 + S: serde::Serializer, 62 + { 63 + serializer.serialize_str(&self.to_string_normalized()) 64 + } 65 + } 66 + 67 + impl<'de> Deserialize<'de> for Scope<'_> { 68 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 69 + where 70 + D: serde::Deserializer<'de>, 71 + { 72 + struct ScopeVisitor; 73 + 74 + impl Visitor<'_> for ScopeVisitor { 75 + type Value = Scope<'static>; 76 + 77 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 78 + write!(formatter, "a scope string") 79 + } 80 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 81 + where 82 + E: serde::de::Error, 83 + { 84 + Scope::parse(v) 85 + .map(|s| s.into_static()) 86 + .map_err(|e| serde::de::Error::custom(format!("{:?}", e))) 87 + } 88 + } 89 + deserializer.deserialize_str(ScopeVisitor) 90 + } 91 + } 92 + 93 + impl IntoStatic for Scope<'_> { 94 + type Output = Scope<'static>; 95 + 96 + fn into_static(self) -> Self::Output { 97 + match self { 98 + Scope::Account(scope) => Scope::Account(scope), 99 + Scope::Identity(scope) => Scope::Identity(scope), 100 + Scope::Blob(scope) => Scope::Blob(scope.into_static()), 101 + Scope::Repo(scope) => Scope::Repo(scope.into_static()), 102 + Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 103 + Scope::Atproto => Scope::Atproto, 104 + Scope::Transition(scope) => Scope::Transition(scope), 105 + Scope::OpenId => Scope::OpenId, 106 + Scope::Profile => Scope::Profile, 107 + Scope::Email => Scope::Email, 108 + } 109 + } 110 + } 111 + 112 + /// Account scope attributes 113 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 114 + pub struct AccountScope { 115 + /// The account resource type 116 + pub resource: AccountResource, 117 + /// The action permission level 118 + pub action: AccountAction, 119 + } 120 + 121 + /// Account resource types 122 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 123 + pub enum AccountResource { 124 + /// Email access 125 + Email, 126 + /// Repository access 127 + Repo, 128 + /// Status access 129 + Status, 130 + } 131 + 132 + /// Account action permissions 133 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 134 + pub enum AccountAction { 135 + /// Read-only access 136 + Read, 137 + /// Management access (includes read) 138 + Manage, 139 + } 140 + 141 + /// Identity scope attributes 142 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 143 + pub enum IdentityScope { 144 + /// Handle access 145 + Handle, 146 + /// All identity access (wildcard) 147 + All, 148 + } 149 + 150 + /// Transition scope types 151 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 152 + pub enum TransitionScope { 153 + /// Generic transition operations 154 + Generic, 155 + /// Email transition operations 156 + Email, 157 + } 158 + 159 + /// Blob scope with mime type constraints 160 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 161 + pub struct BlobScope<'s> { 162 + /// Accepted mime types 163 + pub accept: BTreeSet<MimePattern<'s>>, 164 + } 165 + 166 + impl IntoStatic for BlobScope<'_> { 167 + type Output = BlobScope<'static>; 168 + 169 + fn into_static(self) -> Self::Output { 170 + BlobScope { 171 + accept: self.accept.into_iter().map(|p| p.into_static()).collect(), 172 + } 173 + } 174 + } 175 + 176 + /// MIME type pattern for blob scope 177 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 178 + pub enum MimePattern<'s> { 179 + /// Match all types 180 + All, 181 + /// Match all subtypes of a type (e.g., "image/*") 182 + TypeWildcard(CowStr<'s>), 183 + /// Exact mime type match 184 + Exact(CowStr<'s>), 185 + } 186 + 187 + impl IntoStatic for MimePattern<'_> { 188 + type Output = MimePattern<'static>; 189 + 190 + fn into_static(self) -> Self::Output { 191 + match self { 192 + MimePattern::All => MimePattern::All, 193 + MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()), 194 + MimePattern::Exact(s) => MimePattern::Exact(s.into_static()), 195 + } 196 + } 197 + } 198 + 199 + /// Repository scope with collection and action constraints 200 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 201 + pub struct RepoScope<'s> { 202 + /// Collection NSID or wildcard 203 + pub collection: RepoCollection<'s>, 204 + /// Allowed actions 205 + pub actions: BTreeSet<RepoAction>, 206 + } 207 + 208 + impl IntoStatic for RepoScope<'_> { 209 + type Output = RepoScope<'static>; 210 + 211 + fn into_static(self) -> Self::Output { 212 + RepoScope { 213 + collection: self.collection.into_static(), 214 + actions: self.actions, 215 + } 216 + } 217 + } 218 + 219 + /// Repository collection identifier 220 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 221 + pub enum RepoCollection<'s> { 222 + /// All collections (wildcard) 223 + All, 224 + /// Specific collection NSID 225 + Nsid(Nsid<'s>), 226 + } 227 + 228 + impl IntoStatic for RepoCollection<'_> { 229 + type Output = RepoCollection<'static>; 230 + 231 + fn into_static(self) -> Self::Output { 232 + match self { 233 + RepoCollection::All => RepoCollection::All, 234 + RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), 235 + } 236 + } 237 + } 238 + 239 + /// Repository actions 240 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 241 + pub enum RepoAction { 242 + /// Create records 243 + Create, 244 + /// Update records 245 + Update, 246 + /// Delete records 247 + Delete, 248 + } 249 + 250 + /// RPC scope with lexicon method and audience constraints 251 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 252 + pub struct RpcScope<'s> { 253 + /// Lexicon methods (NSIDs or wildcard) 254 + pub lxm: BTreeSet<RpcLexicon<'s>>, 255 + /// Audiences (DIDs or wildcard) 256 + pub aud: BTreeSet<RpcAudience<'s>>, 257 + } 258 + 259 + impl IntoStatic for RpcScope<'_> { 260 + type Output = RpcScope<'static>; 261 + 262 + fn into_static(self) -> Self::Output { 263 + RpcScope { 264 + lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(), 265 + aud: self.aud.into_iter().map(|s| s.into_static()).collect(), 266 + } 267 + } 268 + } 269 + 270 + /// RPC lexicon identifier 271 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 272 + pub enum RpcLexicon<'s> { 273 + /// All lexicons (wildcard) 274 + All, 275 + /// Specific lexicon NSID 276 + Nsid(Nsid<'s>), 277 + } 278 + 279 + impl IntoStatic for RpcLexicon<'_> { 280 + type Output = RpcLexicon<'static>; 281 + 282 + fn into_static(self) -> Self::Output { 283 + match self { 284 + RpcLexicon::All => RpcLexicon::All, 285 + RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()), 286 + } 287 + } 288 + } 289 + 290 + /// RPC audience identifier 291 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 292 + pub enum RpcAudience<'s> { 293 + /// All audiences (wildcard) 294 + All, 295 + /// Specific DID 296 + Did(Did<'s>), 297 + } 298 + 299 + impl IntoStatic for RpcAudience<'_> { 300 + type Output = RpcAudience<'static>; 301 + 302 + fn into_static(self) -> Self::Output { 303 + match self { 304 + RpcAudience::All => RpcAudience::All, 305 + RpcAudience::Did(did) => RpcAudience::Did(did.into_static()), 306 + } 307 + } 308 + } 309 + 310 + impl<'s> Scope<'s> { 311 + /// Parse multiple space-separated scopes from a string 312 + /// 313 + /// # Examples 314 + /// ``` 315 + /// # use jacquard_oauth::scopes::Scope; 316 + /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 317 + /// assert_eq!(scopes.len(), 2); 318 + /// ``` 319 + pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> { 320 + if s.trim().is_empty() { 321 + return Ok(Vec::new()); 322 + } 323 + 324 + let mut scopes = Vec::new(); 325 + for scope_str in s.split_whitespace() { 326 + scopes.push(Self::parse(scope_str)?); 327 + } 328 + 329 + Ok(scopes) 330 + } 331 + 332 + /// Parse multiple space-separated scopes and return the minimal set needed 333 + /// 334 + /// This method removes duplicate scopes and scopes that are already granted 335 + /// by other scopes in the list, returning only the minimal set of scopes needed. 336 + /// 337 + /// # Examples 338 + /// ``` 339 + /// # use jacquard_oauth::scopes::Scope; 340 + /// // repo:* grants repo:foo.bar, so only repo:* is kept 341 + /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 342 + /// assert_eq!(scopes.len(), 2); // atproto and repo:* 343 + /// ``` 344 + pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> { 345 + let all_scopes = Self::parse_multiple(s)?; 346 + 347 + if all_scopes.is_empty() { 348 + return Ok(Vec::new()); 349 + } 350 + 351 + let mut result: Vec<Self> = Vec::new(); 352 + 353 + for scope in all_scopes { 354 + // Check if this scope is already granted by something in the result 355 + let mut is_granted = false; 356 + for existing in &result { 357 + if existing.grants(&scope) && existing != &scope { 358 + is_granted = true; 359 + break; 360 + } 361 + } 362 + 363 + if is_granted { 364 + continue; // Skip this scope, it's already covered 365 + } 366 + 367 + // Check if this scope grants any existing scopes in the result 368 + let mut indices_to_remove = Vec::new(); 369 + for (i, existing) in result.iter().enumerate() { 370 + if scope.grants(existing) && &scope != existing { 371 + indices_to_remove.push(i); 372 + } 373 + } 374 + 375 + // Remove scopes that are granted by the new scope (in reverse order to maintain indices) 376 + for i in indices_to_remove.into_iter().rev() { 377 + result.remove(i); 378 + } 379 + 380 + // Add the new scope if it's not a duplicate 381 + if !result.contains(&scope) { 382 + result.push(scope); 383 + } 384 + } 385 + 386 + Ok(result) 387 + } 388 + 389 + /// Serialize a list of scopes into a space-separated OAuth scopes string 390 + /// 391 + /// The scopes are sorted alphabetically by their string representation to ensure 392 + /// consistent output regardless of input order. 393 + /// 394 + /// # Examples 395 + /// ``` 396 + /// # use jacquard_oauth::scopes::Scope; 397 + /// let scopes = vec![ 398 + /// Scope::parse("repo:*").unwrap(), 399 + /// Scope::parse("atproto").unwrap(), 400 + /// Scope::parse("account:email").unwrap(), 401 + /// ]; 402 + /// let result = Scope::serialize_multiple(&scopes); 403 + /// assert_eq!(result, "account:email atproto repo:*"); 404 + /// ``` 405 + pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> { 406 + if scopes.is_empty() { 407 + return CowStr::default(); 408 + } 409 + 410 + let mut serialized: Vec<String> = scopes 411 + .iter() 412 + .map(|scope| scope.to_string_normalized()) 413 + .collect(); 414 + 415 + serialized.sort(); 416 + serialized.join(" ").into() 417 + } 418 + 419 + /// Remove a scope from a list of scopes 420 + /// 421 + /// Returns a new vector with all instances of the specified scope removed. 422 + /// If the scope doesn't exist in the list, returns a copy of the original list. 423 + /// 424 + /// # Examples 425 + /// ``` 426 + /// # use jacquard_oauth::scopes::Scope; 427 + /// let scopes = vec![ 428 + /// Scope::parse("repo:*").unwrap(), 429 + /// Scope::parse("atproto").unwrap(), 430 + /// Scope::parse("account:email").unwrap(), 431 + /// ]; 432 + /// let to_remove = Scope::parse("atproto").unwrap(); 433 + /// let result = Scope::remove_scope(&scopes, &to_remove); 434 + /// assert_eq!(result.len(), 2); 435 + /// assert!(!result.contains(&to_remove)); 436 + /// ``` 437 + pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> { 438 + scopes 439 + .iter() 440 + .filter(|s| *s != scope_to_remove) 441 + .cloned() 442 + .collect() 443 + } 444 + 445 + /// Parse a scope from a string 446 + pub fn parse(s: &'s str) -> Result<Self, ParseError> { 447 + // Determine the prefix first by checking for known prefixes 448 + let prefixes = [ 449 + "account", 450 + "identity", 451 + "blob", 452 + "repo", 453 + "rpc", 454 + "atproto", 455 + "transition", 456 + "openid", 457 + "profile", 458 + "email", 459 + ]; 460 + let mut found_prefix = None; 461 + let mut suffix = None; 462 + 463 + for prefix in &prefixes { 464 + if let Some(remainder) = s.strip_prefix(prefix) 465 + && (remainder.is_empty() 466 + || remainder.starts_with(':') 467 + || remainder.starts_with('?')) 468 + { 469 + found_prefix = Some(*prefix); 470 + if let Some(stripped) = remainder.strip_prefix(':') { 471 + suffix = Some(stripped); 472 + } else if remainder.starts_with('?') { 473 + suffix = Some(remainder); 474 + } else { 475 + suffix = None; 476 + } 477 + break; 478 + } 479 + } 480 + 481 + let prefix = found_prefix.ok_or_else(|| { 482 + // If no known prefix found, extract what looks like a prefix for error reporting 483 + let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); 484 + ParseError::UnknownPrefix(s[..end].to_string()) 485 + })?; 486 + 487 + match prefix { 488 + "account" => Self::parse_account(suffix), 489 + "identity" => Self::parse_identity(suffix), 490 + "blob" => Self::parse_blob(suffix), 491 + "repo" => Self::parse_repo(suffix), 492 + "rpc" => Self::parse_rpc(suffix), 493 + "atproto" => Self::parse_atproto(suffix), 494 + "transition" => Self::parse_transition(suffix), 495 + "openid" => Self::parse_openid(suffix), 496 + "profile" => Self::parse_profile(suffix), 497 + "email" => Self::parse_email(suffix), 498 + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), 499 + } 500 + } 501 + 502 + fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> { 503 + let (resource_str, params) = match suffix { 504 + Some(s) => { 505 + if let Some(pos) = s.find('?') { 506 + (&s[..pos], Some(&s[pos + 1..])) 507 + } else { 508 + (s, None) 509 + } 510 + } 511 + None => return Err(ParseError::MissingResource), 512 + }; 513 + 514 + let resource = match resource_str { 515 + "email" => AccountResource::Email, 516 + "repo" => AccountResource::Repo, 517 + "status" => AccountResource::Status, 518 + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), 519 + }; 520 + 521 + let action = if let Some(params) = params { 522 + let parsed_params = parse_query_string(params); 523 + match parsed_params 524 + .get("action") 525 + .and_then(|v| v.first()) 526 + .map(|s| s.as_ref()) 527 + { 528 + Some("read") => AccountAction::Read, 529 + Some("manage") => AccountAction::Manage, 530 + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), 531 + None => AccountAction::Read, 532 + } 533 + } else { 534 + AccountAction::Read 535 + }; 536 + 537 + Ok(Scope::Account(AccountScope { resource, action })) 538 + } 539 + 540 + fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> { 541 + let scope = match suffix { 542 + Some("handle") => IdentityScope::Handle, 543 + Some("*") => IdentityScope::All, 544 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 545 + None => return Err(ParseError::MissingResource), 546 + }; 547 + 548 + Ok(Scope::Identity(scope)) 549 + } 550 + 551 + fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> { 552 + let mut accept = BTreeSet::new(); 553 + 554 + match suffix { 555 + Some(s) if s.starts_with('?') => { 556 + let params = parse_query_string(&s[1..]); 557 + if let Some(values) = params.get("accept") { 558 + for value in values { 559 + accept.insert(MimePattern::from_str(value)?); 560 + } 561 + } 562 + } 563 + Some(s) => { 564 + accept.insert(MimePattern::from_str(s)?); 565 + } 566 + None => { 567 + accept.insert(MimePattern::All); 568 + } 569 + } 570 + 571 + if accept.is_empty() { 572 + accept.insert(MimePattern::All); 573 + } 574 + 575 + Ok(Scope::Blob(BlobScope { accept })) 576 + } 577 + 578 + fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> { 579 + let (collection_str, params) = match suffix { 580 + Some(s) => { 581 + if let Some(pos) = s.find('?') { 582 + (Some(&s[..pos]), Some(&s[pos + 1..])) 583 + } else { 584 + (Some(s), None) 585 + } 586 + } 587 + None => (None, None), 588 + }; 589 + 590 + let collection = match collection_str { 591 + Some("*") | None => RepoCollection::All, 592 + Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?), 593 + }; 594 + 595 + let mut actions = BTreeSet::new(); 596 + if let Some(params) = params { 597 + let parsed_params = parse_query_string(params); 598 + if let Some(values) = parsed_params.get("action") { 599 + for value in values { 600 + match value.as_ref() { 601 + "create" => { 602 + actions.insert(RepoAction::Create); 603 + } 604 + "update" => { 605 + actions.insert(RepoAction::Update); 606 + } 607 + "delete" => { 608 + actions.insert(RepoAction::Delete); 609 + } 610 + "*" => { 611 + actions.insert(RepoAction::Create); 612 + actions.insert(RepoAction::Update); 613 + actions.insert(RepoAction::Delete); 614 + } 615 + other => return Err(ParseError::InvalidAction(other.to_string())), 616 + } 617 + } 618 + } 619 + } 620 + 621 + if actions.is_empty() { 622 + actions.insert(RepoAction::Create); 623 + actions.insert(RepoAction::Update); 624 + actions.insert(RepoAction::Delete); 625 + } 626 + 627 + Ok(Scope::Repo(RepoScope { 628 + collection, 629 + actions, 630 + })) 631 + } 632 + 633 + fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> { 634 + let mut lxm = BTreeSet::new(); 635 + let mut aud = BTreeSet::new(); 636 + 637 + match suffix { 638 + Some("*") => { 639 + lxm.insert(RpcLexicon::All); 640 + aud.insert(RpcAudience::All); 641 + } 642 + Some(s) if s.starts_with('?') => { 643 + let params = parse_query_string(&s[1..]); 644 + 645 + if let Some(values) = params.get("lxm") { 646 + for value in values { 647 + if value.as_ref() == "*" { 648 + lxm.insert(RpcLexicon::All); 649 + } else { 650 + lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static())); 651 + } 652 + } 653 + } 654 + 655 + if let Some(values) = params.get("aud") { 656 + for value in values { 657 + if value.as_ref() == "*" { 658 + aud.insert(RpcAudience::All); 659 + } else { 660 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 661 + } 662 + } 663 + } 664 + } 665 + Some(s) => { 666 + // Check if there's a query string in the suffix 667 + if let Some(pos) = s.find('?') { 668 + let nsid = &s[..pos]; 669 + let params = parse_query_string(&s[pos + 1..]); 670 + 671 + lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static())); 672 + 673 + if let Some(values) = params.get("aud") { 674 + for value in values { 675 + if value.as_ref() == "*" { 676 + aud.insert(RpcAudience::All); 677 + } else { 678 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 679 + } 680 + } 681 + } 682 + } else { 683 + lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static())); 684 + } 685 + } 686 + None => {} 687 + } 688 + 689 + if lxm.is_empty() { 690 + lxm.insert(RpcLexicon::All); 691 + } 692 + if aud.is_empty() { 693 + aud.insert(RpcAudience::All); 694 + } 695 + 696 + Ok(Scope::Rpc(RpcScope { lxm, aud })) 697 + } 698 + 699 + fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 700 + if suffix.is_some() { 701 + return Err(ParseError::InvalidResource( 702 + "atproto scope does not accept suffixes".to_string(), 703 + )); 704 + } 705 + Ok(Scope::Atproto) 706 + } 707 + 708 + fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 709 + let scope = match suffix { 710 + Some("generic") => TransitionScope::Generic, 711 + Some("email") => TransitionScope::Email, 712 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 713 + None => return Err(ParseError::MissingResource), 714 + }; 715 + 716 + Ok(Scope::Transition(scope)) 717 + } 718 + 719 + fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 720 + if suffix.is_some() { 721 + return Err(ParseError::InvalidResource( 722 + "openid scope does not accept suffixes".to_string(), 723 + )); 724 + } 725 + Ok(Scope::OpenId) 726 + } 727 + 728 + fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> { 729 + if suffix.is_some() { 730 + return Err(ParseError::InvalidResource( 731 + "profile scope does not accept suffixes".to_string(), 732 + )); 733 + } 734 + Ok(Scope::Profile) 735 + } 736 + 737 + fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> { 738 + if suffix.is_some() { 739 + return Err(ParseError::InvalidResource( 740 + "email scope does not accept suffixes".to_string(), 741 + )); 742 + } 743 + Ok(Scope::Email) 744 + } 745 + 746 + /// Convert the scope to its normalized string representation 747 + pub fn to_string_normalized(&self) -> String { 748 + match self { 749 + Scope::Account(scope) => { 750 + let resource = match scope.resource { 751 + AccountResource::Email => "email", 752 + AccountResource::Repo => "repo", 753 + AccountResource::Status => "status", 754 + }; 755 + 756 + match scope.action { 757 + AccountAction::Read => format!("account:{}", resource), 758 + AccountAction::Manage => format!("account:{}?action=manage", resource), 759 + } 760 + } 761 + Scope::Identity(scope) => match scope { 762 + IdentityScope::Handle => "identity:handle".to_string(), 763 + IdentityScope::All => "identity:*".to_string(), 764 + }, 765 + Scope::Blob(scope) => { 766 + if scope.accept.len() == 1 { 767 + if let Some(pattern) = scope.accept.iter().next() { 768 + match pattern { 769 + MimePattern::All => "blob:*/*".to_string(), 770 + MimePattern::TypeWildcard(t) => format!("blob:{}/*", t), 771 + MimePattern::Exact(mime) => format!("blob:{}", mime), 772 + } 773 + } else { 774 + "blob:*/*".to_string() 775 + } 776 + } else { 777 + let mut params = Vec::new(); 778 + for pattern in &scope.accept { 779 + match pattern { 780 + MimePattern::All => params.push("accept=*/*".to_string()), 781 + MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)), 782 + MimePattern::Exact(mime) => params.push(format!("accept={}", mime)), 783 + } 784 + } 785 + params.sort(); 786 + format!("blob?{}", params.join("&")) 787 + } 788 + } 789 + Scope::Repo(scope) => { 790 + let collection = match &scope.collection { 791 + RepoCollection::All => "*", 792 + RepoCollection::Nsid(nsid) => nsid, 793 + }; 794 + 795 + if scope.actions.len() == 3 { 796 + format!("repo:{}", collection) 797 + } else { 798 + let mut params = Vec::new(); 799 + for action in &scope.actions { 800 + match action { 801 + RepoAction::Create => params.push("action=create"), 802 + RepoAction::Update => params.push("action=update"), 803 + RepoAction::Delete => params.push("action=delete"), 804 + } 805 + } 806 + format!("repo:{}?{}", collection, params.join("&")) 807 + } 808 + } 809 + Scope::Rpc(scope) => { 810 + if scope.lxm.len() == 1 811 + && scope.lxm.contains(&RpcLexicon::All) 812 + && scope.aud.len() == 1 813 + && scope.aud.contains(&RpcAudience::All) 814 + { 815 + "rpc:*".to_string() 816 + } else if scope.lxm.len() == 1 817 + && scope.aud.len() == 1 818 + && scope.aud.contains(&RpcAudience::All) 819 + { 820 + if let Some(lxm) = scope.lxm.iter().next() { 821 + match lxm { 822 + RpcLexicon::All => "rpc:*".to_string(), 823 + RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 824 + } 825 + } else { 826 + "rpc:*".to_string() 827 + } 828 + } else { 829 + let mut params = Vec::new(); 830 + 831 + for lxm in &scope.lxm { 832 + match lxm { 833 + RpcLexicon::All => params.push("lxm=*".to_string()), 834 + RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)), 835 + } 836 + } 837 + 838 + for aud in &scope.aud { 839 + match aud { 840 + RpcAudience::All => params.push("aud=*".to_string()), 841 + RpcAudience::Did(did) => params.push(format!("aud={}", did)), 842 + } 843 + } 844 + 845 + params.sort(); 846 + 847 + if params.is_empty() { 848 + "rpc:*".to_string() 849 + } else { 850 + format!("rpc?{}", params.join("&")) 851 + } 852 + } 853 + } 854 + Scope::Atproto => "atproto".to_string(), 855 + Scope::Transition(scope) => match scope { 856 + TransitionScope::Generic => "transition:generic".to_string(), 857 + TransitionScope::Email => "transition:email".to_string(), 858 + }, 859 + Scope::OpenId => "openid".to_string(), 860 + Scope::Profile => "profile".to_string(), 861 + Scope::Email => "email".to_string(), 862 + } 863 + } 864 + 865 + /// Check if this scope grants the permissions of another scope 866 + pub fn grants(&self, other: &Scope) -> bool { 867 + match (self, other) { 868 + // Atproto only grants itself (it's a required scope, not a permission grant) 869 + (Scope::Atproto, Scope::Atproto) => true, 870 + (Scope::Atproto, _) => false, 871 + // Nothing else grants atproto 872 + (_, Scope::Atproto) => false, 873 + // Transition scopes only grant themselves 874 + (Scope::Transition(a), Scope::Transition(b)) => a == b, 875 + // Other scopes don't grant transition scopes 876 + (_, Scope::Transition(_)) => false, 877 + (Scope::Transition(_), _) => false, 878 + // OpenID Connect scopes only grant themselves 879 + (Scope::OpenId, Scope::OpenId) => true, 880 + (Scope::OpenId, _) => false, 881 + (_, Scope::OpenId) => false, 882 + (Scope::Profile, Scope::Profile) => true, 883 + (Scope::Profile, _) => false, 884 + (_, Scope::Profile) => false, 885 + (Scope::Email, Scope::Email) => true, 886 + (Scope::Email, _) => false, 887 + (_, Scope::Email) => false, 888 + (Scope::Account(a), Scope::Account(b)) => { 889 + a.resource == b.resource 890 + && matches!( 891 + (a.action, b.action), 892 + (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read) 893 + ) 894 + } 895 + (Scope::Identity(a), Scope::Identity(b)) => matches!( 896 + (a, b), 897 + (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle) 898 + ), 899 + (Scope::Blob(a), Scope::Blob(b)) => { 900 + for b_pattern in &b.accept { 901 + let mut granted = false; 902 + for a_pattern in &a.accept { 903 + if a_pattern.grants(b_pattern) { 904 + granted = true; 905 + break; 906 + } 907 + } 908 + if !granted { 909 + return false; 910 + } 911 + } 912 + true 913 + } 914 + (Scope::Repo(a), Scope::Repo(b)) => { 915 + let collection_match = match (&a.collection, &b.collection) { 916 + (RepoCollection::All, _) => true, 917 + (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { 918 + a_nsid == b_nsid 919 + } 920 + _ => false, 921 + }; 922 + 923 + if !collection_match { 924 + return false; 925 + } 926 + 927 + b.actions.is_subset(&a.actions) || a.actions.len() == 3 928 + } 929 + (Scope::Rpc(a), Scope::Rpc(b)) => { 930 + let lxm_match = if a.lxm.contains(&RpcLexicon::All) { 931 + true 932 + } else { 933 + b.lxm.iter().all(|b_lxm| match b_lxm { 934 + RpcLexicon::All => false, 935 + RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm), 936 + }) 937 + }; 938 + 939 + let aud_match = if a.aud.contains(&RpcAudience::All) { 940 + true 941 + } else { 942 + b.aud.iter().all(|b_aud| match b_aud { 943 + RpcAudience::All => false, 944 + RpcAudience::Did(_) => a.aud.contains(b_aud), 945 + }) 946 + }; 947 + 948 + lxm_match && aud_match 949 + } 950 + _ => false, 951 + } 952 + } 953 + } 954 + 955 + impl MimePattern<'_> { 956 + fn grants(&self, other: &MimePattern) -> bool { 957 + match (self, other) { 958 + (MimePattern::All, _) => true, 959 + (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { 960 + a_type == b_type 961 + } 962 + (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => { 963 + b_mime.starts_with(&format!("{}/", a_type)) 964 + } 965 + (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b, 966 + _ => false, 967 + } 968 + } 969 + } 970 + 971 + impl FromStr for MimePattern<'_> { 972 + type Err = ParseError; 973 + 974 + fn from_str(s: &str) -> Result<Self, Self::Err> { 975 + if s == "*/*" { 976 + Ok(MimePattern::All) 977 + } else if let Some(stripped) = s.strip_suffix("/*") { 978 + Ok(MimePattern::TypeWildcard(CowStr::Owned( 979 + stripped.to_smolstr(), 980 + ))) 981 + } else if s.contains('/') { 982 + Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr()))) 983 + } else { 984 + Err(ParseError::InvalidMimeType(s.to_string())) 985 + } 986 + } 987 + } 988 + 989 + impl FromStr for Scope<'_> { 990 + type Err = ParseError; 991 + 992 + fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> { 993 + match Scope::parse(s) { 994 + Ok(parsed) => Ok(parsed.into_static()), 995 + Err(e) => Err(e), 996 + } 997 + } 998 + } 999 + 1000 + impl fmt::Display for Scope<'_> { 1001 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1002 + write!(f, "{}", self.to_string_normalized()) 1003 + } 1004 + } 1005 + 1006 + /// Parse a query string into a map of keys to lists of values 1007 + fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> { 1008 + let mut params = BTreeMap::new(); 1009 + 1010 + for pair in query.split('&') { 1011 + if let Some(pos) = pair.find('=') { 1012 + let key = &pair[..pos]; 1013 + let value = &pair[pos + 1..]; 1014 + params 1015 + .entry(key.to_smolstr()) 1016 + .or_insert_with(Vec::new) 1017 + .push(CowStr::Owned(value.to_smolstr())); 1018 + } 1019 + } 1020 + 1021 + params 1022 + } 1023 + 1024 + /// Error type for scope parsing 1025 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1026 + pub enum ParseError { 1027 + /// Unknown scope prefix 1028 + UnknownPrefix(String), 1029 + /// Missing required resource 1030 + MissingResource, 1031 + /// Invalid resource type 1032 + InvalidResource(String), 1033 + /// Invalid action type 1034 + InvalidAction(String), 1035 + /// Invalid MIME type 1036 + InvalidMimeType(String), 1037 + ParseError(#[from] AtStrError), 1038 + } 1039 + 1040 + impl fmt::Display for ParseError { 1041 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1042 + match self { 1043 + ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), 1044 + ParseError::MissingResource => write!(f, "Missing required resource"), 1045 + ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), 1046 + ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), 1047 + ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), 1048 + ParseError::ParseError(err) => write!(f, "Parse error: {}", err), 1049 + } 1050 + } 1051 + } 1052 + 1053 + #[cfg(test)] 1054 + mod tests { 1055 + use super::*; 1056 + 1057 + #[test] 1058 + fn test_account_scope_parsing() { 1059 + let scope = Scope::parse("account:email").unwrap(); 1060 + assert_eq!( 1061 + scope, 1062 + Scope::Account(AccountScope { 1063 + resource: AccountResource::Email, 1064 + action: AccountAction::Read, 1065 + }) 1066 + ); 1067 + 1068 + let scope = Scope::parse("account:repo?action=manage").unwrap(); 1069 + assert_eq!( 1070 + scope, 1071 + Scope::Account(AccountScope { 1072 + resource: AccountResource::Repo, 1073 + action: AccountAction::Manage, 1074 + }) 1075 + ); 1076 + 1077 + let scope = Scope::parse("account:status?action=read").unwrap(); 1078 + assert_eq!( 1079 + scope, 1080 + Scope::Account(AccountScope { 1081 + resource: AccountResource::Status, 1082 + action: AccountAction::Read, 1083 + }) 1084 + ); 1085 + } 1086 + 1087 + #[test] 1088 + fn test_identity_scope_parsing() { 1089 + let scope = Scope::parse("identity:handle").unwrap(); 1090 + assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); 1091 + 1092 + let scope = Scope::parse("identity:*").unwrap(); 1093 + assert_eq!(scope, Scope::Identity(IdentityScope::All)); 1094 + } 1095 + 1096 + #[test] 1097 + fn test_blob_scope_parsing() { 1098 + let scope = Scope::parse("blob:*/*").unwrap(); 1099 + let mut accept = BTreeSet::new(); 1100 + accept.insert(MimePattern::All); 1101 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1102 + 1103 + let scope = Scope::parse("blob:image/png").unwrap(); 1104 + let mut accept = BTreeSet::new(); 1105 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1106 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1107 + 1108 + let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); 1109 + let mut accept = BTreeSet::new(); 1110 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1111 + accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg"))); 1112 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1113 + 1114 + let scope = Scope::parse("blob:image/*").unwrap(); 1115 + let mut accept = BTreeSet::new(); 1116 + accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image"))); 1117 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1118 + } 1119 + 1120 + #[test] 1121 + fn test_repo_scope_parsing() { 1122 + let scope = Scope::parse("repo:*?action=create").unwrap(); 1123 + let mut actions = BTreeSet::new(); 1124 + actions.insert(RepoAction::Create); 1125 + assert_eq!( 1126 + scope, 1127 + Scope::Repo(RepoScope { 1128 + collection: RepoCollection::All, 1129 + actions, 1130 + }) 1131 + ); 1132 + 1133 + let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(); 1134 + let mut actions = BTreeSet::new(); 1135 + actions.insert(RepoAction::Create); 1136 + actions.insert(RepoAction::Update); 1137 + assert_eq!( 1138 + scope, 1139 + Scope::Repo(RepoScope { 1140 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1141 + actions, 1142 + }) 1143 + ); 1144 + 1145 + let scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1146 + let mut actions = BTreeSet::new(); 1147 + actions.insert(RepoAction::Create); 1148 + actions.insert(RepoAction::Update); 1149 + actions.insert(RepoAction::Delete); 1150 + assert_eq!( 1151 + scope, 1152 + Scope::Repo(RepoScope { 1153 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1154 + actions, 1155 + }) 1156 + ); 1157 + } 1158 + 1159 + #[test] 1160 + fn test_rpc_scope_parsing() { 1161 + let scope = Scope::parse("rpc:*").unwrap(); 1162 + let mut lxm = BTreeSet::new(); 1163 + let mut aud = BTreeSet::new(); 1164 + lxm.insert(RpcLexicon::All); 1165 + aud.insert(RpcAudience::All); 1166 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1167 + 1168 + let scope = Scope::parse("rpc:com.example.service").unwrap(); 1169 + let mut lxm = BTreeSet::new(); 1170 + let mut aud = BTreeSet::new(); 1171 + lxm.insert(RpcLexicon::Nsid( 1172 + Nsid::new_static("com.example.service").unwrap(), 1173 + )); 1174 + aud.insert(RpcAudience::All); 1175 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1176 + 1177 + let scope = 1178 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(); 1179 + let mut lxm = BTreeSet::new(); 1180 + let mut aud = BTreeSet::new(); 1181 + lxm.insert(RpcLexicon::Nsid( 1182 + Nsid::new_static("com.example.service").unwrap(), 1183 + )); 1184 + aud.insert(RpcAudience::Did( 1185 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1186 + )); 1187 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1188 + 1189 + let scope = 1190 + Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g") 1191 + .unwrap(); 1192 + let mut lxm = BTreeSet::new(); 1193 + let mut aud = BTreeSet::new(); 1194 + lxm.insert(RpcLexicon::Nsid( 1195 + Nsid::new_static("com.example.method1").unwrap(), 1196 + )); 1197 + lxm.insert(RpcLexicon::Nsid( 1198 + Nsid::new_static("com.example.method2").unwrap(), 1199 + )); 1200 + aud.insert(RpcAudience::Did( 1201 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1202 + )); 1203 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1204 + } 1205 + 1206 + #[test] 1207 + fn test_scope_normalization() { 1208 + let tests = vec![ 1209 + ("account:email", "account:email"), 1210 + ("account:email?action=read", "account:email"), 1211 + ("account:email?action=manage", "account:email?action=manage"), 1212 + ("blob:image/png", "blob:image/png"), 1213 + ( 1214 + "blob?accept=image/jpeg&accept=image/png", 1215 + "blob?accept=image/jpeg&accept=image/png", 1216 + ), 1217 + ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"), 1218 + ( 1219 + "repo:app.bsky.feed.post?action=create", 1220 + "repo:app.bsky.feed.post?action=create", 1221 + ), 1222 + ("rpc:*", "rpc:*"), 1223 + ]; 1224 + 1225 + for (input, expected) in tests { 1226 + let scope = Scope::parse(input).unwrap(); 1227 + assert_eq!(scope.to_string_normalized(), expected); 1228 + } 1229 + } 1230 + 1231 + #[test] 1232 + fn test_account_scope_grants() { 1233 + let manage = Scope::parse("account:email?action=manage").unwrap(); 1234 + let read = Scope::parse("account:email?action=read").unwrap(); 1235 + let other_read = Scope::parse("account:repo?action=read").unwrap(); 1236 + 1237 + assert!(manage.grants(&read)); 1238 + assert!(manage.grants(&manage)); 1239 + assert!(!read.grants(&manage)); 1240 + assert!(read.grants(&read)); 1241 + assert!(!read.grants(&other_read)); 1242 + } 1243 + 1244 + #[test] 1245 + fn test_identity_scope_grants() { 1246 + let all = Scope::parse("identity:*").unwrap(); 1247 + let handle = Scope::parse("identity:handle").unwrap(); 1248 + 1249 + assert!(all.grants(&handle)); 1250 + assert!(all.grants(&all)); 1251 + assert!(!handle.grants(&all)); 1252 + assert!(handle.grants(&handle)); 1253 + } 1254 + 1255 + #[test] 1256 + fn test_blob_scope_grants() { 1257 + let all = Scope::parse("blob:*/*").unwrap(); 1258 + let image_all = Scope::parse("blob:image/*").unwrap(); 1259 + let image_png = Scope::parse("blob:image/png").unwrap(); 1260 + let text_plain = Scope::parse("blob:text/plain").unwrap(); 1261 + 1262 + assert!(all.grants(&image_all)); 1263 + assert!(all.grants(&image_png)); 1264 + assert!(all.grants(&text_plain)); 1265 + assert!(image_all.grants(&image_png)); 1266 + assert!(!image_all.grants(&text_plain)); 1267 + assert!(!image_png.grants(&image_all)); 1268 + } 1269 + 1270 + #[test] 1271 + fn test_repo_scope_grants() { 1272 + let all_all = Scope::parse("repo:*").unwrap(); 1273 + let all_create = Scope::parse("repo:*?action=create").unwrap(); 1274 + let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1275 + let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap(); 1276 + let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap(); 1277 + 1278 + assert!(all_all.grants(&all_create)); 1279 + assert!(all_all.grants(&specific_all)); 1280 + assert!(all_all.grants(&specific_create)); 1281 + assert!(all_create.grants(&all_create)); 1282 + assert!(!all_create.grants(&specific_all)); 1283 + assert!(specific_all.grants(&specific_create)); 1284 + assert!(!specific_create.grants(&specific_all)); 1285 + assert!(!specific_create.grants(&other_create)); 1286 + } 1287 + 1288 + #[test] 1289 + fn test_rpc_scope_grants() { 1290 + let all = Scope::parse("rpc:*").unwrap(); 1291 + let specific_lxm = Scope::parse("rpc:com.example.service").unwrap(); 1292 + let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 1293 + 1294 + assert!(all.grants(&specific_lxm)); 1295 + assert!(all.grants(&specific_both)); 1296 + assert!(specific_lxm.grants(&specific_both)); 1297 + assert!(!specific_both.grants(&specific_lxm)); 1298 + assert!(!specific_both.grants(&all)); 1299 + } 1300 + 1301 + #[test] 1302 + fn test_cross_scope_grants() { 1303 + let account = Scope::parse("account:email").unwrap(); 1304 + let identity = Scope::parse("identity:handle").unwrap(); 1305 + 1306 + assert!(!account.grants(&identity)); 1307 + assert!(!identity.grants(&account)); 1308 + } 1309 + 1310 + #[test] 1311 + fn test_parse_errors() { 1312 + assert!(matches!( 1313 + Scope::parse("unknown:test"), 1314 + Err(ParseError::UnknownPrefix(_)) 1315 + )); 1316 + 1317 + assert!(matches!( 1318 + Scope::parse("account"), 1319 + Err(ParseError::MissingResource) 1320 + )); 1321 + 1322 + assert!(matches!( 1323 + Scope::parse("account:invalid"), 1324 + Err(ParseError::InvalidResource(_)) 1325 + )); 1326 + 1327 + assert!(matches!( 1328 + Scope::parse("account:email?action=invalid"), 1329 + Err(ParseError::InvalidAction(_)) 1330 + )); 1331 + } 1332 + 1333 + #[test] 1334 + fn test_query_parameter_sorting() { 1335 + let scope = 1336 + Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap(); 1337 + let normalized = scope.to_string_normalized(); 1338 + assert!(normalized.contains("accept=application/pdf")); 1339 + assert!(normalized.contains("accept=image/jpeg")); 1340 + assert!(normalized.contains("accept=image/png")); 1341 + let pdf_pos = normalized.find("accept=application/pdf").unwrap(); 1342 + let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); 1343 + let png_pos = normalized.find("accept=image/png").unwrap(); 1344 + assert!(pdf_pos < jpeg_pos); 1345 + assert!(jpeg_pos < png_pos); 1346 + } 1347 + 1348 + #[test] 1349 + fn test_repo_action_wildcard() { 1350 + let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap(); 1351 + let mut actions = BTreeSet::new(); 1352 + actions.insert(RepoAction::Create); 1353 + actions.insert(RepoAction::Update); 1354 + actions.insert(RepoAction::Delete); 1355 + assert_eq!( 1356 + scope, 1357 + Scope::Repo(RepoScope { 1358 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1359 + actions, 1360 + }) 1361 + ); 1362 + } 1363 + 1364 + #[test] 1365 + fn test_multiple_blob_accepts() { 1366 + let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap(); 1367 + assert!(scope.grants(&Scope::parse("blob:image/png").unwrap())); 1368 + assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap())); 1369 + assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap())); 1370 + } 1371 + 1372 + #[test] 1373 + fn test_rpc_default_wildcards() { 1374 + let scope = Scope::parse("rpc").unwrap(); 1375 + let mut lxm = BTreeSet::new(); 1376 + let mut aud = BTreeSet::new(); 1377 + lxm.insert(RpcLexicon::All); 1378 + aud.insert(RpcAudience::All); 1379 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1380 + } 1381 + 1382 + #[test] 1383 + fn test_atproto_scope_parsing() { 1384 + let scope = Scope::parse("atproto").unwrap(); 1385 + assert_eq!(scope, Scope::Atproto); 1386 + 1387 + // Atproto should not accept suffixes 1388 + assert!(Scope::parse("atproto:something").is_err()); 1389 + assert!(Scope::parse("atproto?param=value").is_err()); 1390 + } 1391 + 1392 + #[test] 1393 + fn test_transition_scope_parsing() { 1394 + let scope = Scope::parse("transition:generic").unwrap(); 1395 + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 1396 + 1397 + let scope = Scope::parse("transition:email").unwrap(); 1398 + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 1399 + 1400 + // Test invalid transition types 1401 + assert!(matches!( 1402 + Scope::parse("transition:invalid"), 1403 + Err(ParseError::InvalidResource(_)) 1404 + )); 1405 + 1406 + // Test missing suffix 1407 + assert!(matches!( 1408 + Scope::parse("transition"), 1409 + Err(ParseError::MissingResource) 1410 + )); 1411 + 1412 + // Test transition doesn't accept query parameters 1413 + assert!(matches!( 1414 + Scope::parse("transition:generic?param=value"), 1415 + Err(ParseError::InvalidResource(_)) 1416 + )); 1417 + } 1418 + 1419 + #[test] 1420 + fn test_atproto_scope_normalization() { 1421 + let scope = Scope::parse("atproto").unwrap(); 1422 + assert_eq!(scope.to_string_normalized(), "atproto"); 1423 + } 1424 + 1425 + #[test] 1426 + fn test_transition_scope_normalization() { 1427 + let tests = vec![ 1428 + ("transition:generic", "transition:generic"), 1429 + ("transition:email", "transition:email"), 1430 + ]; 1431 + 1432 + for (input, expected) in tests { 1433 + let scope = Scope::parse(input).unwrap(); 1434 + assert_eq!(scope.to_string_normalized(), expected); 1435 + } 1436 + } 1437 + 1438 + #[test] 1439 + fn test_atproto_scope_grants() { 1440 + let atproto = Scope::parse("atproto").unwrap(); 1441 + let account = Scope::parse("account:email").unwrap(); 1442 + let identity = Scope::parse("identity:handle").unwrap(); 1443 + let blob = Scope::parse("blob:image/png").unwrap(); 1444 + let repo = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1445 + let rpc = Scope::parse("rpc:com.example.service").unwrap(); 1446 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1447 + let transition_email = Scope::parse("transition:email").unwrap(); 1448 + 1449 + // Atproto only grants itself (it's a required scope, not a permission grant) 1450 + assert!(atproto.grants(&atproto)); 1451 + assert!(!atproto.grants(&account)); 1452 + assert!(!atproto.grants(&identity)); 1453 + assert!(!atproto.grants(&blob)); 1454 + assert!(!atproto.grants(&repo)); 1455 + assert!(!atproto.grants(&rpc)); 1456 + assert!(!atproto.grants(&transition_generic)); 1457 + assert!(!atproto.grants(&transition_email)); 1458 + 1459 + // Nothing else grants atproto 1460 + assert!(!account.grants(&atproto)); 1461 + assert!(!identity.grants(&atproto)); 1462 + assert!(!blob.grants(&atproto)); 1463 + assert!(!repo.grants(&atproto)); 1464 + assert!(!rpc.grants(&atproto)); 1465 + assert!(!transition_generic.grants(&atproto)); 1466 + assert!(!transition_email.grants(&atproto)); 1467 + } 1468 + 1469 + #[test] 1470 + fn test_transition_scope_grants() { 1471 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1472 + let transition_email = Scope::parse("transition:email").unwrap(); 1473 + let account = Scope::parse("account:email").unwrap(); 1474 + 1475 + // Transition scopes only grant themselves 1476 + assert!(transition_generic.grants(&transition_generic)); 1477 + assert!(transition_email.grants(&transition_email)); 1478 + assert!(!transition_generic.grants(&transition_email)); 1479 + assert!(!transition_email.grants(&transition_generic)); 1480 + 1481 + // Transition scopes don't grant other scope types 1482 + assert!(!transition_generic.grants(&account)); 1483 + assert!(!transition_email.grants(&account)); 1484 + 1485 + // Other scopes don't grant transition scopes 1486 + assert!(!account.grants(&transition_generic)); 1487 + assert!(!account.grants(&transition_email)); 1488 + } 1489 + 1490 + #[test] 1491 + fn test_parse_multiple() { 1492 + // Test parsing multiple scopes 1493 + let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 1494 + assert_eq!(scopes.len(), 2); 1495 + assert_eq!(scopes[0], Scope::Atproto); 1496 + assert_eq!( 1497 + scopes[1], 1498 + Scope::Repo(RepoScope { 1499 + collection: RepoCollection::All, 1500 + actions: { 1501 + let mut actions = BTreeSet::new(); 1502 + actions.insert(RepoAction::Create); 1503 + actions.insert(RepoAction::Update); 1504 + actions.insert(RepoAction::Delete); 1505 + actions 1506 + } 1507 + }) 1508 + ); 1509 + 1510 + // Test with more scopes 1511 + let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap(); 1512 + assert_eq!(scopes.len(), 3); 1513 + assert!(matches!(scopes[0], Scope::Account(_))); 1514 + assert!(matches!(scopes[1], Scope::Identity(_))); 1515 + assert!(matches!(scopes[2], Scope::Blob(_))); 1516 + 1517 + // Test with complex scopes 1518 + let scopes = Scope::parse_multiple( 1519 + "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email", 1520 + ) 1521 + .unwrap(); 1522 + assert_eq!(scopes.len(), 3); 1523 + 1524 + // Test empty string 1525 + let scopes = Scope::parse_multiple("").unwrap(); 1526 + assert_eq!(scopes.len(), 0); 1527 + 1528 + // Test whitespace only 1529 + let scopes = Scope::parse_multiple(" ").unwrap(); 1530 + assert_eq!(scopes.len(), 0); 1531 + 1532 + // Test with extra whitespace 1533 + let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap(); 1534 + assert_eq!(scopes.len(), 2); 1535 + 1536 + // Test single scope 1537 + let scopes = Scope::parse_multiple("atproto").unwrap(); 1538 + assert_eq!(scopes.len(), 1); 1539 + assert_eq!(scopes[0], Scope::Atproto); 1540 + 1541 + // Test error propagation 1542 + assert!(Scope::parse_multiple("atproto invalid:scope").is_err()); 1543 + assert!(Scope::parse_multiple("account:invalid repo:*").is_err()); 1544 + } 1545 + 1546 + #[test] 1547 + fn test_parse_multiple_reduced() { 1548 + // Test repo scope reduction - wildcard grants specific 1549 + let scopes = 1550 + Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 1551 + assert_eq!(scopes.len(), 2); 1552 + assert!(scopes.contains(&Scope::Atproto)); 1553 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1554 + collection: RepoCollection::All, 1555 + actions: { 1556 + let mut actions = BTreeSet::new(); 1557 + actions.insert(RepoAction::Create); 1558 + actions.insert(RepoAction::Update); 1559 + actions.insert(RepoAction::Delete); 1560 + actions 1561 + } 1562 + }))); 1563 + 1564 + // Test reverse order - should get same result 1565 + let scopes = 1566 + Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap(); 1567 + assert_eq!(scopes.len(), 2); 1568 + assert!(scopes.contains(&Scope::Atproto)); 1569 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1570 + collection: RepoCollection::All, 1571 + actions: { 1572 + let mut actions = BTreeSet::new(); 1573 + actions.insert(RepoAction::Create); 1574 + actions.insert(RepoAction::Update); 1575 + actions.insert(RepoAction::Delete); 1576 + actions 1577 + } 1578 + }))); 1579 + 1580 + // Test account scope reduction - manage grants read 1581 + let scopes = 1582 + Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap(); 1583 + assert_eq!(scopes.len(), 1); 1584 + assert_eq!( 1585 + scopes[0], 1586 + Scope::Account(AccountScope { 1587 + resource: AccountResource::Email, 1588 + action: AccountAction::Manage, 1589 + }) 1590 + ); 1591 + 1592 + // Test identity scope reduction - wildcard grants specific 1593 + let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap(); 1594 + assert_eq!(scopes.len(), 1); 1595 + assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); 1596 + 1597 + // Test blob scope reduction - wildcard grants specific 1598 + let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap(); 1599 + assert_eq!(scopes.len(), 1); 1600 + let mut accept = BTreeSet::new(); 1601 + accept.insert(MimePattern::All); 1602 + assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); 1603 + 1604 + // Test no reduction needed - different scope types 1605 + let scopes = 1606 + Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap(); 1607 + assert_eq!(scopes.len(), 3); 1608 + 1609 + // Test repo action reduction 1610 + let scopes = Scope::parse_multiple_reduced( 1611 + "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post", 1612 + ) 1613 + .unwrap(); 1614 + assert_eq!(scopes.len(), 1); 1615 + assert_eq!( 1616 + scopes[0], 1617 + Scope::Repo(RepoScope { 1618 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1619 + actions: { 1620 + let mut actions = BTreeSet::new(); 1621 + actions.insert(RepoAction::Create); 1622 + actions.insert(RepoAction::Update); 1623 + actions.insert(RepoAction::Delete); 1624 + actions 1625 + } 1626 + }) 1627 + ); 1628 + 1629 + // Test RPC scope reduction 1630 + let scopes = Scope::parse_multiple_reduced( 1631 + "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", 1632 + ) 1633 + .unwrap(); 1634 + assert_eq!(scopes.len(), 1); 1635 + assert_eq!( 1636 + scopes[0], 1637 + Scope::Rpc(RpcScope { 1638 + lxm: { 1639 + let mut lxm = BTreeSet::new(); 1640 + lxm.insert(RpcLexicon::All); 1641 + lxm 1642 + }, 1643 + aud: { 1644 + let mut aud = BTreeSet::new(); 1645 + aud.insert(RpcAudience::All); 1646 + aud 1647 + } 1648 + }) 1649 + ); 1650 + 1651 + // Test duplicate removal 1652 + let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap(); 1653 + assert_eq!(scopes.len(), 1); 1654 + assert_eq!(scopes[0], Scope::Atproto); 1655 + 1656 + // Test transition scopes - only grant themselves 1657 + let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap(); 1658 + assert_eq!(scopes.len(), 2); 1659 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); 1660 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); 1661 + 1662 + // Test empty input 1663 + let scopes = Scope::parse_multiple_reduced("").unwrap(); 1664 + assert_eq!(scopes.len(), 0); 1665 + 1666 + // Test complex scenario with multiple reductions 1667 + let scopes = Scope::parse_multiple_reduced( 1668 + "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" 1669 + ).unwrap(); 1670 + assert_eq!(scopes.len(), 3); 1671 + // Should have: account:email?action=manage, account:repo, identity:* 1672 + assert!(scopes.contains(&Scope::Account(AccountScope { 1673 + resource: AccountResource::Email, 1674 + action: AccountAction::Manage, 1675 + }))); 1676 + assert!(scopes.contains(&Scope::Account(AccountScope { 1677 + resource: AccountResource::Repo, 1678 + action: AccountAction::Read, 1679 + }))); 1680 + assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); 1681 + 1682 + // Test that atproto doesn't grant other scopes (per recent change) 1683 + let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap(); 1684 + assert_eq!(scopes.len(), 3); 1685 + assert!(scopes.contains(&Scope::Atproto)); 1686 + assert!(scopes.contains(&Scope::Account(AccountScope { 1687 + resource: AccountResource::Email, 1688 + action: AccountAction::Read, 1689 + }))); 1690 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1691 + collection: RepoCollection::All, 1692 + actions: { 1693 + let mut actions = BTreeSet::new(); 1694 + actions.insert(RepoAction::Create); 1695 + actions.insert(RepoAction::Update); 1696 + actions.insert(RepoAction::Delete); 1697 + actions 1698 + } 1699 + }))); 1700 + } 1701 + 1702 + #[test] 1703 + fn test_openid_connect_scope_parsing() { 1704 + // Test OpenID scope 1705 + let scope = Scope::parse("openid").unwrap(); 1706 + assert_eq!(scope, Scope::OpenId); 1707 + 1708 + // Test Profile scope 1709 + let scope = Scope::parse("profile").unwrap(); 1710 + assert_eq!(scope, Scope::Profile); 1711 + 1712 + // Test Email scope 1713 + let scope = Scope::parse("email").unwrap(); 1714 + assert_eq!(scope, Scope::Email); 1715 + 1716 + // Test that they don't accept suffixes 1717 + assert!(Scope::parse("openid:something").is_err()); 1718 + assert!(Scope::parse("profile:something").is_err()); 1719 + assert!(Scope::parse("email:something").is_err()); 1720 + 1721 + // Test that they don't accept query parameters 1722 + assert!(Scope::parse("openid?param=value").is_err()); 1723 + assert!(Scope::parse("profile?param=value").is_err()); 1724 + assert!(Scope::parse("email?param=value").is_err()); 1725 + } 1726 + 1727 + #[test] 1728 + fn test_openid_connect_scope_normalization() { 1729 + let scope = Scope::parse("openid").unwrap(); 1730 + assert_eq!(scope.to_string_normalized(), "openid"); 1731 + 1732 + let scope = Scope::parse("profile").unwrap(); 1733 + assert_eq!(scope.to_string_normalized(), "profile"); 1734 + 1735 + let scope = Scope::parse("email").unwrap(); 1736 + assert_eq!(scope.to_string_normalized(), "email"); 1737 + } 1738 + 1739 + #[test] 1740 + fn test_openid_connect_scope_grants() { 1741 + let openid = Scope::parse("openid").unwrap(); 1742 + let profile = Scope::parse("profile").unwrap(); 1743 + let email = Scope::parse("email").unwrap(); 1744 + let account = Scope::parse("account:email").unwrap(); 1745 + 1746 + // OpenID Connect scopes only grant themselves 1747 + assert!(openid.grants(&openid)); 1748 + assert!(!openid.grants(&profile)); 1749 + assert!(!openid.grants(&email)); 1750 + assert!(!openid.grants(&account)); 1751 + 1752 + assert!(profile.grants(&profile)); 1753 + assert!(!profile.grants(&openid)); 1754 + assert!(!profile.grants(&email)); 1755 + assert!(!profile.grants(&account)); 1756 + 1757 + assert!(email.grants(&email)); 1758 + assert!(!email.grants(&openid)); 1759 + assert!(!email.grants(&profile)); 1760 + assert!(!email.grants(&account)); 1761 + 1762 + // Other scopes don't grant OpenID Connect scopes 1763 + assert!(!account.grants(&openid)); 1764 + assert!(!account.grants(&profile)); 1765 + assert!(!account.grants(&email)); 1766 + } 1767 + 1768 + #[test] 1769 + fn test_parse_multiple_with_openid_connect() { 1770 + let scopes = Scope::parse_multiple("openid profile email atproto").unwrap(); 1771 + assert_eq!(scopes.len(), 4); 1772 + assert_eq!(scopes[0], Scope::OpenId); 1773 + assert_eq!(scopes[1], Scope::Profile); 1774 + assert_eq!(scopes[2], Scope::Email); 1775 + assert_eq!(scopes[3], Scope::Atproto); 1776 + 1777 + // Test with mixed scopes 1778 + let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap(); 1779 + assert_eq!(scopes.len(), 4); 1780 + assert!(scopes.contains(&Scope::OpenId)); 1781 + assert!(scopes.contains(&Scope::Profile)); 1782 + } 1783 + 1784 + #[test] 1785 + fn test_parse_multiple_reduced_with_openid_connect() { 1786 + // OpenID Connect scopes don't grant each other, so no reduction 1787 + let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap(); 1788 + assert_eq!(scopes.len(), 3); 1789 + assert!(scopes.contains(&Scope::OpenId)); 1790 + assert!(scopes.contains(&Scope::Profile)); 1791 + assert!(scopes.contains(&Scope::Email)); 1792 + 1793 + // Mixed with other scopes 1794 + let scopes = Scope::parse_multiple_reduced( 1795 + "openid account:email account:email?action=manage profile", 1796 + ) 1797 + .unwrap(); 1798 + assert_eq!(scopes.len(), 3); 1799 + assert!(scopes.contains(&Scope::OpenId)); 1800 + assert!(scopes.contains(&Scope::Profile)); 1801 + assert!(scopes.contains(&Scope::Account(AccountScope { 1802 + resource: AccountResource::Email, 1803 + action: AccountAction::Manage, 1804 + }))); 1805 + } 1806 + 1807 + #[test] 1808 + fn test_serialize_multiple() { 1809 + // Test empty list 1810 + let scopes: Vec<Scope> = vec![]; 1811 + assert_eq!(Scope::serialize_multiple(&scopes), ""); 1812 + 1813 + // Test single scope 1814 + let scopes = vec![Scope::Atproto]; 1815 + assert_eq!(Scope::serialize_multiple(&scopes), "atproto"); 1816 + 1817 + // Test multiple scopes - should be sorted alphabetically 1818 + let scopes = vec![ 1819 + Scope::parse("repo:*").unwrap(), 1820 + Scope::Atproto, 1821 + Scope::parse("account:email").unwrap(), 1822 + ]; 1823 + assert_eq!( 1824 + Scope::serialize_multiple(&scopes), 1825 + "account:email atproto repo:*" 1826 + ); 1827 + 1828 + // Test that sorting is consistent regardless of input order 1829 + let scopes = vec![ 1830 + Scope::parse("identity:handle").unwrap(), 1831 + Scope::parse("blob:image/png").unwrap(), 1832 + Scope::parse("account:repo?action=manage").unwrap(), 1833 + ]; 1834 + assert_eq!( 1835 + Scope::serialize_multiple(&scopes), 1836 + "account:repo?action=manage blob:image/png identity:handle" 1837 + ); 1838 + 1839 + // Test with OpenID Connect scopes 1840 + let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; 1841 + assert_eq!( 1842 + Scope::serialize_multiple(&scopes), 1843 + "atproto email openid profile" 1844 + ); 1845 + 1846 + // Test with complex scopes including query parameters 1847 + let scopes = vec![ 1848 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method") 1849 + .unwrap(), 1850 + Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(), 1851 + Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1852 + ]; 1853 + let result = Scope::serialize_multiple(&scopes); 1854 + // The result should be sorted alphabetically 1855 + // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1856 + assert!(result.starts_with("blob:")); 1857 + assert!(result.contains(" repo:")); 1858 + assert!( 1859 + result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service") 1860 + ); 1861 + 1862 + // Test with transition scopes 1863 + let scopes = vec![ 1864 + Scope::Transition(TransitionScope::Email), 1865 + Scope::Transition(TransitionScope::Generic), 1866 + Scope::Atproto, 1867 + ]; 1868 + assert_eq!( 1869 + Scope::serialize_multiple(&scopes), 1870 + "atproto transition:email transition:generic" 1871 + ); 1872 + 1873 + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) 1874 + let scopes = vec![ 1875 + Scope::Atproto, 1876 + Scope::Atproto, 1877 + Scope::parse("account:email").unwrap(), 1878 + ]; 1879 + assert_eq!( 1880 + Scope::serialize_multiple(&scopes), 1881 + "account:email atproto atproto" 1882 + ); 1883 + 1884 + // Test normalization is preserved in serialization 1885 + let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; 1886 + // Should normalize query parameters alphabetically 1887 + assert_eq!( 1888 + Scope::serialize_multiple(&scopes), 1889 + "blob?accept=image/jpeg&accept=image/png" 1890 + ); 1891 + } 1892 + 1893 + #[test] 1894 + fn test_serialize_multiple_roundtrip() { 1895 + // Test that parse_multiple and serialize_multiple are inverses (when sorted) 1896 + let original = "account:email atproto blob:image/png identity:handle repo:*"; 1897 + let scopes = Scope::parse_multiple(original).unwrap(); 1898 + let serialized = Scope::serialize_multiple(&scopes); 1899 + assert_eq!(serialized, original); 1900 + 1901 + // Test with complex scopes 1902 + let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; 1903 + let scopes = Scope::parse_multiple(original).unwrap(); 1904 + let serialized = Scope::serialize_multiple(&scopes); 1905 + // Parse again to verify it's valid 1906 + let reparsed = Scope::parse_multiple(&serialized).unwrap(); 1907 + assert_eq!(scopes, reparsed); 1908 + 1909 + // Test with OpenID Connect scopes 1910 + let original = "email openid profile"; 1911 + let scopes = Scope::parse_multiple(original).unwrap(); 1912 + let serialized = Scope::serialize_multiple(&scopes); 1913 + assert_eq!(serialized, original); 1914 + } 1915 + 1916 + #[test] 1917 + fn test_remove_scope() { 1918 + // Test removing a scope that exists 1919 + let scopes = vec![ 1920 + Scope::parse("repo:*").unwrap(), 1921 + Scope::Atproto, 1922 + Scope::parse("account:email").unwrap(), 1923 + ]; 1924 + let to_remove = Scope::Atproto; 1925 + let result = Scope::remove_scope(&scopes, &to_remove); 1926 + assert_eq!(result.len(), 2); 1927 + assert!(!result.contains(&to_remove)); 1928 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1929 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1930 + 1931 + // Test removing a scope that doesn't exist 1932 + let scopes = vec![ 1933 + Scope::parse("repo:*").unwrap(), 1934 + Scope::parse("account:email").unwrap(), 1935 + ]; 1936 + let to_remove = Scope::parse("identity:handle").unwrap(); 1937 + let result = Scope::remove_scope(&scopes, &to_remove); 1938 + assert_eq!(result.len(), 2); 1939 + assert_eq!(result, scopes); 1940 + 1941 + // Test removing from empty list 1942 + let scopes: Vec<Scope> = vec![]; 1943 + let to_remove = Scope::Atproto; 1944 + let result = Scope::remove_scope(&scopes, &to_remove); 1945 + assert_eq!(result.len(), 0); 1946 + 1947 + // Test removing all instances of a duplicate scope 1948 + let scopes = vec![ 1949 + Scope::Atproto, 1950 + Scope::parse("account:email").unwrap(), 1951 + Scope::Atproto, 1952 + Scope::parse("repo:*").unwrap(), 1953 + Scope::Atproto, 1954 + ]; 1955 + let to_remove = Scope::Atproto; 1956 + let result = Scope::remove_scope(&scopes, &to_remove); 1957 + assert_eq!(result.len(), 2); 1958 + assert!(!result.contains(&to_remove)); 1959 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1960 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1961 + 1962 + // Test removing complex scopes with query parameters 1963 + let scopes = vec![ 1964 + Scope::parse("account:email?action=manage").unwrap(), 1965 + Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), 1966 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1967 + ]; 1968 + let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order 1969 + let result = Scope::remove_scope(&scopes, &to_remove); 1970 + assert_eq!(result.len(), 2); 1971 + assert!(!result.contains(&to_remove)); 1972 + 1973 + // Test with OpenID Connect scopes 1974 + let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; 1975 + let to_remove = Scope::Profile; 1976 + let result = Scope::remove_scope(&scopes, &to_remove); 1977 + assert_eq!(result.len(), 3); 1978 + assert!(!result.contains(&to_remove)); 1979 + assert!(result.contains(&Scope::OpenId)); 1980 + assert!(result.contains(&Scope::Email)); 1981 + assert!(result.contains(&Scope::Atproto)); 1982 + 1983 + // Test with transition scopes 1984 + let scopes = vec![ 1985 + Scope::Transition(TransitionScope::Generic), 1986 + Scope::Transition(TransitionScope::Email), 1987 + Scope::Atproto, 1988 + ]; 1989 + let to_remove = Scope::Transition(TransitionScope::Email); 1990 + let result = Scope::remove_scope(&scopes, &to_remove); 1991 + assert_eq!(result.len(), 2); 1992 + assert!(!result.contains(&to_remove)); 1993 + assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); 1994 + assert!(result.contains(&Scope::Atproto)); 1995 + 1996 + // Test that only exact matches are removed 1997 + let scopes = vec![ 1998 + Scope::parse("account:email").unwrap(), 1999 + Scope::parse("account:email?action=manage").unwrap(), 2000 + Scope::parse("account:repo").unwrap(), 2001 + ]; 2002 + let to_remove = Scope::parse("account:email").unwrap(); 2003 + let result = Scope::remove_scope(&scopes, &to_remove); 2004 + assert_eq!(result.len(), 2); 2005 + assert!(!result.contains(&Scope::parse("account:email").unwrap())); 2006 + assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 2007 + assert!(result.contains(&Scope::parse("account:repo").unwrap())); 2008 + } 2009 + }
+374
crates/jacquard-oauth/src/session.rs
··· 1 + use std::sync::Arc; 2 + 3 + use crate::{ 4 + atproto::{AtprotoClientMetadata, atproto_client_metadata}, 5 + authstore::ClientAuthStore, 6 + dpop::DpopExt, 7 + keyset::Keyset, 8 + request::{OAuthMetadata, refresh}, 9 + resolver::OAuthResolver, 10 + scopes::Scope, 11 + types::TokenSet, 12 + }; 13 + 14 + use dashmap::DashMap; 15 + use jacquard_common::{ 16 + CowStr, IntoStatic, 17 + http_client::HttpClient, 18 + session::SessionStoreError, 19 + types::{did::Did, string::Datetime}, 20 + }; 21 + use jose_jwk::Key; 22 + use serde::{Deserialize, Serialize}; 23 + use smol_str::{SmolStr, format_smolstr}; 24 + use tokio::sync::Mutex; 25 + use url::Url; 26 + 27 + pub trait DpopDataSource { 28 + fn key(&self) -> &Key; 29 + fn authserver_nonce(&self) -> Option<CowStr<'_>>; 30 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>); 31 + fn host_nonce(&self) -> Option<CowStr<'_>>; 32 + fn set_host_nonce(&mut self, nonce: CowStr<'_>); 33 + } 34 + 35 + /// Persisted information about an OAuth session. Used to resume an active session. 36 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 37 + pub struct ClientSessionData<'s> { 38 + // Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this information. 39 + #[serde(borrow)] 40 + pub account_did: Did<'s>, 41 + 42 + // Identifier to distinguish this particular session for the account. Server backends generally support multiple sessions for the same account. This package will re-use the random 'state' token from the auth flow as the session ID. 43 + pub session_id: CowStr<'s>, 44 + 45 + // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 46 + pub host_url: Url, 47 + 48 + // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 49 + pub authserver_url: Url, 50 + 51 + // Full token endpoint 52 + pub authserver_token_endpoint: CowStr<'s>, 53 + 54 + // Full revocation endpoint, if it exists 55 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 56 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 57 + 58 + // The set of scopes approved for this session (returned in the initial token request) 59 + pub scopes: Vec<Scope<'s>>, 60 + 61 + #[serde(flatten)] 62 + pub dpop_data: DpopClientData<'s>, 63 + 64 + #[serde(flatten)] 65 + pub token_set: TokenSet<'s>, 66 + } 67 + 68 + impl IntoStatic for ClientSessionData<'_> { 69 + type Output = ClientSessionData<'static>; 70 + 71 + fn into_static(self) -> Self::Output { 72 + ClientSessionData { 73 + authserver_url: self.authserver_url, 74 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 75 + authserver_revocation_endpoint: self 76 + .authserver_revocation_endpoint 77 + .map(IntoStatic::into_static), 78 + scopes: self.scopes.into_static(), 79 + dpop_data: self.dpop_data.into_static(), 80 + token_set: self.token_set.into_static(), 81 + account_did: self.account_did.into_static(), 82 + session_id: self.session_id.into_static(), 83 + host_url: self.host_url, 84 + } 85 + } 86 + } 87 + 88 + impl ClientSessionData<'_> { 89 + pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) { 90 + if let Some(Ok(scopes)) = token_set 91 + .scope 92 + .as_ref() 93 + .map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static)) 94 + { 95 + self.scopes = scopes; 96 + } 97 + self.token_set = token_set.into_static(); 98 + } 99 + } 100 + 101 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 102 + pub struct DpopClientData<'s> { 103 + pub dpop_key: Key, 104 + // Current auth server DPoP nonce 105 + #[serde(borrow)] 106 + pub dpop_authserver_nonce: CowStr<'s>, 107 + // Current host ("resource server", eg PDS) DPoP nonce 108 + pub dpop_host_nonce: CowStr<'s>, 109 + } 110 + 111 + impl IntoStatic for DpopClientData<'_> { 112 + type Output = DpopClientData<'static>; 113 + 114 + fn into_static(self) -> Self::Output { 115 + DpopClientData { 116 + dpop_key: self.dpop_key, 117 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 118 + dpop_host_nonce: self.dpop_host_nonce.into_static(), 119 + } 120 + } 121 + } 122 + 123 + impl DpopDataSource for DpopClientData<'_> { 124 + fn key(&self) -> &Key { 125 + &self.dpop_key 126 + } 127 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 128 + Some(self.dpop_authserver_nonce.clone()) 129 + } 130 + 131 + fn host_nonce(&self) -> Option<CowStr<'_>> { 132 + Some(self.dpop_host_nonce.clone()) 133 + } 134 + 135 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 136 + self.dpop_authserver_nonce = nonce.into_static(); 137 + } 138 + 139 + fn set_host_nonce(&mut self, nonce: CowStr<'_>) { 140 + self.dpop_host_nonce = nonce.into_static(); 141 + } 142 + } 143 + 144 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 145 + pub struct AuthRequestData<'s> { 146 + // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information. 147 + #[serde(borrow)] 148 + pub state: CowStr<'s>, 149 + 150 + // URL of the auth server (eg, PDS or entryway) 151 + pub authserver_url: Url, 152 + 153 + // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response. 154 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 155 + pub account_did: Option<Did<'s>>, 156 + 157 + // OAuth scope strings 158 + pub scopes: Vec<Scope<'s>>, 159 + 160 + // unique token in URI format, which will be used by the client in the auth flow redirect 161 + pub request_uri: CowStr<'s>, 162 + 163 + // Full token endpoint URL 164 + pub authserver_token_endpoint: CowStr<'s>, 165 + 166 + // Full revocation endpoint, if it exists 167 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 168 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 169 + 170 + // The secret token/nonce which a code challenge was generated from 171 + pub pkce_verifier: CowStr<'s>, 172 + 173 + #[serde(flatten)] 174 + pub dpop_data: DpopReqData<'s>, 175 + } 176 + 177 + impl IntoStatic for AuthRequestData<'_> { 178 + type Output = AuthRequestData<'static>; 179 + fn into_static(self) -> AuthRequestData<'static> { 180 + AuthRequestData { 181 + request_uri: self.request_uri.into_static(), 182 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 183 + authserver_revocation_endpoint: self 184 + .authserver_revocation_endpoint 185 + .map(|s| s.into_static()), 186 + pkce_verifier: self.pkce_verifier.into_static(), 187 + dpop_data: self.dpop_data.into_static(), 188 + state: self.state.into_static(), 189 + authserver_url: self.authserver_url, 190 + account_did: self.account_did.into_static(), 191 + scopes: self.scopes.into_static(), 192 + } 193 + } 194 + } 195 + 196 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 197 + pub struct DpopReqData<'s> { 198 + // The secret cryptographic key generated by the client for this specific OAuth session 199 + pub dpop_key: Key, 200 + // Server-provided DPoP nonce from auth request (PAR) 201 + #[serde(borrow)] 202 + pub dpop_authserver_nonce: Option<CowStr<'s>>, 203 + } 204 + 205 + impl IntoStatic for DpopReqData<'_> { 206 + type Output = DpopReqData<'static>; 207 + fn into_static(self) -> DpopReqData<'static> { 208 + DpopReqData { 209 + dpop_key: self.dpop_key, 210 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 211 + } 212 + } 213 + } 214 + 215 + impl DpopDataSource for DpopReqData<'_> { 216 + fn key(&self) -> &Key { 217 + &self.dpop_key 218 + } 219 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 220 + self.dpop_authserver_nonce.clone() 221 + } 222 + 223 + fn host_nonce(&self) -> Option<CowStr<'_>> { 224 + None 225 + } 226 + 227 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 228 + self.dpop_authserver_nonce = Some(nonce.into_static()); 229 + } 230 + 231 + fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {} 232 + } 233 + 234 + #[derive(Clone, Debug)] 235 + pub struct ClientData<'s> { 236 + pub keyset: Option<Keyset>, 237 + pub config: AtprotoClientMetadata<'s>, 238 + } 239 + 240 + pub struct ClientSession<'s> { 241 + pub keyset: Option<Keyset>, 242 + pub config: AtprotoClientMetadata<'s>, 243 + pub session_data: ClientSessionData<'s>, 244 + } 245 + 246 + impl<'s> ClientSession<'s> { 247 + pub fn new( 248 + ClientData { keyset, config }: ClientData<'s>, 249 + session_data: ClientSessionData<'s>, 250 + ) -> Self { 251 + Self { 252 + keyset, 253 + config, 254 + session_data, 255 + } 256 + } 257 + 258 + pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>( 259 + &self, 260 + client: &T, 261 + ) -> Result<OAuthMetadata, Error> { 262 + Ok(OAuthMetadata { 263 + server_metadata: client 264 + .get_authorization_server_metadata(&self.session_data.authserver_url) 265 + .await 266 + .map_err(|e| Error::ServerAgent(crate::request::RequestError::ResolverError(e)))?, 267 + client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset) 268 + .unwrap() 269 + .into_static(), 270 + keyset: self.keyset.clone(), 271 + }) 272 + } 273 + } 274 + 275 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 276 + pub enum Error { 277 + #[error(transparent)] 278 + #[diagnostic(code(jacquard_oauth::session::request))] 279 + ServerAgent(#[from] crate::request::RequestError), 280 + #[error(transparent)] 281 + #[diagnostic(code(jacquard_oauth::session::storage))] 282 + Store(#[from] SessionStoreError), 283 + #[error("session does not exist")] 284 + #[diagnostic(code(jacquard_oauth::session::not_found))] 285 + SessionNotFound, 286 + } 287 + 288 + pub struct SessionRegistry<T, S> 289 + where 290 + T: OAuthResolver, 291 + S: ClientAuthStore, 292 + { 293 + pub store: Arc<S>, 294 + pub client: Arc<T>, 295 + pub client_data: ClientData<'static>, 296 + pending: DashMap<SmolStr, Arc<Mutex<()>>>, 297 + } 298 + 299 + impl<T, S> SessionRegistry<T, S> 300 + where 301 + S: ClientAuthStore, 302 + T: OAuthResolver, 303 + { 304 + pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self { 305 + let store = Arc::new(store); 306 + Self { 307 + store: Arc::clone(&store), 308 + client, 309 + client_data, 310 + pending: DashMap::new(), 311 + } 312 + } 313 + } 314 + 315 + impl<T, S> SessionRegistry<T, S> 316 + where 317 + S: ClientAuthStore + Send + Sync + 'static, 318 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 319 + { 320 + async fn get_refreshed( 321 + &self, 322 + did: &Did<'_>, 323 + session_id: &str, 324 + ) -> Result<ClientSessionData<'_>, Error> { 325 + let key = format_smolstr!("{}_{}", did, session_id); 326 + let lock = self 327 + .pending 328 + .entry(key) 329 + .or_insert_with(|| Arc::new(Mutex::new(()))) 330 + .clone(); 331 + let _guard = lock.lock().await; 332 + 333 + let mut session = self 334 + .store 335 + .get_session(did, session_id) 336 + .await? 337 + .ok_or(Error::SessionNotFound)?; 338 + if let Some(expires_at) = &session.token_set.expires_at { 339 + if expires_at > &Datetime::now() { 340 + return Ok(session); 341 + } 342 + } 343 + let metadata = 344 + OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?; 345 + session = refresh(self.client.as_ref(), session, &metadata).await?; 346 + self.store.upsert_session(session.clone()).await?; 347 + 348 + Ok(session) 349 + } 350 + pub async fn get( 351 + &self, 352 + did: &Did<'_>, 353 + session_id: &str, 354 + refresh: bool, 355 + ) -> Result<ClientSessionData<'_>, Error> { 356 + if refresh { 357 + self.get_refreshed(did, session_id).await 358 + } else { 359 + // TODO: cached? 360 + self.store 361 + .get_session(did, session_id) 362 + .await? 363 + .ok_or(Error::SessionNotFound) 364 + } 365 + } 366 + pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> { 367 + self.store.upsert_session(value).await?; 368 + Ok(()) 369 + } 370 + pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> { 371 + self.store.delete_session(did, session_id).await?; 372 + Ok(()) 373 + } 374 + }
+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 ParParameters<'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 ParParameters<'_> { 119 + type Output = ParParameters<'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 + }
+37
crates/jacquard-oauth/src/types/response.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use smol_str::SmolStr; 3 + 4 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 5 + pub struct OAuthParResponse { 6 + pub request_uri: SmolStr, 7 + pub expires_in: Option<u32>, 8 + } 9 + 10 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 11 + pub enum OAuthTokenType { 12 + DPoP, 13 + Bearer, 14 + } 15 + 16 + impl OAuthTokenType { 17 + pub fn as_str(&self) -> &'static str { 18 + match self { 19 + OAuthTokenType::DPoP => "DPoP", 20 + OAuthTokenType::Bearer => "Bearer", 21 + } 22 + } 23 + } 24 + 25 + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 26 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 27 + pub struct OAuthTokenResponse { 28 + pub access_token: SmolStr, 29 + pub token_type: OAuthTokenType, 30 + pub expires_in: Option<i64>, 31 + pub refresh_token: Option<SmolStr>, 32 + pub scope: Option<SmolStr>, 33 + // ATPROTO extension: add the sub claim to the token response to allow 34 + // clients to resolve the PDS url (audience) using the did resolution 35 + // mechanism. 36 + pub sub: Option<SmolStr>, 37 + }
+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 + }
+62
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 + use url::Url; 17 + 18 + #[derive(Debug, Deserialize, Clone, Copy)] 19 + pub enum AuthorizeOptionPrompt { 20 + Login, 21 + None, 22 + Consent, 23 + SelectAccount, 24 + } 25 + 26 + impl From<AuthorizeOptionPrompt> for CowStr<'static> { 27 + fn from(value: AuthorizeOptionPrompt) -> Self { 28 + match value { 29 + AuthorizeOptionPrompt::Login => CowStr::new_static("login"), 30 + AuthorizeOptionPrompt::None => CowStr::new_static("none"), 31 + AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"), 32 + AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"), 33 + } 34 + } 35 + } 36 + 37 + #[derive(Debug)] 38 + pub struct AuthorizeOptions<'s> { 39 + pub redirect_uri: Option<Url>, 40 + pub scopes: Vec<Scope<'s>>, 41 + pub prompt: Option<AuthorizeOptionPrompt>, 42 + pub state: Option<CowStr<'s>>, 43 + } 44 + 45 + impl Default for AuthorizeOptions<'_> { 46 + fn default() -> Self { 47 + Self { 48 + redirect_uri: None, 49 + scopes: vec![Scope::Atproto], 50 + prompt: None, 51 + state: None, 52 + } 53 + } 54 + } 55 + 56 + #[derive(Debug, Deserialize)] 57 + pub struct CallbackParams<'s> { 58 + #[serde(borrow)] 59 + pub code: CowStr<'s>, 60 + pub state: Option<CowStr<'s>>, 61 + pub iss: Option<CowStr<'s>>, 62 + }
+94
crates/jacquard-oauth/src/utils.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use elliptic_curve::SecretKey; 4 + use jacquard_common::CowStr; 5 + use jose_jwk::{Key, crypto}; 6 + use rand::{CryptoRng, RngCore, rngs::ThreadRng}; 7 + use sha2::{Digest, Sha256}; 8 + use std::cmp::Ordering; 9 + 10 + use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata}; 11 + 12 + pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> { 13 + for alg in allowed_algos { 14 + #[allow(clippy::single_match)] 15 + match alg.as_ref() { 16 + "ES256" => { 17 + return Some(Key::from(&crypto::Key::from( 18 + SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()), 19 + ))); 20 + } 21 + _ => { 22 + // TODO: Implement other algorithms? 23 + } 24 + } 25 + } 26 + None 27 + } 28 + 29 + pub fn generate_nonce() -> CowStr<'static> { 30 + URL_SAFE_NO_PAD 31 + .encode(get_random_values::<_, 16>(&mut ThreadRng::default())) 32 + .into() 33 + } 34 + 35 + pub fn generate_verifier() -> CowStr<'static> { 36 + URL_SAFE_NO_PAD 37 + .encode(get_random_values::<_, 43>(&mut ThreadRng::default())) 38 + .into() 39 + } 40 + 41 + pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN] 42 + where 43 + R: RngCore + CryptoRng, 44 + { 45 + let mut bytes = [0u8; LEN]; 46 + rng.fill_bytes(&mut bytes); 47 + bytes 48 + } 49 + 50 + // 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) 51 + pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering { 52 + if a.as_ref() == "ES256K" { 53 + return Ordering::Less; 54 + } 55 + if b.as_ref() == "ES256K" { 56 + return Ordering::Greater; 57 + } 58 + for prefix in ["ES", "PS", "RS"] { 59 + if let Some(stripped_a) = a.strip_prefix(prefix) { 60 + if let Some(stripped_b) = b.strip_prefix(prefix) { 61 + if let (Ok(len_a), Ok(len_b)) = 62 + (stripped_a.parse::<u32>(), stripped_b.parse::<u32>()) 63 + { 64 + return len_a.cmp(&len_b); 65 + } 66 + } else { 67 + return Ordering::Less; 68 + } 69 + } else if b.starts_with(prefix) { 70 + return Ordering::Greater; 71 + } 72 + } 73 + Ordering::Equal 74 + } 75 + 76 + pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) { 77 + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 78 + let verifier = generate_verifier(); 79 + ( 80 + URL_SAFE_NO_PAD 81 + .encode(Sha256::digest(&verifier.as_str())) 82 + .into(), 83 + verifier, 84 + ) 85 + } 86 + 87 + pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> { 88 + let mut algs = metadata 89 + .dpop_signing_alg_values_supported 90 + .clone() 91 + .unwrap_or(vec![FALLBACK_ALG.into()]); 92 + algs.sort_by(compare_algos); 93 + generate_key(&algs) 94 + }
-14
docs/identity.md
··· 1 - # Identity Resolution 2 - 3 - This module provides helpers for resolving AT Protocol identifiers (handles and DIDs) and fetching DID documents. 4 - 5 - Highlights: 6 - 7 - - DNS TXT (`_atproto.<handle>`) first when compiled with the `dns` feature, then HTTPS well-known, then Slingshot `resolveHandle` when configured as PLC source. 8 - - DID resolution via did:web well-known or PLC base (PLC Directory or Slingshot), returning a `DidDocResponse` that supports borrowed parsing and validation. 9 - - Validation: convenience helpers validate that the fetched DID document `id` matches the requested DID (default on). On mismatch, a `DocIdMismatch` error includes the fetched document for callers to inspect. 10 - - Slingshot: supports unauthenticated `resolveHandle` and a minimal-document endpoint (`com.bad-example.identity.resolveMiniDoc`). 11 - - Auth-aware fallbacks: PDS `resolveHandle` / `resolveDid` available via helpers that accept an `XrpcClient`. 12 - 13 - See `jacquard::identity::resolver` rustdoc for examples. 14 -