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