+1
.gitignore
+1
.gitignore
+587
-27
Cargo.lock
+587
-27
Cargo.lock
···
37
37
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
38
38
39
39
[[package]]
40
+
name = "adler32"
41
+
version = "1.2.0"
42
+
source = "registry+https://github.com/rust-lang/crates.io-index"
43
+
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
44
+
45
+
[[package]]
40
46
name = "aho-corasick"
41
47
version = "1.1.3"
42
48
source = "registry+https://github.com/rust-lang/crates.io-index"
···
52
58
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
53
59
54
60
[[package]]
61
+
name = "alloc-no-stdlib"
62
+
version = "2.0.4"
63
+
source = "registry+https://github.com/rust-lang/crates.io-index"
64
+
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
65
+
66
+
[[package]]
67
+
name = "alloc-stdlib"
68
+
version = "0.2.2"
69
+
source = "registry+https://github.com/rust-lang/crates.io-index"
70
+
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
71
+
dependencies = [
72
+
"alloc-no-stdlib",
73
+
]
74
+
75
+
[[package]]
55
76
name = "android_system_properties"
56
77
version = "0.1.5"
57
78
source = "registry+https://github.com/rust-lang/crates.io-index"
···
109
130
"once_cell_polyfill",
110
131
"windows-sys 0.60.2",
111
132
]
133
+
134
+
[[package]]
135
+
name = "ascii"
136
+
version = "1.1.0"
137
+
source = "registry+https://github.com/rust-lang/crates.io-index"
138
+
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
112
139
113
140
[[package]]
114
141
name = "async-compression"
···
184
211
185
212
[[package]]
186
213
name = "base64"
214
+
version = "0.13.1"
215
+
source = "registry+https://github.com/rust-lang/crates.io-index"
216
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
217
+
218
+
[[package]]
219
+
name = "base64"
187
220
version = "0.22.1"
188
221
source = "registry+https://github.com/rust-lang/crates.io-index"
189
222
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
223
+
224
+
[[package]]
225
+
name = "base64ct"
226
+
version = "1.8.0"
227
+
source = "registry+https://github.com/rust-lang/crates.io-index"
228
+
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
190
229
191
230
[[package]]
192
231
name = "bitflags"
···
205
244
206
245
[[package]]
207
246
name = "bon"
208
-
version = "3.7.2"
247
+
version = "3.8.0"
209
248
source = "registry+https://github.com/rust-lang/crates.io-index"
210
-
checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb"
249
+
checksum = "f44aa969f86ffb99e5c2d51f393ec9ed6e9fe2f47b609c917b0071f129854d29"
211
250
dependencies = [
212
251
"bon-macros",
213
252
"rustversion",
···
215
254
216
255
[[package]]
217
256
name = "bon-macros"
218
-
version = "3.7.2"
257
+
version = "3.8.0"
219
258
source = "registry+https://github.com/rust-lang/crates.io-index"
220
-
checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005"
259
+
checksum = "e1e78cd86b6a6515d87392332fd63c4950ed3e50eab54275259a5f59f3666f90"
221
260
dependencies = [
222
261
"darling",
223
262
"ident_case",
···
238
277
]
239
278
240
279
[[package]]
280
+
name = "brotli"
281
+
version = "3.5.0"
282
+
source = "registry+https://github.com/rust-lang/crates.io-index"
283
+
checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391"
284
+
dependencies = [
285
+
"alloc-no-stdlib",
286
+
"alloc-stdlib",
287
+
"brotli-decompressor",
288
+
]
289
+
290
+
[[package]]
291
+
name = "brotli-decompressor"
292
+
version = "2.5.1"
293
+
source = "registry+https://github.com/rust-lang/crates.io-index"
294
+
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
295
+
dependencies = [
296
+
"alloc-no-stdlib",
297
+
"alloc-stdlib",
298
+
]
299
+
300
+
[[package]]
241
301
name = "btree-range-map"
242
302
version = "0.7.2"
243
303
source = "registry+https://github.com/rust-lang/crates.io-index"
···
262
322
]
263
323
264
324
[[package]]
325
+
name = "buf_redux"
326
+
version = "0.8.4"
327
+
source = "registry+https://github.com/rust-lang/crates.io-index"
328
+
checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f"
329
+
dependencies = [
330
+
"memchr",
331
+
"safemem",
332
+
]
333
+
334
+
[[package]]
265
335
name = "bumpalo"
266
336
version = "3.19.0"
267
337
source = "registry+https://github.com/rust-lang/crates.io-index"
268
338
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
339
+
340
+
[[package]]
341
+
name = "byteorder"
342
+
version = "1.5.0"
343
+
source = "registry+https://github.com/rust-lang/crates.io-index"
344
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
269
345
270
346
[[package]]
271
347
name = "bytes"
···
331
407
]
332
408
333
409
[[package]]
410
+
name = "chunked_transfer"
411
+
version = "1.5.0"
412
+
source = "registry+https://github.com/rust-lang/crates.io-index"
413
+
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
414
+
415
+
[[package]]
334
416
name = "ciborium"
335
417
version = "0.2.2"
336
418
source = "registry+https://github.com/rust-lang/crates.io-index"
···
484
566
]
485
567
486
568
[[package]]
569
+
name = "crossbeam-utils"
570
+
version = "0.8.21"
571
+
source = "registry+https://github.com/rust-lang/crates.io-index"
572
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
573
+
574
+
[[package]]
487
575
name = "crunchy"
488
576
version = "0.2.4"
489
577
source = "registry+https://github.com/rust-lang/crates.io-index"
···
573
661
]
574
662
575
663
[[package]]
664
+
name = "dashmap"
665
+
version = "6.1.0"
666
+
source = "registry+https://github.com/rust-lang/crates.io-index"
667
+
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
668
+
dependencies = [
669
+
"cfg-if",
670
+
"crossbeam-utils",
671
+
"hashbrown 0.14.5",
672
+
"lock_api",
673
+
"once_cell",
674
+
"parking_lot_core",
675
+
]
676
+
677
+
[[package]]
576
678
name = "data-encoding"
577
679
version = "2.9.0"
578
680
source = "registry+https://github.com/rust-lang/crates.io-index"
···
599
701
]
600
702
601
703
[[package]]
704
+
name = "deflate"
705
+
version = "1.0.0"
706
+
source = "registry+https://github.com/rust-lang/crates.io-index"
707
+
checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f"
708
+
dependencies = [
709
+
"adler32",
710
+
"gzip-header",
711
+
]
712
+
713
+
[[package]]
602
714
name = "der"
603
715
version = "0.7.10"
604
716
source = "registry+https://github.com/rust-lang/crates.io-index"
605
717
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
606
718
dependencies = [
607
719
"const-oid",
720
+
"pem-rfc7468",
608
721
"zeroize",
609
722
]
610
723
···
625
738
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
626
739
dependencies = [
627
740
"block-buffer",
741
+
"const-oid",
628
742
"crypto-common",
743
+
"subtle",
629
744
]
630
745
631
746
[[package]]
···
646
761
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
647
762
648
763
[[package]]
764
+
name = "ecdsa"
765
+
version = "0.16.9"
766
+
source = "registry+https://github.com/rust-lang/crates.io-index"
767
+
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
768
+
dependencies = [
769
+
"der",
770
+
"digest",
771
+
"elliptic-curve",
772
+
"rfc6979",
773
+
"signature",
774
+
"spki",
775
+
]
776
+
777
+
[[package]]
649
778
name = "ed25519"
650
779
version = "2.2.3"
651
780
source = "registry+https://github.com/rust-lang/crates.io-index"
···
681
810
dependencies = [
682
811
"base16ct",
683
812
"crypto-bigint",
813
+
"digest",
684
814
"ff",
685
815
"generic-array",
686
816
"group",
817
+
"pem-rfc7468",
818
+
"pkcs8",
687
819
"rand_core 0.6.4",
688
820
"sec1",
689
821
"subtle",
···
712
844
]
713
845
714
846
[[package]]
715
-
name = "enum_dispatch"
716
-
version = "0.3.13"
717
-
source = "registry+https://github.com/rust-lang/crates.io-index"
718
-
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
719
-
dependencies = [
720
-
"once_cell",
721
-
"proc-macro2",
722
-
"quote",
723
-
"syn 2.0.106",
724
-
]
725
-
726
-
[[package]]
727
847
name = "equivalent"
728
848
version = "1.0.2"
729
849
source = "registry+https://github.com/rust-lang/crates.io-index"
···
762
882
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
763
883
764
884
[[package]]
885
+
name = "filetime"
886
+
version = "0.2.26"
887
+
source = "registry+https://github.com/rust-lang/crates.io-index"
888
+
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
889
+
dependencies = [
890
+
"cfg-if",
891
+
"libc",
892
+
"libredox",
893
+
"windows-sys 0.60.2",
894
+
]
895
+
896
+
[[package]]
765
897
name = "find-msvc-tools"
766
898
version = "0.1.2"
767
899
source = "registry+https://github.com/rust-lang/crates.io-index"
···
894
1026
]
895
1027
896
1028
[[package]]
1029
+
name = "gzip-header"
1030
+
version = "1.0.0"
1031
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1032
+
checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2"
1033
+
dependencies = [
1034
+
"crc32fast",
1035
+
]
1036
+
1037
+
[[package]]
897
1038
name = "h2"
898
1039
version = "0.4.12"
899
1040
source = "registry+https://github.com/rust-lang/crates.io-index"
···
930
1071
931
1072
[[package]]
932
1073
name = "hashbrown"
1074
+
version = "0.14.5"
1075
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1076
+
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1077
+
1078
+
[[package]]
1079
+
name = "hashbrown"
933
1080
version = "0.16.0"
934
1081
source = "registry+https://github.com/rust-lang/crates.io-index"
935
1082
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
···
945
1092
version = "0.5.0"
946
1093
source = "registry+https://github.com/rust-lang/crates.io-index"
947
1094
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1095
+
1096
+
[[package]]
1097
+
name = "hermit-abi"
1098
+
version = "0.5.2"
1099
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1100
+
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
948
1101
949
1102
[[package]]
950
1103
name = "hex"
···
1004
1157
]
1005
1158
1006
1159
[[package]]
1160
+
name = "hmac"
1161
+
version = "0.12.1"
1162
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1163
+
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1164
+
dependencies = [
1165
+
"digest",
1166
+
]
1167
+
1168
+
[[package]]
1007
1169
name = "http"
1008
1170
version = "1.3.1"
1009
1171
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1042
1204
version = "1.10.1"
1043
1205
source = "registry+https://github.com/rust-lang/crates.io-index"
1044
1206
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1207
+
1208
+
[[package]]
1209
+
name = "httpdate"
1210
+
version = "1.0.3"
1211
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1212
+
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1045
1213
1046
1214
[[package]]
1047
1215
name = "hyper"
···
1088
1256
source = "registry+https://github.com/rust-lang/crates.io-index"
1089
1257
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
1090
1258
dependencies = [
1091
-
"base64",
1259
+
"base64 0.22.1",
1092
1260
"bytes",
1093
1261
"futures-channel",
1094
1262
"futures-core",
···
1353
1521
1354
1522
[[package]]
1355
1523
name = "jacquard"
1356
-
version = "0.2.1"
1524
+
version = "0.3.0"
1357
1525
dependencies = [
1358
1526
"async-trait",
1359
1527
"bon",
1360
1528
"bytes",
1361
1529
"clap",
1362
-
"hickory-resolver",
1363
1530
"http",
1364
1531
"jacquard-api",
1365
1532
"jacquard-common",
1366
1533
"jacquard-derive",
1534
+
"jacquard-identity",
1535
+
"jacquard-oauth",
1536
+
"jose-jwk",
1367
1537
"miette",
1538
+
"p256",
1368
1539
"percent-encoding",
1540
+
"rand_core 0.6.4",
1369
1541
"reqwest",
1542
+
"rouille",
1370
1543
"serde",
1371
1544
"serde_html_form",
1372
1545
"serde_ipld_dagcbor",
···
1380
1553
1381
1554
[[package]]
1382
1555
name = "jacquard-api"
1383
-
version = "0.2.0"
1556
+
version = "0.3.0"
1384
1557
dependencies = [
1385
1558
"bon",
1386
1559
"bytes",
···
1393
1566
1394
1567
[[package]]
1395
1568
name = "jacquard-common"
1396
-
version = "0.2.0"
1569
+
version = "0.3.0"
1397
1570
dependencies = [
1398
-
"base64",
1571
+
"async-trait",
1572
+
"base64 0.22.1",
1399
1573
"bon",
1400
1574
"bytes",
1401
1575
"chrono",
1402
1576
"cid",
1403
1577
"ed25519-dalek",
1404
-
"enum_dispatch",
1578
+
"http",
1405
1579
"ipld-core",
1406
1580
"k256",
1407
1581
"langtag",
···
1413
1587
"p256",
1414
1588
"rand 0.9.2",
1415
1589
"regex",
1590
+
"reqwest",
1416
1591
"serde",
1417
1592
"serde_html_form",
1593
+
"serde_ipld_dagcbor",
1418
1594
"serde_json",
1419
1595
"serde_with",
1420
1596
"smol_str",
1421
1597
"thiserror 2.0.17",
1598
+
"tokio",
1599
+
"trait-variant",
1422
1600
"url",
1423
1601
]
1424
1602
1425
1603
[[package]]
1426
1604
name = "jacquard-derive"
1427
-
version = "0.2.0"
1605
+
version = "0.3.0"
1428
1606
dependencies = [
1429
1607
"heck 0.5.0",
1430
1608
"itertools",
···
1440
1618
]
1441
1619
1442
1620
[[package]]
1621
+
name = "jacquard-identity"
1622
+
version = "0.3.0"
1623
+
dependencies = [
1624
+
"async-trait",
1625
+
"bon",
1626
+
"bytes",
1627
+
"hickory-resolver",
1628
+
"http",
1629
+
"jacquard-api",
1630
+
"jacquard-common",
1631
+
"miette",
1632
+
"percent-encoding",
1633
+
"reqwest",
1634
+
"serde",
1635
+
"serde_html_form",
1636
+
"serde_json",
1637
+
"thiserror 2.0.17",
1638
+
"tokio",
1639
+
"url",
1640
+
"urlencoding",
1641
+
]
1642
+
1643
+
[[package]]
1443
1644
name = "jacquard-lexicon"
1444
-
version = "0.2.0"
1645
+
version = "0.3.0"
1445
1646
dependencies = [
1446
1647
"clap",
1447
1648
"heck 0.5.0",
···
1461
1662
]
1462
1663
1463
1664
[[package]]
1665
+
name = "jacquard-oauth"
1666
+
version = "0.3.0"
1667
+
dependencies = [
1668
+
"async-trait",
1669
+
"base64 0.22.1",
1670
+
"bytes",
1671
+
"chrono",
1672
+
"dashmap",
1673
+
"elliptic-curve",
1674
+
"http",
1675
+
"jacquard-common",
1676
+
"jacquard-identity",
1677
+
"jose-jwa",
1678
+
"jose-jwk",
1679
+
"miette",
1680
+
"p256",
1681
+
"rand 0.8.5",
1682
+
"rand_core 0.6.4",
1683
+
"reqwest",
1684
+
"serde",
1685
+
"serde_html_form",
1686
+
"serde_json",
1687
+
"sha2",
1688
+
"signature",
1689
+
"smol_str",
1690
+
"thiserror 2.0.17",
1691
+
"tokio",
1692
+
"trait-variant",
1693
+
"url",
1694
+
"uuid",
1695
+
]
1696
+
1697
+
[[package]]
1698
+
name = "jose-b64"
1699
+
version = "0.1.2"
1700
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1701
+
checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56"
1702
+
dependencies = [
1703
+
"base64ct",
1704
+
"serde",
1705
+
"subtle",
1706
+
"zeroize",
1707
+
]
1708
+
1709
+
[[package]]
1710
+
name = "jose-jwa"
1711
+
version = "0.1.2"
1712
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1713
+
checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7"
1714
+
dependencies = [
1715
+
"serde",
1716
+
]
1717
+
1718
+
[[package]]
1719
+
name = "jose-jwk"
1720
+
version = "0.1.2"
1721
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1722
+
checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7"
1723
+
dependencies = [
1724
+
"jose-b64",
1725
+
"jose-jwa",
1726
+
"p256",
1727
+
"p384",
1728
+
"rsa",
1729
+
"serde",
1730
+
"zeroize",
1731
+
]
1732
+
1733
+
[[package]]
1464
1734
name = "js-sys"
1465
1735
version = "0.3.81"
1466
1736
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1492
1762
]
1493
1763
1494
1764
[[package]]
1765
+
name = "lazy_static"
1766
+
version = "1.5.0"
1767
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1768
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1769
+
dependencies = [
1770
+
"spin",
1771
+
]
1772
+
1773
+
[[package]]
1495
1774
name = "libc"
1496
1775
version = "0.2.176"
1497
1776
source = "registry+https://github.com/rust-lang/crates.io-index"
1498
1777
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
1778
+
1779
+
[[package]]
1780
+
name = "libm"
1781
+
version = "0.2.15"
1782
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1783
+
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
1784
+
1785
+
[[package]]
1786
+
name = "libredox"
1787
+
version = "0.1.10"
1788
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1789
+
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
1790
+
dependencies = [
1791
+
"bitflags",
1792
+
"libc",
1793
+
"redox_syscall",
1794
+
]
1499
1795
1500
1796
[[package]]
1501
1797
name = "linked-hash-map"
···
1588
1884
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1589
1885
1590
1886
[[package]]
1887
+
name = "mime_guess"
1888
+
version = "2.0.5"
1889
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1890
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
1891
+
dependencies = [
1892
+
"mime",
1893
+
"unicase",
1894
+
]
1895
+
1896
+
[[package]]
1591
1897
name = "minimal-lexical"
1592
1898
version = "0.2.1"
1593
1899
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1636
1942
]
1637
1943
1638
1944
[[package]]
1945
+
name = "multipart"
1946
+
version = "0.18.0"
1947
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1948
+
checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182"
1949
+
dependencies = [
1950
+
"buf_redux",
1951
+
"httparse",
1952
+
"log",
1953
+
"mime",
1954
+
"mime_guess",
1955
+
"quick-error",
1956
+
"rand 0.8.5",
1957
+
"safemem",
1958
+
"tempfile",
1959
+
"twoway",
1960
+
]
1961
+
1962
+
[[package]]
1639
1963
name = "nom"
1640
1964
version = "7.1.3"
1641
1965
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1646
1970
]
1647
1971
1648
1972
[[package]]
1973
+
name = "num-bigint-dig"
1974
+
version = "0.8.4"
1975
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1976
+
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
1977
+
dependencies = [
1978
+
"byteorder",
1979
+
"lazy_static",
1980
+
"libm",
1981
+
"num-integer",
1982
+
"num-iter",
1983
+
"num-traits",
1984
+
"rand 0.8.5",
1985
+
"smallvec",
1986
+
"zeroize",
1987
+
]
1988
+
1989
+
[[package]]
1649
1990
name = "num-conv"
1650
1991
version = "0.1.0"
1651
1992
source = "registry+https://github.com/rust-lang/crates.io-index"
1652
1993
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
1653
1994
1654
1995
[[package]]
1996
+
name = "num-integer"
1997
+
version = "0.1.46"
1998
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1999
+
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
2000
+
dependencies = [
2001
+
"num-traits",
2002
+
]
2003
+
2004
+
[[package]]
2005
+
name = "num-iter"
2006
+
version = "0.1.45"
2007
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2008
+
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
2009
+
dependencies = [
2010
+
"autocfg",
2011
+
"num-integer",
2012
+
"num-traits",
2013
+
]
2014
+
2015
+
[[package]]
1655
2016
name = "num-traits"
1656
2017
version = "0.2.19"
1657
2018
source = "registry+https://github.com/rust-lang/crates.io-index"
1658
2019
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1659
2020
dependencies = [
1660
2021
"autocfg",
2022
+
"libm",
2023
+
]
2024
+
2025
+
[[package]]
2026
+
name = "num_cpus"
2027
+
version = "1.17.0"
2028
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2029
+
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2030
+
dependencies = [
2031
+
"hermit-abi",
2032
+
"libc",
2033
+
]
2034
+
2035
+
[[package]]
2036
+
name = "num_threads"
2037
+
version = "0.1.7"
2038
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2039
+
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
2040
+
dependencies = [
2041
+
"libc",
1661
2042
]
1662
2043
1663
2044
[[package]]
···
1717
2098
source = "registry+https://github.com/rust-lang/crates.io-index"
1718
2099
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1719
2100
dependencies = [
2101
+
"ecdsa",
2102
+
"elliptic-curve",
2103
+
"primeorder",
2104
+
"sha2",
2105
+
]
2106
+
2107
+
[[package]]
2108
+
name = "p384"
2109
+
version = "0.13.1"
2110
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2111
+
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
2112
+
dependencies = [
1720
2113
"elliptic-curve",
1721
2114
"primeorder",
1722
2115
]
···
1742
2135
"redox_syscall",
1743
2136
"smallvec",
1744
2137
"windows-link 0.2.0",
2138
+
]
2139
+
2140
+
[[package]]
2141
+
name = "pem-rfc7468"
2142
+
version = "0.7.0"
2143
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2144
+
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
2145
+
dependencies = [
2146
+
"base64ct",
1745
2147
]
1746
2148
1747
2149
[[package]]
···
1763
2165
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1764
2166
1765
2167
[[package]]
2168
+
name = "pkcs1"
2169
+
version = "0.7.5"
2170
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2171
+
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
2172
+
dependencies = [
2173
+
"der",
2174
+
"pkcs8",
2175
+
"spki",
2176
+
]
2177
+
2178
+
[[package]]
1766
2179
name = "pkcs8"
1767
2180
version = "0.10.2"
1768
2181
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1860
2273
"version_check",
1861
2274
"yansi",
1862
2275
]
2276
+
2277
+
[[package]]
2278
+
name = "quick-error"
2279
+
version = "1.2.3"
2280
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2281
+
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1863
2282
1864
2283
[[package]]
1865
2284
name = "quinn"
···
2061
2480
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
2062
2481
dependencies = [
2063
2482
"async-compression",
2064
-
"base64",
2483
+
"base64 0.22.1",
2065
2484
"bytes",
2066
2485
"encoding_rs",
2067
2486
"futures-core",
···
2105
2524
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
2106
2525
2107
2526
[[package]]
2527
+
name = "rfc6979"
2528
+
version = "0.4.0"
2529
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2530
+
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
2531
+
dependencies = [
2532
+
"hmac",
2533
+
"subtle",
2534
+
]
2535
+
2536
+
[[package]]
2108
2537
name = "ring"
2109
2538
version = "0.17.14"
2110
2539
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2119
2548
]
2120
2549
2121
2550
[[package]]
2551
+
name = "rouille"
2552
+
version = "3.6.2"
2553
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2554
+
checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921"
2555
+
dependencies = [
2556
+
"base64 0.13.1",
2557
+
"brotli",
2558
+
"chrono",
2559
+
"deflate",
2560
+
"filetime",
2561
+
"multipart",
2562
+
"percent-encoding",
2563
+
"rand 0.8.5",
2564
+
"serde",
2565
+
"serde_derive",
2566
+
"serde_json",
2567
+
"sha1_smol",
2568
+
"threadpool",
2569
+
"time",
2570
+
"tiny_http",
2571
+
"url",
2572
+
]
2573
+
2574
+
[[package]]
2575
+
name = "rsa"
2576
+
version = "0.9.8"
2577
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2578
+
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
2579
+
dependencies = [
2580
+
"const-oid",
2581
+
"digest",
2582
+
"num-bigint-dig",
2583
+
"num-integer",
2584
+
"num-traits",
2585
+
"pkcs1",
2586
+
"pkcs8",
2587
+
"rand_core 0.6.4",
2588
+
"signature",
2589
+
"spki",
2590
+
"subtle",
2591
+
"zeroize",
2592
+
]
2593
+
2594
+
[[package]]
2122
2595
name = "rustc-demangle"
2123
2596
version = "0.1.26"
2124
2597
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2200
2673
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
2201
2674
2202
2675
[[package]]
2676
+
name = "safemem"
2677
+
version = "0.3.3"
2678
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2679
+
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
2680
+
2681
+
[[package]]
2203
2682
name = "schemars"
2204
2683
version = "0.9.0"
2205
2684
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2238
2717
"base16ct",
2239
2718
"der",
2240
2719
"generic-array",
2720
+
"pkcs8",
2241
2721
"subtle",
2242
2722
"zeroize",
2243
2723
]
···
2354
2834
source = "registry+https://github.com/rust-lang/crates.io-index"
2355
2835
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
2356
2836
dependencies = [
2357
-
"base64",
2837
+
"base64 0.22.1",
2358
2838
"chrono",
2359
2839
"hex",
2360
2840
"indexmap 1.9.3",
···
2381
2861
]
2382
2862
2383
2863
[[package]]
2864
+
name = "sha1_smol"
2865
+
version = "1.0.1"
2866
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2867
+
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
2868
+
2869
+
[[package]]
2384
2870
name = "sha2"
2385
2871
version = "0.10.9"
2386
2872
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2402
2888
version = "2.2.0"
2403
2889
source = "registry+https://github.com/rust-lang/crates.io-index"
2404
2890
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2891
+
dependencies = [
2892
+
"digest",
2893
+
"rand_core 0.6.4",
2894
+
]
2405
2895
2406
2896
[[package]]
2407
2897
name = "slab"
···
2446
2936
]
2447
2937
2448
2938
[[package]]
2939
+
name = "spin"
2940
+
version = "0.9.8"
2941
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2942
+
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
2943
+
2944
+
[[package]]
2449
2945
name = "spki"
2450
2946
version = "0.7.3"
2451
2947
source = "registry+https://github.com/rust-lang/crates.io-index"
2452
2948
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
2453
2949
dependencies = [
2950
+
"base64ct",
2454
2951
"der",
2455
2952
]
2456
2953
···
2655
3152
]
2656
3153
2657
3154
[[package]]
3155
+
name = "threadpool"
3156
+
version = "1.8.1"
3157
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3158
+
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
3159
+
dependencies = [
3160
+
"num_cpus",
3161
+
]
3162
+
3163
+
[[package]]
2658
3164
name = "time"
2659
3165
version = "0.3.44"
2660
3166
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2662
3168
dependencies = [
2663
3169
"deranged",
2664
3170
"itoa",
3171
+
"libc",
2665
3172
"num-conv",
3173
+
"num_threads",
2666
3174
"powerfmt",
2667
3175
"serde",
2668
3176
"time-core",
···
2683
3191
dependencies = [
2684
3192
"num-conv",
2685
3193
"time-core",
3194
+
]
3195
+
3196
+
[[package]]
3197
+
name = "tiny_http"
3198
+
version = "0.12.0"
3199
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3200
+
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
3201
+
dependencies = [
3202
+
"ascii",
3203
+
"chunked_transfer",
3204
+
"httpdate",
3205
+
"log",
2686
3206
]
2687
3207
2688
3208
[[package]]
···
2839
3359
]
2840
3360
2841
3361
[[package]]
3362
+
name = "trait-variant"
3363
+
version = "0.1.2"
3364
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3365
+
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
3366
+
dependencies = [
3367
+
"proc-macro2",
3368
+
"quote",
3369
+
"syn 2.0.106",
3370
+
]
3371
+
3372
+
[[package]]
2842
3373
name = "try-lock"
2843
3374
version = "0.2.5"
2844
3375
source = "registry+https://github.com/rust-lang/crates.io-index"
2845
3376
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2846
3377
2847
3378
[[package]]
3379
+
name = "twoway"
3380
+
version = "0.1.8"
3381
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3382
+
checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1"
3383
+
dependencies = [
3384
+
"memchr",
3385
+
]
3386
+
3387
+
[[package]]
2848
3388
name = "typenum"
2849
3389
version = "1.18.0"
2850
3390
source = "registry+https://github.com/rust-lang/crates.io-index"
2851
3391
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
3392
+
3393
+
[[package]]
3394
+
name = "unicase"
3395
+
version = "2.8.1"
3396
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3397
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
2852
3398
2853
3399
[[package]]
2854
3400
name = "unicode-ident"
···
2915
3461
version = "0.2.2"
2916
3462
source = "registry+https://github.com/rust-lang/crates.io-index"
2917
3463
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3464
+
3465
+
[[package]]
3466
+
name = "uuid"
3467
+
version = "1.18.1"
3468
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3469
+
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
3470
+
dependencies = [
3471
+
"getrandom 0.3.3",
3472
+
"js-sys",
3473
+
"wasm-bindgen",
3474
+
]
2918
3475
2919
3476
[[package]]
2920
3477
name = "version_check"
···
3476
4033
version = "1.8.2"
3477
4034
source = "registry+https://github.com/rust-lang/crates.io-index"
3478
4035
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
4036
+
dependencies = [
4037
+
"serde",
4038
+
]
3479
4039
3480
4040
[[package]]
3481
4041
name = "zerotrie"
+24
-1
Cargo.toml
+24
-1
Cargo.toml
···
5
5
6
6
[workspace.package]
7
7
edition = "2024"
8
-
version = "0.2.0"
8
+
version = "0.3.0"
9
9
authors = ["Orual <orual@nonbinary.computer>"]
10
10
repository = "https://tangled.org/@nonbinary.computer/jacquard"
11
11
keywords = ["atproto", "at", "bluesky", "api", "client"]
···
35
35
miette = "7.6"
36
36
thiserror = "2.0"
37
37
38
+
# trait stuff
39
+
trait-variant = "0.1.2"
40
+
41
+
42
+
bon = "3.8.0"
43
+
38
44
# Data types
39
45
bytes = "1.10"
40
46
smol_str = { version = "0.3", features = ["serde"] }
···
51
57
# HTTP
52
58
http = "1.3"
53
59
reqwest = { version = "0.12", default-features = false }
60
+
61
+
# Async and runtimes
62
+
async-trait = "0.1"
63
+
tokio = "1"
64
+
65
+
# Encoding and crypto building blocks
66
+
base64 = "0.22"
67
+
percent-encoding = "2.3"
68
+
urlencoding = "2.1.3"
69
+
rand_core = "0.6"
70
+
71
+
# Time
72
+
chrono = "0.4"
73
+
74
+
# Crypto curves and JOSE
75
+
p256 = "0.13"
76
+
jose-jwk = "0.1"
+22
-31
README.md
+22
-31
README.md
···
18
18
19
19
## Example
20
20
21
-
Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
21
+
Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline.
22
22
23
23
```rust
24
+
use std::sync::Arc;
24
25
use clap::Parser;
25
26
use jacquard::CowStr;
26
27
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
27
-
use jacquard::api::com_atproto::server::create_session::CreateSession;
28
-
use jacquard::client::{BasicClient, Session};
28
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
29
+
use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
30
+
use jacquard::identity::PublicResolver as JacquardResolver;
29
31
use miette::IntoDiagnostic;
30
32
31
33
#[derive(Parser, Debug)]
32
34
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
33
35
struct Args {
34
-
/// Username/handle (e.g., alice.mosphere.at)
36
+
/// Username/handle (e.g., alice.bsky.social) or DID
35
37
#[arg(short, long)]
36
38
username: CowStr<'static>,
37
-
38
-
/// PDS URL (e.g., https://bsky.social)
39
-
#[arg(long, default_value = "https://bsky.social")]
40
-
pds: CowStr<'static>,
41
-
42
39
/// App password
43
40
#[arg(short, long)]
44
41
password: CowStr<'static>,
···
48
45
async fn main() -> miette::Result<()> {
49
46
let args = Args::parse();
50
47
51
-
// Create HTTP client
52
-
let base = url::Url::parse(&args.pds).into_diagnostic()?;
53
-
let client = BasicClient::new(base);
48
+
// Resolver + storage
49
+
let resolver = Arc::new(JacquardResolver::default());
50
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
51
+
let client = Arc::new(resolver.clone());
52
+
let session = CredentialSession::new(store, client);
54
53
55
-
// Create session
56
-
let session = Session::from(
57
-
client
58
-
.send(
59
-
CreateSession::new()
60
-
.identifier(args.username)
61
-
.password(args.password)
62
-
.build(),
63
-
)
64
-
.await?
65
-
.into_output()?,
66
-
);
67
-
68
-
println!("logged in as {} ({})", session.handle, session.did);
69
-
client.set_session(session).await.into_diagnostic()?;
54
+
// Login (resolves PDS automatically) and persist as (did, "session")
55
+
session
56
+
.login(args.username.clone(), args.password.clone(), None, None, None)
57
+
.await
58
+
.into_diagnostic()?;
70
59
71
60
// Fetch timeline
72
-
println!("\nfetching timeline...");
73
-
let timeline = client
61
+
let timeline = session
62
+
.clone()
74
63
.send(GetTimeline::new().limit(5).build())
75
-
.await?
76
-
.into_output()?;
64
+
.await
65
+
.into_diagnostic()?
66
+
.into_output()
67
+
.into_diagnostic()?;
77
68
78
69
println!("\ntimeline ({} posts):", timeline.feed.len());
79
70
for (i, post) in timeline.feed.iter().enumerate() {
+19
-12
crates/jacquard/Cargo.toml
+19
-12
crates/jacquard/Cargo.toml
···
2
2
name = "jacquard"
3
3
description.workspace = true
4
4
edition.workspace = true
5
-
version = "0.2.1"
5
+
version = "0.3.0"
6
6
authors.workspace = true
7
7
repository.workspace = true
8
8
keywords.workspace = true
···
12
12
license.workspace = true
13
13
14
14
[features]
15
-
default = ["api_all", "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 = ["jacquard-identity/dns"]
20
20
fancy = ["miette/fancy"]
21
+
loopback = ["dep:rouille"]
21
22
22
23
[lib]
23
24
name = "jacquard"
···
29
30
30
31
31
32
[dependencies]
32
-
bon = "3"
33
-
async-trait = "0.1"
33
+
jacquard-api = { version = "0.3.0", path = "../jacquard-api" }
34
+
jacquard-common = { version = "0.3.0", path = "../jacquard-common", features = ["reqwest-client"] }
35
+
jacquard-oauth = { version = "0.3.0", path = "../jacquard-oauth" }
36
+
jacquard-derive = { version = "0.3.0", path = "../jacquard-derive", optional = true }
37
+
jacquard-identity = { version = "0.3.0", path = "../jacquard-identity" }
38
+
39
+
bon.workspace = true
40
+
async-trait.workspace = true
34
41
bytes.workspace = true
35
42
clap.workspace = true
36
43
http.workspace = true
37
-
jacquard-api = { version = "0.2.0", path = "../jacquard-api" }
38
-
jacquard-common = { version = "0.2.0", path = "../jacquard-common" }
39
-
jacquard-derive = { version = "0.2.0", path = "../jacquard-derive", optional = true }
40
44
miette = { workspace = true }
41
45
reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] }
42
46
serde.workspace = true
···
44
48
serde_ipld_dagcbor.workspace = true
45
49
serde_json.workspace = true
46
50
thiserror.workspace = true
47
-
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
48
-
hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true }
51
+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
49
52
url.workspace = true
50
53
smol_str.workspace = true
51
-
percent-encoding = "2"
52
-
urlencoding = "2"
54
+
percent-encoding.workspace = true
55
+
urlencoding.workspace = true
56
+
jose-jwk = { workspace = true, features = ["p256"] }
57
+
p256 = { workspace = true, features = ["ecdsa"] }
58
+
rand_core.workspace = true
59
+
rouille = { version = "3.6.2", optional = true }
-232
crates/jacquard/src/client/at_client.rs
-232
crates/jacquard/src/client/at_client.rs
···
1
-
use bytes::Bytes;
2
-
use url::Url;
3
-
4
-
use crate::client::xrpc_call::{CallOptions, XrpcExt};
5
-
use crate::client::{self as super_mod, AuthorizationToken, HttpClient, Response, Session, error};
6
-
use jacquard_common::types::xrpc::XrpcRequest;
7
-
8
-
use super::token::TokenStore;
9
-
10
-
/// Per-call overrides when sending via `AtClient`.
11
-
#[derive(Debug, Default, Clone)]
12
-
pub struct SendOverrides<'a> {
13
-
/// Optional base URI override for this call.
14
-
pub base_uri: Option<Url>,
15
-
/// Per-request options such as auth, proxy, labelers, extra headers.
16
-
pub options: CallOptions<'a>,
17
-
/// Whether to auto-refresh on expired/invalid token and retry once.
18
-
pub auto_refresh: bool,
19
-
}
20
-
21
-
impl<'a> SendOverrides<'a> {
22
-
/// Construct default overrides (no base override, auto-refresh enabled).
23
-
pub fn new() -> Self {
24
-
Self {
25
-
base_uri: None,
26
-
options: CallOptions::default(),
27
-
auto_refresh: true,
28
-
}
29
-
}
30
-
/// Override the base URI for this call only.
31
-
pub fn base_uri(mut self, base: Url) -> Self {
32
-
self.base_uri = Some(base);
33
-
self
34
-
}
35
-
/// Provide a full set of call options (auth/headers/etc.).
36
-
pub fn options(mut self, opts: CallOptions<'a>) -> Self {
37
-
self.options = opts;
38
-
self
39
-
}
40
-
/// Enable or disable one-shot auto-refresh + retry behavior.
41
-
pub fn auto_refresh(mut self, enable: bool) -> Self {
42
-
self.auto_refresh = enable;
43
-
self
44
-
}
45
-
}
46
-
47
-
/// Stateful client for AT Protocol XRPC with token storage and auto-refresh.
48
-
///
49
-
/// Example (file-backed tokens)
50
-
/// ```ignore
51
-
/// use jacquard::client::{AtClient, FileTokenStore, TokenStore};
52
-
/// use jacquard::api::com_atproto::server::create_session::CreateSession;
53
-
/// use jacquard::client::AtClient as _; // method resolution
54
-
/// use jacquard::CowStr;
55
-
///
56
-
/// #[tokio::main]
57
-
/// async fn main() -> miette::Result<()> {
58
-
/// let base = url::Url::parse("https://bsky.social")?;
59
-
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
60
-
/// let client = AtClient::new(reqwest::Client::new(), base, store);
61
-
/// let session = client
62
-
/// .send(
63
-
/// CreateSession::new()
64
-
/// .identifier(CowStr::from("alice.example"))
65
-
/// .password(CowStr::from("app-password"))
66
-
/// .build(),
67
-
/// )
68
-
/// .await?
69
-
/// .into_output()?;
70
-
/// client.set_session(session.into()).await?;
71
-
/// Ok(())
72
-
/// }
73
-
/// ```
74
-
pub struct AtClient<C: HttpClient, S: TokenStore> {
75
-
transport: C,
76
-
base: Url,
77
-
tokens: S,
78
-
refresh_lock: tokio::sync::Mutex<()>,
79
-
}
80
-
81
-
impl<C: HttpClient, S: TokenStore> AtClient<C, S> {
82
-
/// Create a new client with a transport, base URL, and token store.
83
-
pub fn new(transport: C, base: Url, tokens: S) -> Self {
84
-
Self {
85
-
transport,
86
-
base,
87
-
tokens,
88
-
refresh_lock: tokio::sync::Mutex::new(()),
89
-
}
90
-
}
91
-
92
-
/// Get the base URL of this client.
93
-
pub fn base(&self) -> &Url {
94
-
&self.base
95
-
}
96
-
97
-
/// Access the underlying transport.
98
-
pub fn transport(&self) -> &C {
99
-
&self.transport
100
-
}
101
-
102
-
/// Get the current session, if any.
103
-
pub async fn session(&self) -> Option<Session> {
104
-
self.tokens.get().await
105
-
}
106
-
107
-
/// Set the current session in the token store.
108
-
pub async fn set_session(&self, session: Session) -> Result<(), super_mod::TokenStoreError> {
109
-
self.tokens.set(session).await
110
-
}
111
-
112
-
/// Clear the current session from the token store.
113
-
pub async fn clear_session(&self) -> Result<(), super_mod::TokenStoreError> {
114
-
self.tokens.clear().await
115
-
}
116
-
117
-
/// Send an XRPC request using the client's base URL and default behavior.
118
-
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> super_mod::Result<Response<R>> {
119
-
self.send_with(req, SendOverrides::new()).await
120
-
}
121
-
122
-
/// Send an XRPC request with per-call overrides.
123
-
pub async fn send_with<R: XrpcRequest + Send>(
124
-
&self,
125
-
req: R,
126
-
mut overrides: SendOverrides<'_>,
127
-
) -> super_mod::Result<Response<R>> {
128
-
let base = overrides
129
-
.base_uri
130
-
.clone()
131
-
.unwrap_or_else(|| self.base.clone());
132
-
let is_refresh = R::NSID == super_mod::NSID_REFRESH_SESSION;
133
-
134
-
if overrides.options.auth.is_none() {
135
-
if let Some(s) = self.tokens.get().await {
136
-
overrides.options.auth = Some(if is_refresh {
137
-
AuthorizationToken::Bearer(s.refresh_jwt)
138
-
} else {
139
-
AuthorizationToken::Bearer(s.access_jwt)
140
-
});
141
-
}
142
-
}
143
-
144
-
let http_request = super_mod::build_http_request(&base, &req, &overrides.options)
145
-
.map_err(error::TransportError::from)?;
146
-
let http_response = self
147
-
.transport
148
-
.send_http(http_request)
149
-
.await
150
-
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
151
-
let status = http_response.status();
152
-
let buffer = Bytes::from(http_response.into_body());
153
-
154
-
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
155
-
return Err(error::HttpError {
156
-
status,
157
-
body: Some(buffer),
158
-
}
159
-
.into());
160
-
}
161
-
162
-
if overrides.auto_refresh
163
-
&& !is_refresh
164
-
&& overrides.options.auth.is_some()
165
-
&& Self::is_auth_expired(status, &buffer)
166
-
{
167
-
self.refresh_once().await?;
168
-
169
-
let mut retry_opts = overrides.options.clone();
170
-
if let Some(s) = self.tokens.get().await {
171
-
retry_opts.auth = Some(AuthorizationToken::Bearer(s.access_jwt));
172
-
}
173
-
let http_request = super_mod::build_http_request(&base, &req, &retry_opts)
174
-
.map_err(error::TransportError::from)?;
175
-
let http_response = self
176
-
.transport
177
-
.send_http(http_request)
178
-
.await
179
-
.map_err(|e| error::TransportError::Other(Box::new(e)))?;
180
-
let status = http_response.status();
181
-
let buffer = Bytes::from(http_response.into_body());
182
-
183
-
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
184
-
return Err(error::HttpError {
185
-
status,
186
-
body: Some(buffer),
187
-
}
188
-
.into());
189
-
}
190
-
return Ok(Response::new(buffer, status));
191
-
}
192
-
193
-
Ok(Response::new(buffer, status))
194
-
}
195
-
196
-
async fn refresh_once(&self) -> super_mod::Result<()> {
197
-
let _guard = self.refresh_lock.lock().await;
198
-
let Some(s) = self.tokens.get().await else {
199
-
return Err(error::ClientError::Auth(error::AuthError::NotAuthenticated));
200
-
};
201
-
let refresh_token = s.refresh_jwt.clone();
202
-
let refresh_resp = self
203
-
.transport
204
-
.xrpc(self.base.clone())
205
-
.auth(AuthorizationToken::Bearer(refresh_token))
206
-
.send(jacquard_api::com_atproto::server::refresh_session::RefreshSession)
207
-
.await?;
208
-
let refreshed = match refresh_resp.into_output() {
209
-
Ok(o) => Session::from(o),
210
-
Err(_) => return Err(error::ClientError::Auth(error::AuthError::RefreshFailed)),
211
-
};
212
-
self.tokens
213
-
.set(refreshed)
214
-
.await
215
-
.map_err(|_| error::ClientError::Auth(error::AuthError::RefreshFailed))?;
216
-
Ok(())
217
-
}
218
-
219
-
fn is_auth_expired(status: http::StatusCode, buffer: &Bytes) -> bool {
220
-
if status.as_u16() == 401 {
221
-
return true;
222
-
}
223
-
if status.as_u16() == 400 {
224
-
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(buffer) {
225
-
if let Some(code) = val.get("error").and_then(|v| v.as_str()) {
226
-
return matches!(code, "ExpiredToken" | "InvalidToken");
227
-
}
228
-
}
229
-
}
230
-
false
231
-
}
232
-
}
+462
crates/jacquard/src/client/credential_session.rs
+462
crates/jacquard/src/client/credential_session.rs
···
1
+
use std::sync::Arc;
2
+
3
+
use jacquard_api::com_atproto::server::refresh_session::RefreshSession;
4
+
use jacquard_common::{
5
+
AuthorizationToken, CowStr, IntoStatic,
6
+
error::{AuthError, ClientError, XrpcResult},
7
+
http_client::HttpClient,
8
+
session::SessionStore,
9
+
types::{
10
+
did::Did,
11
+
xrpc::{CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest},
12
+
},
13
+
};
14
+
use tokio::sync::RwLock;
15
+
use url::Url;
16
+
17
+
use crate::client::AtpSession;
18
+
use jacquard_identity::resolver::IdentityResolver;
19
+
use std::any::Any;
20
+
21
+
/// Storage key for appโpassword sessions: `(account DID, session id)`.
22
+
pub type SessionKey = (Did<'static>, CowStr<'static>);
23
+
24
+
/// Stateful client for appโpassword based sessions.
25
+
///
26
+
/// - Persists sessions via a pluggable `SessionStore`.
27
+
/// - Automatically refreshes on token expiry.
28
+
/// - Tracks a base endpoint, defaulting to the public appview until login/restore.
29
+
pub struct CredentialSession<S, T>
30
+
where
31
+
S: SessionStore<SessionKey, AtpSession>,
32
+
{
33
+
store: Arc<S>,
34
+
client: Arc<T>,
35
+
/// Default call options applied to each request (auth/headers/labelers).
36
+
pub options: RwLock<CallOptions<'static>>,
37
+
/// Active session key, if any.
38
+
pub key: RwLock<Option<SessionKey>>,
39
+
/// Current base endpoint (PDS); defaults to public appview when unset.
40
+
pub endpoint: RwLock<Option<Url>>,
41
+
}
42
+
43
+
impl<S, T> CredentialSession<S, T>
44
+
where
45
+
S: SessionStore<SessionKey, AtpSession>,
46
+
{
47
+
/// Create a new credential session using the given store and client.
48
+
pub fn new(store: Arc<S>, client: Arc<T>) -> Self {
49
+
Self {
50
+
store,
51
+
client,
52
+
options: RwLock::new(CallOptions::default()),
53
+
key: RwLock::new(None),
54
+
endpoint: RwLock::new(None),
55
+
}
56
+
}
57
+
}
58
+
59
+
impl<S, T> CredentialSession<S, T>
60
+
where
61
+
S: SessionStore<SessionKey, AtpSession>,
62
+
{
63
+
/// Return a copy configured with the provided default call options.
64
+
pub fn with_options(self, options: CallOptions<'_>) -> Self {
65
+
Self {
66
+
client: self.client,
67
+
store: self.store,
68
+
options: RwLock::new(options.into_static()),
69
+
key: self.key,
70
+
endpoint: self.endpoint,
71
+
}
72
+
}
73
+
74
+
/// Replace default call options.
75
+
pub async fn set_options(&self, options: CallOptions<'_>) {
76
+
*self.options.write().await = options.into_static();
77
+
}
78
+
79
+
/// Get the active session key (account DID and session id), if any.
80
+
pub async fn session_info(&self) -> Option<SessionKey> {
81
+
self.key.read().await.clone()
82
+
}
83
+
84
+
/// Current base endpoint. Defaults to the public appview when unset.
85
+
pub async fn endpoint(&self) -> Url {
86
+
self.endpoint.read().await.clone().unwrap_or(
87
+
Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
88
+
)
89
+
}
90
+
91
+
/// Override the current base endpoint.
92
+
pub async fn set_endpoint(&self, endpoint: Url) {
93
+
*self.endpoint.write().await = Some(endpoint);
94
+
}
95
+
96
+
/// Current access token (Bearer), if logged in.
97
+
pub async fn access_token(&self) -> Option<AuthorizationToken<'_>> {
98
+
let key = self.key.read().await.clone()?;
99
+
let session = self.store.get(&key).await;
100
+
session.map(|session| AuthorizationToken::Bearer(session.access_jwt))
101
+
}
102
+
103
+
/// Current refresh token (Bearer), if logged in.
104
+
pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
105
+
let key = self.key.read().await.clone()?;
106
+
let session = self.store.get(&key).await;
107
+
session.map(|session| AuthorizationToken::Bearer(session.refresh_jwt))
108
+
}
109
+
}
110
+
111
+
impl<S, T> CredentialSession<S, T>
112
+
where
113
+
S: SessionStore<SessionKey, AtpSession>,
114
+
T: HttpClient,
115
+
{
116
+
/// Refresh the active session by calling `com.atproto.server.refreshSession`.
117
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> {
118
+
let key = self.key.read().await.clone().ok_or(ClientError::Auth(
119
+
jacquard_common::error::AuthError::NotAuthenticated,
120
+
))?;
121
+
let session = self.store.get(&key).await;
122
+
let endpoint = self.endpoint().await;
123
+
let mut opts = self.options.read().await.clone();
124
+
opts.auth = session.map(|s| AuthorizationToken::Bearer(s.refresh_jwt));
125
+
let response = self
126
+
.client
127
+
.xrpc(endpoint)
128
+
.with_options(opts)
129
+
.send(&RefreshSession)
130
+
.await?;
131
+
let refresh = response
132
+
.into_output()
133
+
.map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?;
134
+
135
+
let new_session: AtpSession = refresh.into();
136
+
let token = AuthorizationToken::Bearer(new_session.access_jwt.clone());
137
+
self.store
138
+
.set(key, new_session)
139
+
.await
140
+
.map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?;
141
+
142
+
Ok(token)
143
+
}
144
+
}
145
+
146
+
impl<S, T> CredentialSession<S, T>
147
+
where
148
+
S: SessionStore<SessionKey, AtpSession>,
149
+
T: HttpClient + IdentityResolver + XrpcExt,
150
+
{
151
+
/// Resolve the user's PDS and create an app-password session.
152
+
///
153
+
/// - `identifier`: handle (preferred), DID, or `https://` PDS base URL.
154
+
/// - `session_id`: optional session label; defaults to "session".
155
+
/// - Persists and activates the session, and updates the base endpoint to the user's PDS.
156
+
pub async fn login(
157
+
&self,
158
+
identifier: CowStr<'_>,
159
+
password: CowStr<'_>,
160
+
session_id: Option<CowStr<'_>>,
161
+
allow_takendown: Option<bool>,
162
+
auth_factor_token: Option<CowStr<'_>>,
163
+
) -> Result<AtpSession, ClientError>
164
+
where
165
+
S: Any + 'static,
166
+
{
167
+
// Resolve PDS base
168
+
let pds = if identifier.as_ref().starts_with("http://")
169
+
|| identifier.as_ref().starts_with("https://")
170
+
{
171
+
Url::parse(identifier.as_ref()).map_err(|e| {
172
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
173
+
e.to_string(),
174
+
))
175
+
})?
176
+
} else if identifier.as_ref().starts_with("did:") {
177
+
let did = Did::new(identifier.as_ref()).map_err(|e| {
178
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
179
+
format!("invalid did: {:?}", e),
180
+
))
181
+
})?;
182
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
183
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
184
+
})?;
185
+
resp.into_owned()
186
+
.map_err(|e| {
187
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
188
+
e,
189
+
)))
190
+
})?
191
+
.pds_endpoint()
192
+
.ok_or_else(|| {
193
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
194
+
"missing PDS endpoint".into(),
195
+
))
196
+
})?
197
+
} else {
198
+
// treat as handle
199
+
let handle =
200
+
jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| {
201
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
202
+
format!("invalid handle: {:?}", e),
203
+
))
204
+
})?;
205
+
let did = self.client.resolve_handle(&handle).await.map_err(|e| {
206
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
207
+
})?;
208
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
209
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
210
+
})?;
211
+
resp.into_owned()
212
+
.map_err(|e| {
213
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
214
+
e,
215
+
)))
216
+
})?
217
+
.pds_endpoint()
218
+
.ok_or_else(|| {
219
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
220
+
"missing PDS endpoint".into(),
221
+
))
222
+
})?
223
+
};
224
+
225
+
// Build and send createSession
226
+
use std::collections::BTreeMap;
227
+
let req = jacquard_api::com_atproto::server::create_session::CreateSession {
228
+
allow_takendown,
229
+
auth_factor_token,
230
+
identifier: identifier.clone().into_static(),
231
+
password: password.into_static(),
232
+
extra_data: BTreeMap::new(),
233
+
};
234
+
235
+
let resp = self
236
+
.client
237
+
.xrpc(pds.clone())
238
+
.with_options(self.options.read().await.clone())
239
+
.send(&req)
240
+
.await?;
241
+
let out = resp
242
+
.into_output()
243
+
.map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?;
244
+
let session = AtpSession::from(out);
245
+
246
+
let sid = session_id.unwrap_or_else(|| CowStr::new_static("session"));
247
+
let key = (session.did.clone(), sid.into_static());
248
+
self.store
249
+
.set(key.clone(), session.clone())
250
+
.await
251
+
.map_err(|e| {
252
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
253
+
})?;
254
+
// If using FileAuthStore, persist PDS for faster resume
255
+
if let Some(file_store) =
256
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
257
+
{
258
+
let _ = file_store.set_atp_pds(&key, &pds);
259
+
}
260
+
// Activate
261
+
*self.key.write().await = Some(key);
262
+
*self.endpoint.write().await = Some(pds);
263
+
264
+
Ok(session)
265
+
}
266
+
267
+
/// Restore a previously persisted app-password session and set base endpoint.
268
+
pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError>
269
+
where
270
+
S: Any + 'static,
271
+
{
272
+
let key = (did.clone().into_static(), session_id.clone().into_static());
273
+
let Some(sess) = self.store.get(&key).await else {
274
+
return Err(ClientError::Auth(AuthError::NotAuthenticated));
275
+
};
276
+
// Try to read cached PDS; otherwise resolve via DID
277
+
let pds = if let Some(file_store) =
278
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
279
+
{
280
+
file_store.get_atp_pds(&key).ok().flatten().or_else(|| None)
281
+
} else {
282
+
None
283
+
}
284
+
.unwrap_or({
285
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
286
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
287
+
})?;
288
+
resp.into_owned()
289
+
.map_err(|e| {
290
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
291
+
e,
292
+
)))
293
+
})?
294
+
.pds_endpoint()
295
+
.ok_or_else(|| {
296
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
297
+
"missing PDS endpoint".into(),
298
+
))
299
+
})?
300
+
});
301
+
302
+
// Activate
303
+
*self.key.write().await = Some(key.clone());
304
+
*self.endpoint.write().await = Some(pds);
305
+
// ensure store has the session (no-op if it existed)
306
+
self.store
307
+
.set((sess.did.clone(), session_id.into_static()), sess)
308
+
.await
309
+
.map_err(|e| {
310
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
311
+
})?;
312
+
if let Some(file_store) =
313
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
314
+
{
315
+
let _ = file_store.set_atp_pds(&key, &self.endpoint().await);
316
+
}
317
+
Ok(())
318
+
}
319
+
320
+
/// Switch to a different stored session (and refresh endpoint/PDS).
321
+
pub async fn switch_session(
322
+
&self,
323
+
did: Did<'_>,
324
+
session_id: CowStr<'_>,
325
+
) -> Result<(), ClientError>
326
+
where
327
+
S: Any + 'static,
328
+
{
329
+
let key = (did.clone().into_static(), session_id.into_static());
330
+
if self.store.get(&key).await.is_none() {
331
+
return Err(ClientError::Auth(AuthError::NotAuthenticated));
332
+
}
333
+
// Endpoint from store if cached, else resolve
334
+
let pds = if let Some(file_store) =
335
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
336
+
{
337
+
file_store.get_atp_pds(&key).ok().flatten().or_else(|| None)
338
+
} else {
339
+
None
340
+
}
341
+
.unwrap_or({
342
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
343
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
344
+
})?;
345
+
resp.into_owned()
346
+
.map_err(|e| {
347
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
348
+
e,
349
+
)))
350
+
})?
351
+
.pds_endpoint()
352
+
.ok_or_else(|| {
353
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
354
+
"missing PDS endpoint".into(),
355
+
))
356
+
})?
357
+
});
358
+
*self.key.write().await = Some(key.clone());
359
+
*self.endpoint.write().await = Some(pds);
360
+
if let Some(file_store) =
361
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
362
+
{
363
+
let _ = file_store.set_atp_pds(&key, &self.endpoint().await);
364
+
}
365
+
Ok(())
366
+
}
367
+
368
+
/// Clear and delete the current session from the store.
369
+
pub async fn logout(&self) -> Result<(), ClientError> {
370
+
let Some(key) = self.key.read().await.clone() else {
371
+
return Ok(());
372
+
};
373
+
self.store.del(&key).await.map_err(|e| {
374
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
375
+
})?;
376
+
*self.key.write().await = None;
377
+
Ok(())
378
+
}
379
+
}
380
+
381
+
impl<S, T> HttpClient for CredentialSession<S, T>
382
+
where
383
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
384
+
T: HttpClient + XrpcExt + Send + Sync + 'static,
385
+
{
386
+
type Error = T::Error;
387
+
388
+
async fn send_http(
389
+
&self,
390
+
request: http::Request<Vec<u8>>,
391
+
) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
392
+
self.client.send_http(request).await
393
+
}
394
+
}
395
+
396
+
impl<S, T> XrpcClient for CredentialSession<S, T>
397
+
where
398
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
399
+
T: HttpClient + XrpcExt + Send + Sync + 'static,
400
+
{
401
+
fn base_uri(&self) -> Url {
402
+
// base_uri is a synchronous trait method; avoid `.await` here.
403
+
// Under Tokio, use `block_in_place` to make a blocking RwLock read safe.
404
+
if tokio::runtime::Handle::try_current().is_ok() {
405
+
tokio::task::block_in_place(|| {
406
+
self.endpoint
407
+
.blocking_read()
408
+
.clone()
409
+
.unwrap_or(
410
+
Url::parse("https://public.bsky.app")
411
+
.expect("public appview should be valid url"),
412
+
)
413
+
})
414
+
} else {
415
+
self.endpoint
416
+
.blocking_read()
417
+
.clone()
418
+
.unwrap_or(
419
+
Url::parse("https://public.bsky.app")
420
+
.expect("public appview should be valid url"),
421
+
)
422
+
}
423
+
}
424
+
async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>(
425
+
self,
426
+
request: &R,
427
+
) -> XrpcResult<Response<R>> {
428
+
let base_uri = self.base_uri();
429
+
let auth = self.access_token().await;
430
+
let mut opts = self.options.read().await.clone();
431
+
opts.auth = auth;
432
+
let resp = self
433
+
.client
434
+
.xrpc(base_uri.clone())
435
+
.with_options(opts.clone())
436
+
.send(request)
437
+
.await;
438
+
439
+
if is_expired(&resp) {
440
+
let auth = self.refresh().await?;
441
+
opts.auth = Some(auth);
442
+
self.client
443
+
.xrpc(base_uri)
444
+
.with_options(opts)
445
+
.send(request)
446
+
.await
447
+
} else {
448
+
resp
449
+
}
450
+
}
451
+
}
452
+
453
+
fn is_expired<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool {
454
+
match response {
455
+
Err(ClientError::Auth(AuthError::TokenExpired)) => true,
456
+
Ok(resp) => match resp.parse() {
457
+
Err(XrpcError::Auth(AuthError::TokenExpired)) => true,
458
+
_ => false,
459
+
},
460
+
_ => false,
461
+
}
462
+
}
-158
crates/jacquard/src/client/error.rs
-158
crates/jacquard/src/client/error.rs
···
1
-
//! Error types for XRPC client operations
2
-
3
-
use bytes::Bytes;
4
-
5
-
/// Client error type wrapping all possible error conditions
6
-
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
7
-
pub enum ClientError {
8
-
/// HTTP transport error
9
-
#[error("HTTP transport error: {0}")]
10
-
Transport(
11
-
#[from]
12
-
#[diagnostic_source]
13
-
TransportError,
14
-
),
15
-
16
-
/// Request serialization failed
17
-
#[error("{0}")]
18
-
Encode(
19
-
#[from]
20
-
#[diagnostic_source]
21
-
EncodeError,
22
-
),
23
-
24
-
/// Response deserialization failed
25
-
#[error("{0}")]
26
-
Decode(
27
-
#[from]
28
-
#[diagnostic_source]
29
-
DecodeError,
30
-
),
31
-
32
-
/// HTTP error response
33
-
#[error("HTTP {0}")]
34
-
Http(
35
-
#[from]
36
-
#[diagnostic_source]
37
-
HttpError,
38
-
),
39
-
40
-
/// Authentication error
41
-
#[error("Authentication error: {0}")]
42
-
Auth(
43
-
#[from]
44
-
#[diagnostic_source]
45
-
AuthError,
46
-
),
47
-
}
48
-
49
-
/// Transport-level errors that occur during HTTP communication
50
-
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
51
-
pub enum TransportError {
52
-
/// Failed to establish connection to server
53
-
#[error("Connection error: {0}")]
54
-
Connect(String),
55
-
56
-
/// Request timed out
57
-
#[error("Request timeout")]
58
-
Timeout,
59
-
60
-
/// Request construction failed (malformed URI, headers, etc.)
61
-
#[error("Invalid request: {0}")]
62
-
InvalidRequest(String),
63
-
64
-
/// Other transport error
65
-
#[error("Transport error: {0}")]
66
-
Other(Box<dyn std::error::Error + Send + Sync>),
67
-
}
68
-
69
-
// Re-export EncodeError from common
70
-
pub use jacquard_common::types::xrpc::EncodeError;
71
-
72
-
/// Response deserialization errors
73
-
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
74
-
pub enum DecodeError {
75
-
/// JSON deserialization failed
76
-
#[error("Failed to deserialize JSON: {0}")]
77
-
Json(
78
-
#[from]
79
-
#[source]
80
-
serde_json::Error,
81
-
),
82
-
/// CBOR deserialization failed (local I/O)
83
-
#[error("Failed to deserialize CBOR: {0}")]
84
-
CborLocal(
85
-
#[from]
86
-
#[source]
87
-
serde_ipld_dagcbor::DecodeError<std::io::Error>,
88
-
),
89
-
/// CBOR deserialization failed (remote/reqwest)
90
-
#[error("Failed to deserialize CBOR: {0}")]
91
-
CborRemote(
92
-
#[from]
93
-
#[source]
94
-
serde_ipld_dagcbor::DecodeError<reqwest::Error>,
95
-
),
96
-
}
97
-
98
-
/// HTTP error response (non-200 status codes outside of XRPC error handling)
99
-
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
100
-
pub struct HttpError {
101
-
/// HTTP status code
102
-
pub status: http::StatusCode,
103
-
/// Response body if available
104
-
pub body: Option<Bytes>,
105
-
}
106
-
107
-
impl std::fmt::Display for HttpError {
108
-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109
-
write!(f, "HTTP {}", self.status)?;
110
-
if let Some(body) = &self.body {
111
-
if let Ok(s) = std::str::from_utf8(body) {
112
-
write!(f, ":\n{}", s)?;
113
-
}
114
-
}
115
-
Ok(())
116
-
}
117
-
}
118
-
119
-
/// Authentication and authorization errors
120
-
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
121
-
pub enum AuthError {
122
-
/// Access token has expired (use refresh token to get a new one)
123
-
#[error("Access token expired")]
124
-
TokenExpired,
125
-
126
-
/// Access token is invalid or malformed
127
-
#[error("Invalid access token")]
128
-
InvalidToken,
129
-
130
-
/// Token refresh request failed
131
-
#[error("Token refresh failed")]
132
-
RefreshFailed,
133
-
134
-
/// Request requires authentication but none was provided
135
-
#[error("No authentication provided")]
136
-
NotAuthenticated,
137
-
138
-
/// Other authentication error
139
-
#[error("Authentication error: {0:?}")]
140
-
Other(http::HeaderValue),
141
-
}
142
-
143
-
/// Result type for client operations
144
-
pub type Result<T> = std::result::Result<T, ClientError>;
145
-
146
-
impl From<reqwest::Error> for TransportError {
147
-
fn from(e: reqwest::Error) -> Self {
148
-
if e.is_timeout() {
149
-
Self::Timeout
150
-
} else if e.is_connect() {
151
-
Self::Connect(e.to_string())
152
-
} else if e.is_builder() || e.is_request() {
153
-
Self::InvalidRequest(e.to_string())
154
-
} else {
155
-
Self::Other(Box::new(e))
156
-
}
157
-
}
158
-
}
-198
crates/jacquard/src/client/response.rs
-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
-
}
+458
-93
crates/jacquard/src/client/token.rs
+458
-93
crates/jacquard/src/client/token.rs
···
1
-
use async_trait::async_trait;
2
-
use std::path::{Path, PathBuf};
3
-
use std::sync::Arc;
4
-
use thiserror::Error;
5
-
6
-
use super::Session;
7
1
use jacquard_common::IntoStatic;
8
-
use jacquard_common::types::string::{Did, Handle};
2
+
use jacquard_common::cowstr::ToCowStr;
3
+
use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError};
4
+
use jacquard_common::types::string::{Datetime, Did};
5
+
use jacquard_oauth::scopes::Scope;
6
+
use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData};
7
+
use jacquard_oauth::types::OAuthTokenType;
8
+
use jose_jwk::Key;
9
+
use serde::{Deserialize, Serialize};
10
+
use serde_json::Value;
11
+
use url::Url;
9
12
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),
13
+
/// On-disk session records for app-password and OAuth flows, sharing a single JSON map.
14
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15
+
pub enum StoredSession {
16
+
/// App-password session
17
+
Atp(StoredAtSession),
18
+
/// OAuth client session
19
+
OAuth(OAuthSession),
20
+
/// OAuth authorization request state
21
+
OAuthState(OAuthState),
16
22
}
17
23
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>;
24
+
/// Minimal persisted representation of an appโpassword session.
25
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26
+
pub struct StoredAtSession {
27
+
/// Access token (JWT)
28
+
access_jwt: String,
29
+
/// Refresh token (JWT)
30
+
refresh_jwt: String,
31
+
/// Account DID
32
+
did: String,
33
+
/// Optional PDS endpoint for faster resume
34
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
35
+
pds: Option<String>,
36
+
/// Session id label (e.g., "session")
37
+
session_id: String,
38
+
/// Last known handle
39
+
handle: String,
27
40
}
28
41
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>>>);
42
+
/// Persisted OAuth client session (on-disk format).
43
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44
+
pub struct OAuthSession {
45
+
/// Account DID
46
+
account_did: String,
47
+
/// Client-generated session id (usually auth `state`)
48
+
session_id: String,
32
49
33
-
#[async_trait]
34
-
impl TokenStore for MemoryTokenStore {
35
-
async fn get(&self) -> Option<Session> {
36
-
self.0.read().await.clone()
50
+
/// Base URL of the resource server (PDS)
51
+
host_url: Url,
52
+
53
+
/// Base URL of the authorization server (PDS or entryway)
54
+
authserver_url: Url,
55
+
56
+
/// Full token endpoint URL
57
+
authserver_token_endpoint: String,
58
+
59
+
/// Full revocation endpoint URL, if available
60
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
61
+
authserver_revocation_endpoint: Option<String>,
62
+
63
+
/// Granted scopes
64
+
scopes: Vec<String>,
65
+
66
+
/// Client DPoP key material
67
+
pub dpop_key: Key,
68
+
/// Current auth server DPoP nonce
69
+
pub dpop_authserver_nonce: String,
70
+
/// Current resource server (PDS) DPoP nonce
71
+
pub dpop_host_nonce: String,
72
+
73
+
/// Token response issuer
74
+
pub iss: String,
75
+
/// Token subject (DID)
76
+
pub sub: String,
77
+
/// Token audience (verified PDS URL)
78
+
pub aud: String,
79
+
/// Token scopes (raw) if provided
80
+
pub scope: Option<String>,
81
+
82
+
/// Refresh token
83
+
pub refresh_token: Option<String>,
84
+
/// Access token
85
+
pub access_token: String,
86
+
/// Token type (e.g., DPoP)
87
+
pub token_type: OAuthTokenType,
88
+
89
+
/// Expiration timestamp
90
+
pub expires_at: Option<Datetime>,
91
+
}
92
+
93
+
impl From<ClientSessionData<'_>> for OAuthSession {
94
+
fn from(data: ClientSessionData<'_>) -> Self {
95
+
OAuthSession {
96
+
account_did: data.account_did.to_string(),
97
+
session_id: data.session_id.to_string(),
98
+
host_url: data.host_url,
99
+
authserver_url: data.authserver_url,
100
+
authserver_token_endpoint: data.authserver_token_endpoint.to_string(),
101
+
authserver_revocation_endpoint: data
102
+
.authserver_revocation_endpoint
103
+
.map(|s| s.to_string()),
104
+
scopes: data.scopes.into_iter().map(|s| s.to_string()).collect(),
105
+
dpop_key: data.dpop_data.dpop_key,
106
+
dpop_authserver_nonce: data.dpop_data.dpop_authserver_nonce.to_string(),
107
+
dpop_host_nonce: data.dpop_data.dpop_host_nonce.to_string(),
108
+
iss: data.token_set.iss.to_string(),
109
+
sub: data.token_set.sub.to_string(),
110
+
aud: data.token_set.aud.to_string(),
111
+
scope: data.token_set.scope.map(|s| s.to_string()),
112
+
refresh_token: data.token_set.refresh_token.map(|s| s.to_string()),
113
+
access_token: data.token_set.access_token.to_string(),
114
+
token_type: data.token_set.token_type,
115
+
expires_at: data.token_set.expires_at,
116
+
}
117
+
}
118
+
}
119
+
120
+
impl From<OAuthSession> for ClientSessionData<'_> {
121
+
fn from(session: OAuthSession) -> Self {
122
+
ClientSessionData {
123
+
account_did: session.account_did.into(),
124
+
session_id: session.session_id.to_cowstr(),
125
+
host_url: session.host_url,
126
+
authserver_url: session.authserver_url,
127
+
authserver_token_endpoint: session.authserver_token_endpoint.to_cowstr(),
128
+
authserver_revocation_endpoint: session
129
+
.authserver_revocation_endpoint
130
+
.map(|s| s.to_cowstr().into_static()),
131
+
scopes: session
132
+
.scopes
133
+
.into_iter()
134
+
.map(|s| Scope::parse(&s).unwrap().into_static())
135
+
.collect(),
136
+
dpop_data: DpopClientData {
137
+
dpop_key: session.dpop_key,
138
+
dpop_authserver_nonce: session.dpop_authserver_nonce.to_cowstr(),
139
+
dpop_host_nonce: session.dpop_host_nonce.to_cowstr(),
140
+
},
141
+
token_set: jacquard_oauth::types::TokenSet {
142
+
iss: session.iss.into(),
143
+
sub: session.sub.into(),
144
+
aud: session.aud.into(),
145
+
scope: session.scope.map(|s| s.into()),
146
+
refresh_token: session.refresh_token.map(|s| s.into()),
147
+
access_token: session.access_token.into(),
148
+
token_type: session.token_type,
149
+
expires_at: session.expires_at,
150
+
},
151
+
}
152
+
.into_static()
153
+
}
154
+
}
155
+
156
+
/// Persisted OAuth authorization request state.
157
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158
+
pub struct OAuthState {
159
+
/// Random identifier generated for the authorization flow (`state`)
160
+
pub state: String,
161
+
162
+
/// Base URL of the authorization server (PDS or entryway)
163
+
pub authserver_url: Url,
164
+
165
+
/// Optional pre-known account DID
166
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
167
+
pub account_did: Option<String>,
168
+
169
+
/// Requested scopes
170
+
pub scopes: Vec<String>,
171
+
172
+
/// Request URI for the authorization step
173
+
pub request_uri: String,
174
+
175
+
/// Full token endpoint URL
176
+
pub authserver_token_endpoint: String,
177
+
178
+
/// Full revocation endpoint URL, if available
179
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
180
+
pub authserver_revocation_endpoint: Option<String>,
181
+
182
+
/// PKCE verifier
183
+
pub pkce_verifier: String,
184
+
185
+
/// Client DPoP key material
186
+
pub dpop_key: Key,
187
+
/// Auth server DPoP nonce at PAR time
188
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
189
+
pub dpop_authserver_nonce: Option<String>,
190
+
}
191
+
192
+
impl From<AuthRequestData<'_>> for OAuthState {
193
+
fn from(value: AuthRequestData) -> Self {
194
+
OAuthState {
195
+
authserver_url: value.authserver_url,
196
+
account_did: value.account_did.map(|s| s.to_string()),
197
+
scopes: value.scopes.into_iter().map(|s| s.to_string()).collect(),
198
+
request_uri: value.request_uri.to_string(),
199
+
authserver_token_endpoint: value.authserver_token_endpoint.to_string(),
200
+
authserver_revocation_endpoint: value
201
+
.authserver_revocation_endpoint
202
+
.map(|s| s.to_string()),
203
+
pkce_verifier: value.pkce_verifier.to_string(),
204
+
dpop_key: value.dpop_data.dpop_key,
205
+
dpop_authserver_nonce: value.dpop_data.dpop_authserver_nonce.map(|s| s.to_string()),
206
+
state: value.state.to_string(),
207
+
}
208
+
}
209
+
}
210
+
211
+
impl From<OAuthState> for AuthRequestData<'_> {
212
+
fn from(value: OAuthState) -> Self {
213
+
AuthRequestData {
214
+
authserver_url: value.authserver_url,
215
+
state: value.state.to_cowstr(),
216
+
account_did: value.account_did.map(|s| Did::from(s).into_static()),
217
+
authserver_revocation_endpoint: value
218
+
.authserver_revocation_endpoint
219
+
.map(|s| s.to_cowstr().into_static()),
220
+
scopes: value
221
+
.scopes
222
+
.into_iter()
223
+
.map(|s| Scope::parse(&s).unwrap().into_static())
224
+
.collect(),
225
+
request_uri: value.request_uri.to_cowstr(),
226
+
authserver_token_endpoint: value.authserver_token_endpoint.to_cowstr(),
227
+
pkce_verifier: value.pkce_verifier.to_cowstr(),
228
+
dpop_data: DpopReqData {
229
+
dpop_key: value.dpop_key,
230
+
dpop_authserver_nonce: value
231
+
.dpop_authserver_nonce
232
+
.map(|s| s.to_cowstr().into_static()),
233
+
},
234
+
}
235
+
.into_static()
236
+
}
237
+
}
238
+
239
+
/// Convenience wrapper over `FileTokenStore` offering unified storage across auth modes.
240
+
pub struct FileAuthStore(FileTokenStore);
241
+
242
+
impl FileAuthStore {
243
+
/// Create a new file-backed auth store wrapping `FileTokenStore`.
244
+
pub fn new(path: impl AsRef<std::path::Path>) -> Self {
245
+
Self(FileTokenStore::new(path))
246
+
}
247
+
}
248
+
249
+
#[async_trait::async_trait]
250
+
impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore {
251
+
async fn get_session(
252
+
&self,
253
+
did: &Did<'_>,
254
+
session_id: &str,
255
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> {
256
+
let key = format!("{}_{}", did, session_id);
257
+
if let StoredSession::OAuth(session) = self
258
+
.0
259
+
.get(&key)
260
+
.await
261
+
.ok_or(SessionStoreError::Other("not found".into()))?
262
+
{
263
+
Ok(Some(session.into()))
264
+
} else {
265
+
Ok(None)
266
+
}
37
267
}
38
-
async fn set(&self, session: Session) -> Result<(), TokenStoreError> {
39
-
*self.0.write().await = Some(session);
268
+
269
+
async fn upsert_session(
270
+
&self,
271
+
session: ClientSessionData<'_>,
272
+
) -> Result<(), SessionStoreError> {
273
+
let key = format!("{}_{}", session.account_did, session.session_id);
274
+
self.0
275
+
.set(key, StoredSession::OAuth(session.into()))
276
+
.await?;
40
277
Ok(())
41
278
}
42
-
async fn clear(&self) -> Result<(), TokenStoreError> {
43
-
*self.0.write().await = None;
279
+
280
+
async fn delete_session(
281
+
&self,
282
+
did: &Did<'_>,
283
+
session_id: &str,
284
+
) -> Result<(), SessionStoreError> {
285
+
let key = format!("{}_{}", did, session_id);
286
+
let file = std::fs::read_to_string(&self.0.path)?;
287
+
let mut store: Value = serde_json::from_str(&file)?;
288
+
let key_string = key.to_string();
289
+
if let Some(store) = store.as_object_mut() {
290
+
store.remove(&key_string);
291
+
292
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
293
+
Ok(())
294
+
} else {
295
+
Err(SessionStoreError::Other("invalid store".into()))
296
+
}
297
+
}
298
+
299
+
async fn get_auth_req_info(
300
+
&self,
301
+
state: &str,
302
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> {
303
+
let key = format!("authreq_{}", state);
304
+
if let StoredSession::OAuthState(auth_req) = self
305
+
.0
306
+
.get(&key)
307
+
.await
308
+
.ok_or(SessionStoreError::Other("not found".into()))?
309
+
{
310
+
Ok(Some(auth_req.into()))
311
+
} else {
312
+
Ok(None)
313
+
}
314
+
}
315
+
316
+
async fn save_auth_req_info(
317
+
&self,
318
+
auth_req_info: &AuthRequestData<'_>,
319
+
) -> Result<(), SessionStoreError> {
320
+
let key = format!("authreq_{}", auth_req_info.state);
321
+
self.0
322
+
.set(key, StoredSession::OAuthState(auth_req_info.clone().into()))
323
+
.await?;
44
324
Ok(())
45
325
}
46
-
}
47
326
48
-
/// File-backed token store using a JSON file.
49
-
///
50
-
/// Example
51
-
/// ```ignore
52
-
/// use jacquard::client::{AtClient, FileTokenStore};
53
-
/// let base = url::Url::parse("https://bsky.social").unwrap();
54
-
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
55
-
/// let client = AtClient::new(reqwest::Client::new(), base, store);
56
-
/// ```
57
-
#[derive(Clone, Debug)]
58
-
pub struct FileTokenStore {
59
-
path: PathBuf,
327
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> {
328
+
let key = format!("authreq_{}", state);
329
+
let file = std::fs::read_to_string(&self.0.path)?;
330
+
let mut store: Value = serde_json::from_str(&file)?;
331
+
let key_string = key.to_string();
332
+
if let Some(store) = store.as_object_mut() {
333
+
store.remove(&key_string);
334
+
335
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
336
+
Ok(())
337
+
} else {
338
+
Err(SessionStoreError::Other("invalid store".into()))
339
+
}
340
+
}
60
341
}
61
342
62
-
impl FileTokenStore {
63
-
/// Create a new file token store at the given path.
64
-
pub fn new(path: impl AsRef<Path>) -> Self {
65
-
Self {
66
-
path: path.as_ref().to_path_buf(),
343
+
impl FileAuthStore {
344
+
/// Update the persisted PDS endpoint for an app-password session (best-effort).
345
+
pub fn set_atp_pds(
346
+
&self,
347
+
key: &crate::client::credential_session::SessionKey,
348
+
pds: &Url,
349
+
) -> Result<(), SessionStoreError> {
350
+
let key_str = format!("{}_{}", key.0, key.1);
351
+
let file = std::fs::read_to_string(&self.0.path)?;
352
+
let mut store: Value = serde_json::from_str(&file)?;
353
+
if let Some(map) = store.as_object_mut() {
354
+
if let Some(value) = map.get_mut(&key_str) {
355
+
if let Some(outer) = value.as_object_mut() {
356
+
if let Some(inner) = outer.get_mut("Atp").and_then(|v| v.as_object_mut()) {
357
+
inner.insert(
358
+
"pds".to_string(),
359
+
serde_json::Value::String(pds.to_string()),
360
+
);
361
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
362
+
return Ok(());
363
+
}
364
+
}
365
+
}
67
366
}
367
+
Err(SessionStoreError::Other("invalid store".into()))
68
368
}
69
-
}
70
369
71
-
#[derive(serde::Serialize, serde::Deserialize)]
72
-
struct FileSession {
73
-
access_jwt: String,
74
-
refresh_jwt: String,
75
-
did: String,
76
-
handle: String,
370
+
/// Read the persisted PDS endpoint for an app-password session, if present.
371
+
pub fn get_atp_pds(
372
+
&self,
373
+
key: &crate::client::credential_session::SessionKey,
374
+
) -> Result<Option<Url>, SessionStoreError> {
375
+
let key_str = format!("{}_{}", key.0, key.1);
376
+
let file = std::fs::read_to_string(&self.0.path)?;
377
+
let store: Value = serde_json::from_str(&file)?;
378
+
if let Some(value) = store.get(&key_str) {
379
+
if let Some(obj) = value.as_object() {
380
+
if let Some(serde_json::Value::Object(inner)) = obj.get("Atp") {
381
+
if let Some(serde_json::Value::String(pds)) = inner.get("pds") {
382
+
return Ok(Url::parse(pds).ok());
383
+
}
384
+
}
385
+
}
386
+
}
387
+
Ok(None)
388
+
}
77
389
}
78
390
79
-
#[async_trait]
80
-
impl TokenStore for FileTokenStore {
81
-
async fn get(&self) -> Option<Session> {
82
-
let data = tokio::fs::read(&self.path).await.ok()?;
83
-
let disk: FileSession = serde_json::from_slice(&data).ok()?;
84
-
let did = Did::new_owned(disk.did).ok()?;
85
-
let handle = Handle::new_owned(disk.handle).ok()?;
86
-
Some(Session {
87
-
access_jwt: disk.access_jwt.into(),
88
-
refresh_jwt: disk.refresh_jwt.into(),
89
-
did: did.into_static(),
90
-
handle: handle.into_static(),
91
-
})
391
+
#[async_trait::async_trait]
392
+
impl jacquard_common::session::SessionStore<
393
+
crate::client::credential_session::SessionKey,
394
+
crate::client::AtpSession,
395
+
> for FileAuthStore
396
+
{
397
+
async fn get(
398
+
&self,
399
+
key: &crate::client::credential_session::SessionKey,
400
+
) -> Option<crate::client::AtpSession> {
401
+
let key_str = format!("{}_{}", key.0, key.1);
402
+
if let Some(StoredSession::Atp(stored)) = self.0.get(&key_str).await {
403
+
Some(crate::client::AtpSession {
404
+
access_jwt: stored.access_jwt.into(),
405
+
refresh_jwt: stored.refresh_jwt.into(),
406
+
did: stored.did.into(),
407
+
handle: stored.handle.into(),
408
+
})
409
+
} else {
410
+
None
411
+
}
92
412
}
93
413
94
-
async fn set(&self, session: Session) -> Result<(), TokenStoreError> {
95
-
let disk = FileSession {
414
+
async fn set(
415
+
&self,
416
+
key: crate::client::credential_session::SessionKey,
417
+
session: crate::client::AtpSession,
418
+
) -> Result<(), jacquard_common::session::SessionStoreError> {
419
+
let key_str = format!("{}_{}", key.0, key.1);
420
+
let stored = StoredAtSession {
96
421
access_jwt: session.access_jwt.to_string(),
97
422
refresh_jwt: session.refresh_jwt.to_string(),
98
423
did: session.did.to_string(),
424
+
// pds endpoint is resolved on restore; do not persist
425
+
pds: None,
426
+
session_id: key.1.to_string(),
99
427
handle: session.handle.to_string(),
100
428
};
101
-
let buf =
102
-
serde_json::to_vec_pretty(&disk).map_err(|e| TokenStoreError::Other(e.to_string()))?;
103
-
if let Some(parent) = self.path.parent() {
104
-
tokio::fs::create_dir_all(parent)
105
-
.await
106
-
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
429
+
self.0.set(key_str, StoredSession::Atp(stored)).await
430
+
}
431
+
432
+
async fn del(
433
+
&self,
434
+
key: &crate::client::credential_session::SessionKey,
435
+
) -> Result<(), jacquard_common::session::SessionStoreError> {
436
+
let key_str = format!("{}_{}", key.0, key.1);
437
+
// Manual removal to mirror existing pattern
438
+
let file = std::fs::read_to_string(&self.0.path)?;
439
+
let mut store: serde_json::Value = serde_json::from_str(&file)?;
440
+
if let Some(map) = store.as_object_mut() {
441
+
map.remove(&key_str);
442
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
443
+
Ok(())
444
+
} else {
445
+
Err(jacquard_common::session::SessionStoreError::Other(
446
+
"invalid store".into(),
447
+
))
107
448
}
108
-
let tmp = self.path.with_extension("tmp");
109
-
tokio::fs::write(&tmp, &buf)
110
-
.await
111
-
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
112
-
tokio::fs::rename(&tmp, &self.path)
113
-
.await
114
-
.map_err(|e| TokenStoreError::Other(e.to_string()))?;
115
-
Ok(())
116
449
}
450
+
}
117
451
118
-
async fn clear(&self) -> Result<(), TokenStoreError> {
119
-
match tokio::fs::remove_file(&self.path).await {
120
-
Ok(_) => Ok(()),
121
-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
122
-
Err(e) => Err(TokenStoreError::Other(e.to_string())),
123
-
}
452
+
#[cfg(test)]
453
+
mod tests {
454
+
use super::*;
455
+
use crate::client::credential_session::SessionKey;
456
+
use crate::client::AtpSession;
457
+
use jacquard_common::types::string::{Did, Handle};
458
+
use std::fs;
459
+
use std::path::PathBuf;
460
+
461
+
fn temp_file() -> PathBuf {
462
+
let mut p = std::env::temp_dir();
463
+
p.push(format!("jacquard-test-{}.json", std::process::id()));
464
+
p
465
+
}
466
+
467
+
#[tokio::test]
468
+
async fn file_auth_store_roundtrip_atp() {
469
+
let path = temp_file();
470
+
// initialize empty store file
471
+
fs::write(&path, "{}").unwrap();
472
+
let store = FileAuthStore::new(&path);
473
+
let session = AtpSession {
474
+
access_jwt: "a".into(),
475
+
refresh_jwt: "r".into(),
476
+
did: Did::new_static("did:plc:alice").unwrap(),
477
+
handle: Handle::new_static("alice.bsky.social").unwrap(),
478
+
};
479
+
let key: SessionKey = (session.did.clone(), "session".into());
480
+
jacquard_common::session::SessionStore::set(&store, key.clone(), session.clone())
481
+
.await
482
+
.unwrap();
483
+
let restored = jacquard_common::session::SessionStore::get(&store, &key)
484
+
.await
485
+
.unwrap();
486
+
assert_eq!(restored.access_jwt.as_ref(), "a");
487
+
// clean up
488
+
let _ = fs::remove_file(&path);
124
489
}
125
490
}
-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
-
}
+220
-232
crates/jacquard/src/client.rs
+220
-232
crates/jacquard/src/client.rs
···
3
3
//! This module provides HTTP and XRPC client traits along with an authenticated
4
4
//! client implementation that manages session tokens.
5
5
6
-
mod at_client;
7
-
mod error;
8
-
mod response;
9
-
mod token;
10
-
mod xrpc_call;
6
+
/// Stateful session client for appโpassword auth with autoโrefresh.
7
+
pub mod credential_session;
8
+
/// Token storage and onโdisk formats shared across appโpassword and OAuth.
9
+
pub mod token;
11
10
12
-
use std::fmt::Display;
13
-
use std::future::Future;
11
+
use core::future::Future;
14
12
15
-
pub use at_client::{AtClient, SendOverrides};
16
-
pub use error::{ClientError, Result};
17
-
use http::{
18
-
HeaderName, HeaderValue, Request,
19
-
header::{AUTHORIZATION, CONTENT_TYPE},
20
-
};
21
-
pub use response::Response;
22
-
pub use token::{FileTokenStore, MemoryTokenStore, TokenStore, TokenStoreError};
23
-
pub use xrpc_call::{CallOptions, XrpcCall, XrpcExt};
24
-
13
+
use jacquard_common::AuthorizationToken;
14
+
use jacquard_common::error::TransportError;
15
+
pub use jacquard_common::error::{ClientError, XrpcResult};
16
+
pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError};
17
+
use jacquard_common::types::xrpc::{CallOptions, Response, XrpcClient, XrpcRequest};
25
18
use jacquard_common::{
26
19
CowStr, IntoStatic,
27
-
types::{
28
-
string::{Did, Handle},
29
-
xrpc::{XrpcMethod, XrpcRequest},
30
-
},
20
+
types::string::{Did, Handle},
31
21
};
32
-
use url::Url;
22
+
use jacquard_common::{http_client::HttpClient, types::xrpc::XrpcExt};
23
+
use jacquard_identity::resolver::IdentityResolver;
24
+
use jacquard_oauth::authstore::ClientAuthStore;
25
+
use jacquard_oauth::client::OAuthSession;
26
+
use jacquard_oauth::dpop::DpopExt;
27
+
use jacquard_oauth::resolver::OAuthResolver;
28
+
pub use token::FileAuthStore;
33
29
34
-
/// Implement HttpClient for reqwest::Client
35
-
impl HttpClient for reqwest::Client {
36
-
type Error = reqwest::Error;
30
+
use crate::client::credential_session::{CredentialSession, SessionKey};
37
31
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);
32
+
/// App password session information from `com.atproto.server.createSession`
33
+
///
34
+
/// Contains the access and refresh tokens along with user identity information.
35
+
#[derive(Debug, Clone)]
36
+
pub struct AtpSession {
37
+
/// Access token (JWT) used for authenticated requests
38
+
pub access_jwt: CowStr<'static>,
39
+
/// Refresh token (JWT) used to obtain new access tokens
40
+
pub refresh_jwt: CowStr<'static>,
41
+
/// User's DID (Decentralized Identifier)
42
+
pub did: Did<'static>,
43
+
/// User's handle (e.g., "alice.bsky.social")
44
+
pub handle: Handle<'static>,
45
+
}
46
46
47
-
// Copy headers
48
-
for (name, value) in parts.headers.iter() {
49
-
req = req.header(name.as_str(), value.as_bytes());
47
+
impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>>
48
+
for AtpSession
49
+
{
50
+
fn from(
51
+
output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>,
52
+
) -> Self {
53
+
Self {
54
+
access_jwt: output.access_jwt.into_static(),
55
+
refresh_jwt: output.refresh_jwt.into_static(),
56
+
did: output.did.into_static(),
57
+
handle: output.handle.into_static(),
50
58
}
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());
59
+
}
60
+
}
57
61
58
-
// Copy headers
59
-
for (name, value) in resp.headers().iter() {
60
-
builder = builder.header(name.as_str(), value.as_bytes());
62
+
impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>>
63
+
for AtpSession
64
+
{
65
+
fn from(
66
+
output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>,
67
+
) -> Self {
68
+
Self {
69
+
access_jwt: output.access_jwt.into_static(),
70
+
refresh_jwt: output.refresh_jwt.into_static(),
71
+
did: output.did.into_static(),
72
+
handle: output.handle.into_static(),
61
73
}
62
-
63
-
// Read body
64
-
let body = resp.bytes().await?.to_vec();
65
-
66
-
Ok(builder.body(body).expect("Failed to build response"))
67
74
}
68
75
}
69
76
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;
77
+
/// Identifies the active authentication mode for an agent/session.
78
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79
+
pub enum AgentKind {
80
+
/// App password (Bearer) session
81
+
AppPassword,
82
+
/// OAuth (DPoP) session
83
+
OAuth,
79
84
}
80
-
// Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs
81
85
82
-
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
83
-
84
-
/// Authorization token types for XRPC requests.
85
-
#[derive(Debug, Clone)]
86
-
pub enum AuthorizationToken<'s> {
87
-
/// Bearer token (access JWT, refresh JWT to refresh the session)
88
-
Bearer(CowStr<'s>),
89
-
/// DPoP token (proof-of-possession) for OAuth
90
-
Dpop(CowStr<'s>),
86
+
/// Common interface for stateful sessions used by the Agent wrapper.
87
+
///
88
+
/// Implemented by `CredentialSession` (appโpassword) and `OAuthSession` (DPoP).
89
+
pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
90
+
/// Identify the kind of session.
91
+
fn session_kind(&self) -> AgentKind;
92
+
/// Return current DID and an optional session id (always Some for OAuth).
93
+
fn session_info(
94
+
&self,
95
+
) -> core::pin::Pin<
96
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
97
+
>;
98
+
/// Current base endpoint.
99
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>>;
100
+
/// Override per-session call options.
101
+
fn set_options<'a>(
102
+
&'a self,
103
+
opts: CallOptions<'a>,
104
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
105
+
/// Refresh the session and return a fresh AuthorizationToken.
106
+
fn refresh(
107
+
&self,
108
+
) -> core::pin::Pin<
109
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
110
+
>;
91
111
}
92
112
93
-
/// Basic client wrapper: reqwest transport + in-memory token store.
94
-
pub struct BasicClient(AtClient<reqwest::Client, MemoryTokenStore>);
95
-
96
-
impl BasicClient {
97
-
/// Construct a basic client with minimal inputs.
98
-
pub fn new(base: Url) -> Self {
99
-
Self(AtClient::new(
100
-
reqwest::Client::new(),
101
-
base,
102
-
MemoryTokenStore::default(),
103
-
))
113
+
impl<S, T> AgentSession for CredentialSession<S, T>
114
+
where
115
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
116
+
T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static,
117
+
{
118
+
fn session_kind(&self) -> AgentKind {
119
+
AgentKind::AppPassword
120
+
}
121
+
fn session_info(
122
+
&self,
123
+
) -> core::pin::Pin<
124
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
125
+
> {
126
+
Box::pin(async move {
127
+
CredentialSession::<S, T>::session_info(self)
128
+
.await
129
+
.map(|(did, sid)| (did, Some(sid)))
130
+
})
104
131
}
105
-
106
-
/// Access the inner stateful client.
107
-
pub fn inner(&self) -> &AtClient<reqwest::Client, MemoryTokenStore> {
108
-
&self.0
132
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> {
133
+
Box::pin(async move { CredentialSession::<S, T>::endpoint(self).await })
109
134
}
110
-
111
-
/// Send an XRPC request.
112
-
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> Result<Response<R>> {
113
-
self.0.send(req).await
135
+
fn set_options<'a>(
136
+
&'a self,
137
+
opts: CallOptions<'a>,
138
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
139
+
Box::pin(async move { CredentialSession::<S, T>::set_options(self, opts).await })
114
140
}
115
-
116
-
/// Send with per-call overrides.
117
-
pub async fn send_with<R: XrpcRequest + Send>(
141
+
fn refresh(
118
142
&self,
119
-
req: R,
120
-
overrides: SendOverrides<'_>,
121
-
) -> Result<Response<R>> {
122
-
self.0.send_with(req, overrides).await
143
+
) -> core::pin::Pin<
144
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
145
+
> {
146
+
Box::pin(async move {
147
+
Ok(CredentialSession::<S, T>::refresh(self)
148
+
.await?
149
+
.into_static())
150
+
})
123
151
}
152
+
}
124
153
125
-
/// Get current session.
126
-
pub async fn session(&self) -> Option<Session> {
127
-
self.0.session().await
154
+
impl<T, S> AgentSession for OAuthSession<T, S>
155
+
where
156
+
S: ClientAuthStore + Send + Sync + 'static,
157
+
T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
158
+
{
159
+
fn session_kind(&self) -> AgentKind {
160
+
AgentKind::OAuth
128
161
}
129
-
130
-
/// Set the session.
131
-
pub async fn set_session(&self, session: Session) -> core::result::Result<(), TokenStoreError> {
132
-
self.0.set_session(session).await
162
+
fn session_info(
163
+
&self,
164
+
) -> core::pin::Pin<
165
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
166
+
> {
167
+
Box::pin(async move {
168
+
let (did, sid) = OAuthSession::<T, S>::session_info(self).await;
169
+
Some((did.into_static(), Some(sid.into_static())))
170
+
})
171
+
}
172
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> {
173
+
Box::pin(async move { self.endpoint().await })
133
174
}
134
-
135
-
/// Clear session.
136
-
pub async fn clear_session(&self) -> core::result::Result<(), TokenStoreError> {
137
-
self.0.clear_session().await
175
+
fn set_options<'a>(
176
+
&'a self,
177
+
opts: CallOptions<'a>,
178
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
179
+
Box::pin(async move { self.set_options(opts).await })
138
180
}
139
-
140
-
/// Base URL of this client.
141
-
pub fn base(&self) -> &Url {
142
-
self.0.base()
181
+
fn refresh(
182
+
&self,
183
+
) -> core::pin::Pin<
184
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
185
+
> {
186
+
Box::pin(async move {
187
+
self.refresh()
188
+
.await
189
+
.map(|t| t.into_static())
190
+
.map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))
191
+
})
143
192
}
144
193
}
145
194
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,
195
+
/// Thin wrapper over a stateful session providing a uniform `XrpcClient`.
196
+
pub struct Agent<A: AgentSession> {
197
+
inner: A,
158
198
}
159
199
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
-
}
200
+
impl<A: AgentSession> Agent<A> {
201
+
/// Wrap an existing session in an Agent.
202
+
pub fn new(inner: A) -> Self {
203
+
Self { inner }
168
204
}
169
-
}
170
205
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
-
}
206
+
/// Return the underlying session kind.
207
+
pub fn kind(&self) -> AgentKind {
208
+
self.inner.session_kind()
191
209
}
192
210
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);
211
+
/// Return session info if available.
212
+
pub async fn info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> {
213
+
self.inner.session_info().await
202
214
}
203
-
builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
204
215
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
+
/// Get current endpoint.
217
+
pub async fn endpoint(&self) -> url::Url {
218
+
self.inner.endpoint().await
216
219
}
217
220
218
-
if let Some(proxy) = &opts.atproto_proxy {
219
-
builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
221
+
/// Override call options for subsequent requests.
222
+
pub async fn set_options(&self, opts: CallOptions<'_>) {
223
+
self.inner.set_options(opts).await
220
224
}
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
-
}
225
+
226
+
/// Refresh the session and return a fresh token.
227
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> {
228
+
self.inner.refresh().await
230
229
}
231
-
for (name, value) in &opts.extra_headers {
232
-
builder = builder.header(name, value);
233
-
}
230
+
}
234
231
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
-
};
232
+
impl<A: AgentSession> HttpClient for Agent<A> {
233
+
type Error = <A as HttpClient>::Error;
241
234
242
-
builder
243
-
.body(body)
244
-
.map_err(|e| error::TransportError::InvalidRequest(e.to_string()))
235
+
fn send_http(
236
+
&self,
237
+
request: http::Request<Vec<u8>>,
238
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
239
+
{
240
+
self.inner.send_http(request)
241
+
}
245
242
}
246
243
247
-
/// Session information from `com.atproto.server.createSession`
248
-
///
249
-
/// Contains the access and refresh tokens along with user identity information.
250
-
#[derive(Debug, Clone)]
251
-
pub struct Session {
252
-
/// Access token (JWT) used for authenticated requests
253
-
pub access_jwt: CowStr<'static>,
254
-
/// Refresh token (JWT) used to obtain new access tokens
255
-
pub refresh_jwt: CowStr<'static>,
256
-
/// User's DID (Decentralized Identifier)
257
-
pub did: Did<'static>,
258
-
/// User's handle (e.g., "alice.bsky.social")
259
-
pub handle: Handle<'static>,
260
-
}
261
-
262
-
impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> for Session {
263
-
fn from(
264
-
output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>,
265
-
) -> Self {
266
-
Self {
267
-
access_jwt: output.access_jwt.into_static(),
268
-
refresh_jwt: output.refresh_jwt.into_static(),
269
-
did: output.did.into_static(),
270
-
handle: output.handle.into_static(),
271
-
}
244
+
impl<A: AgentSession> XrpcClient for Agent<A> {
245
+
fn base_uri(&self) -> url::Url {
246
+
self.inner.base_uri()
247
+
}
248
+
fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
249
+
self.inner.opts()
250
+
}
251
+
fn send<R: XrpcRequest + Send>(
252
+
self,
253
+
request: &R,
254
+
) -> impl Future<Output = XrpcResult<Response<R>>> {
255
+
async move { self.inner.send(request).await }
272
256
}
273
257
}
274
258
275
-
impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>>
276
-
for Session
277
-
{
278
-
fn from(
279
-
output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>,
280
-
) -> Self {
281
-
Self {
282
-
access_jwt: output.access_jwt.into_static(),
283
-
refresh_jwt: output.refresh_jwt.into_static(),
284
-
did: output.did.into_static(),
285
-
handle: output.handle.into_static(),
286
-
}
259
+
impl<A: AgentSession> From<A> for Agent<A> {
260
+
fn from(inner: A) -> Self {
261
+
Self::new(inner)
287
262
}
288
263
}
264
+
265
+
/// Alias for an agent over a credential (appโpassword) session.
266
+
pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>;
267
+
/// Alias for an agent over an OAuth (DPoP) session.
268
+
pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>;
269
+
270
+
/// BasicClient: in-memory store + public resolver over a credential session.
271
+
pub type BasicClient = Agent<
272
+
CredentialSession<
273
+
MemorySessionStore<SessionKey, AtpSession>,
274
+
jacquard_identity::PublicResolver,
275
+
>,
276
+
>;
-3
crates/jacquard/src/identity/mod.rs
-3
crates/jacquard/src/identity/mod.rs
-931
crates/jacquard/src/identity/resolver.rs
-931
crates/jacquard/src/identity/resolver.rs
···
1
-
//! Identity resolution: handle โ DID and DID โ document, with smart fallbacks.
2
-
//!
3
-
//! Fallback order (default):
4
-
//! - Handle โ DID: DNS TXT (if `dns` feature) โ HTTPS well-known โ 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::CowStr; // not currently needed directly here
16
-
use crate::client::XrpcExt;
17
-
use bon::Builder;
18
-
use bytes::Bytes;
19
-
use jacquard_common::types::did_doc::Service;
20
-
use jacquard_common::types::string::AtprotoStr;
21
-
use jacquard_common::types::uri::Uri;
22
-
use jacquard_common::types::value::Data;
23
-
use jacquard_common::{CowStr, IntoStatic};
24
-
use miette::Diagnostic;
25
-
use percent_encoding::percent_decode_str;
26
-
use reqwest::StatusCode;
27
-
use thiserror::Error;
28
-
use url::{ParseError, Url};
29
-
30
-
use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
31
-
use crate::types::did_doc::DidDocument;
32
-
use crate::types::ident::AtIdentifier;
33
-
use crate::types::string::{Did, Handle};
34
-
use crate::types::value::AtDataError;
35
-
36
-
#[cfg(feature = "dns")]
37
-
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
38
-
39
-
/// Errors that can occur during identity resolution.
40
-
///
41
-
/// Note: when validating a fetched DID document against a requested DID, a
42
-
/// `DocIdMismatch` error is returned that includes the owned document so callers
43
-
/// can inspect it and decide how to proceed.
44
-
#[derive(Debug, Error, Diagnostic)]
45
-
#[allow(missing_docs)]
46
-
pub enum IdentityError {
47
-
#[error("unsupported DID method: {0}")]
48
-
UnsupportedDidMethod(String),
49
-
#[error("invalid well-known atproto-did content")]
50
-
InvalidWellKnown,
51
-
#[error("missing PDS endpoint in DID document")]
52
-
MissingPdsEndpoint,
53
-
#[error("HTTP error: {0}")]
54
-
Http(#[from] reqwest::Error),
55
-
#[error("HTTP status {0}")]
56
-
HttpStatus(StatusCode),
57
-
#[error("XRPC error: {0}")]
58
-
Xrpc(String),
59
-
#[error("URL parse error: {0}")]
60
-
Url(#[from] url::ParseError),
61
-
#[error("DNS error: {0}")]
62
-
#[cfg(feature = "dns")]
63
-
Dns(#[from] hickory_resolver::error::ResolveError),
64
-
#[error("serialize/deserialize error: {0}")]
65
-
Serde(#[from] serde_json::Error),
66
-
#[error("invalid DID document: {0}")]
67
-
InvalidDoc(String),
68
-
#[error(transparent)]
69
-
Data(#[from] AtDataError),
70
-
/// DID document id did not match requested DID; includes the fetched document
71
-
#[error("DID doc id mismatch")]
72
-
DocIdMismatch {
73
-
expected: Did<'static>,
74
-
doc: DidDocument<'static>,
75
-
},
76
-
}
77
-
78
-
/// Source to fetch PLC (did:plc) documents from.
79
-
///
80
-
/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
81
-
/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
82
-
/// `com.atproto.identity.resolveHandle` and a "mini-doc"
83
-
/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
84
-
#[derive(Debug, Clone, PartialEq, Eq)]
85
-
pub enum PlcSource {
86
-
/// Use the public PLC directory
87
-
PlcDirectory {
88
-
/// Base URL for the PLC directory
89
-
base: Url,
90
-
},
91
-
/// Use the slingshot mini-docs service
92
-
Slingshot {
93
-
/// Base URL for the Slingshot service
94
-
base: Url,
95
-
},
96
-
}
97
-
98
-
impl Default for PlcSource {
99
-
fn default() -> Self {
100
-
Self::PlcDirectory {
101
-
base: Url::parse("https://plc.directory/").expect("valid url"),
102
-
}
103
-
}
104
-
}
105
-
106
-
impl PlcSource {
107
-
/// Default Slingshot source (`https://slingshot.microcosm.blue`)
108
-
pub fn slingshot_default() -> Self {
109
-
PlcSource::Slingshot {
110
-
base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
111
-
}
112
-
}
113
-
}
114
-
115
-
/// DID Document fetch response for borrowed/owned parsing.
116
-
///
117
-
/// Carries the raw response bytes and the HTTP status, plus the requested DID
118
-
/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
119
-
/// or `parse_validated()` to also enforce that the doc `id` matches the
120
-
/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
121
-
/// mismatch). Use `into_owned()` to parse into an owned document.
122
-
#[derive(Clone)]
123
-
pub struct DidDocResponse {
124
-
buffer: Bytes,
125
-
status: StatusCode,
126
-
/// Optional DID we intended to resolve; used for validation helpers
127
-
requested: Option<Did<'static>>,
128
-
}
129
-
130
-
impl DidDocResponse {
131
-
/// Parse as borrowed DidDocument<'_>
132
-
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
133
-
if self.status.is_success() {
134
-
if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
135
-
Ok(doc)
136
-
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
137
-
Ok(DidDocument {
138
-
id: mini_doc.did,
139
-
also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
140
-
verification_method: None,
141
-
service: Some(vec![Service {
142
-
id: CowStr::new_static("#atproto_pds"),
143
-
r#type: CowStr::new_static("AtprotoPersonalDataServer"),
144
-
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
145
-
Url::from_str(&mini_doc.pds).unwrap(),
146
-
)))),
147
-
extra_data: BTreeMap::new(),
148
-
}]),
149
-
extra_data: BTreeMap::new(),
150
-
})
151
-
} else {
152
-
Err(IdentityError::MissingPdsEndpoint)
153
-
}
154
-
} else {
155
-
Err(IdentityError::HttpStatus(self.status))
156
-
}
157
-
}
158
-
159
-
/// Parse and validate that the DID in the document matches the requested DID if present.
160
-
///
161
-
/// On mismatch, returns an error that contains the owned document for inspection.
162
-
pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
163
-
let doc = self.parse()?;
164
-
if let Some(expected) = &self.requested {
165
-
if doc.id.as_str() != expected.as_str() {
166
-
return Err(IdentityError::DocIdMismatch {
167
-
expected: expected.clone(),
168
-
doc: doc.clone().into_static(),
169
-
});
170
-
}
171
-
}
172
-
Ok(doc)
173
-
}
174
-
175
-
/// Parse as owned DidDocument<'static>
176
-
pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
177
-
if self.status.is_success() {
178
-
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
179
-
Ok(doc.into_static())
180
-
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
181
-
Ok(DidDocument {
182
-
id: mini_doc.did,
183
-
also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
184
-
verification_method: None,
185
-
service: Some(vec![Service {
186
-
id: CowStr::new_static("#atproto_pds"),
187
-
r#type: CowStr::new_static("AtprotoPersonalDataServer"),
188
-
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
189
-
Url::from_str(&mini_doc.pds).unwrap(),
190
-
)))),
191
-
extra_data: BTreeMap::new(),
192
-
}]),
193
-
extra_data: BTreeMap::new(),
194
-
}
195
-
.into_static())
196
-
} else {
197
-
Err(IdentityError::MissingPdsEndpoint)
198
-
}
199
-
} else {
200
-
Err(IdentityError::HttpStatus(self.status))
201
-
}
202
-
}
203
-
}
204
-
205
-
/// Handle โ DID fallback step.
206
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207
-
pub enum HandleStep {
208
-
/// DNS TXT _atproto.\<handle\>
209
-
DnsTxt,
210
-
/// HTTPS GET https://\<handle\>/.well-known/atproto-did
211
-
HttpsWellKnown,
212
-
/// XRPC com.atproto.identity.resolveHandle against a provided PDS base
213
-
PdsResolveHandle,
214
-
}
215
-
216
-
/// DID โ Doc fallback step.
217
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218
-
pub enum DidStep {
219
-
/// For did:web: fetch from the well-known location
220
-
DidWebHttps,
221
-
/// For did:plc: fetch from PLC source
222
-
PlcHttp,
223
-
/// If a PDS base is known, ask it for the DID doc
224
-
PdsResolveDid,
225
-
}
226
-
227
-
/// Configurable resolver options.
228
-
///
229
-
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
230
-
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
231
-
/// XRPC over reqwest; authentication can be layered as needed).
232
-
/// - `handle_order`/`did_order`: ordered strategies for resolution.
233
-
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
234
-
/// returning `DocIdMismatch` with the fetched document on mismatch.
235
-
/// - `public_fallback_for_handle`: if true (default), attempt
236
-
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
237
-
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
238
-
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
239
-
#[derive(Debug, Clone, Builder)]
240
-
#[builder(start_fn = new)]
241
-
pub struct ResolverOptions {
242
-
/// PLC data source (directory or slingshot)
243
-
pub plc_source: PlcSource,
244
-
/// Optional PDS base to use for fallbacks
245
-
pub pds_fallback: Option<Url>,
246
-
/// Order of attempts for handle โ DID resolution
247
-
pub handle_order: Vec<HandleStep>,
248
-
/// Order of attempts for DID โ Doc resolution
249
-
pub did_order: Vec<DidStep>,
250
-
/// Validate that fetched DID document id matches the requested DID
251
-
pub validate_doc_id: bool,
252
-
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
253
-
pub public_fallback_for_handle: bool,
254
-
}
255
-
256
-
impl Default for ResolverOptions {
257
-
fn default() -> Self {
258
-
// By default, prefer DNS then HTTPS for handles, then PDS fallback
259
-
// For DID documents, prefer method-native sources, then PDS fallback
260
-
Self::new()
261
-
.plc_source(PlcSource::default())
262
-
.handle_order(vec![
263
-
HandleStep::DnsTxt,
264
-
HandleStep::HttpsWellKnown,
265
-
HandleStep::PdsResolveHandle,
266
-
])
267
-
.did_order(vec![
268
-
DidStep::DidWebHttps,
269
-
DidStep::PlcHttp,
270
-
DidStep::PdsResolveDid,
271
-
])
272
-
.validate_doc_id(true)
273
-
.public_fallback_for_handle(true)
274
-
.build()
275
-
}
276
-
}
277
-
278
-
/// Trait for identity resolution, for pluggable implementations.
279
-
///
280
-
/// The provided `DefaultResolver` supports:
281
-
/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
282
-
/// - HTTPS well-known for handles and `did:web`
283
-
/// - PLC directory or Slingshot for `did:plc`
284
-
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
285
-
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
286
-
#[async_trait::async_trait]
287
-
pub trait IdentityResolver {
288
-
/// Access options for validation decisions in default methods
289
-
fn options(&self) -> &ResolverOptions;
290
-
291
-
/// Resolve handle
292
-
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
293
-
294
-
/// Resolve DID document
295
-
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
296
-
297
-
/// Resolve the DID document and return an owned version
298
-
async fn resolve_did_doc_owned(
299
-
&self,
300
-
did: &Did<'_>,
301
-
) -> Result<DidDocument<'static>, IdentityError> {
302
-
self.resolve_did_doc(did).await?.into_owned()
303
-
}
304
-
/// reutrn the PDS url for a DID
305
-
async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
306
-
let resp = self.resolve_did_doc(did).await?;
307
-
let doc = resp.parse()?;
308
-
// Default-on doc id equality check
309
-
if self.options().validate_doc_id {
310
-
if doc.id.as_str() != did.as_str() {
311
-
return Err(IdentityError::DocIdMismatch {
312
-
expected: did.clone().into_static(),
313
-
doc: doc.clone().into_static(),
314
-
});
315
-
}
316
-
}
317
-
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
318
-
}
319
-
/// Return the DIS and PDS url for a handle
320
-
async fn pds_for_handle(
321
-
&self,
322
-
handle: &Handle<'_>,
323
-
) -> Result<(Did<'static>, Url), IdentityError> {
324
-
let did = self.resolve_handle(handle).await?;
325
-
let pds = self.pds_for_did(&did).await?;
326
-
Ok((did, pds))
327
-
}
328
-
}
329
-
330
-
/// Default resolver implementation with configurable fallback order.
331
-
pub struct DefaultResolver {
332
-
http: reqwest::Client,
333
-
opts: ResolverOptions,
334
-
#[cfg(feature = "dns")]
335
-
dns: Option<TokioAsyncResolver>,
336
-
}
337
-
338
-
impl DefaultResolver {
339
-
/// Create a new instance of the default resolver with all options (except DNS) up front
340
-
pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
341
-
Self {
342
-
http,
343
-
opts,
344
-
#[cfg(feature = "dns")]
345
-
dns: None,
346
-
}
347
-
}
348
-
349
-
#[cfg(feature = "dns")]
350
-
/// Add default DNS resolution to the resolver
351
-
pub fn with_system_dns(mut self) -> Self {
352
-
self.dns = Some(TokioAsyncResolver::tokio(
353
-
ResolverConfig::default(),
354
-
Default::default(),
355
-
));
356
-
self
357
-
}
358
-
359
-
/// Set PLC source (PLC directory or Slingshot)
360
-
pub fn with_plc_source(mut self, source: PlcSource) -> Self {
361
-
self.opts.plc_source = source;
362
-
self
363
-
}
364
-
365
-
/// Enable/disable public unauthenticated fallback for resolveHandle
366
-
pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
367
-
self.opts.public_fallback_for_handle = enable;
368
-
self
369
-
}
370
-
371
-
/// Enable/disable doc id validation
372
-
pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
373
-
self.opts.validate_doc_id = enable;
374
-
self
375
-
}
376
-
377
-
/// Construct the well-known HTTPS URL for a `did:web` DID.
378
-
///
379
-
/// - `did:web:example.com` โ `https://example.com/.well-known/did.json`
380
-
/// - `did:web:example.com:user:alice` โ `https://example.com/user/alice/did.json`
381
-
fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
382
-
// did:web:example.com[:path:segments]
383
-
let s = did.as_str();
384
-
let rest = s
385
-
.strip_prefix("did:web:")
386
-
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
387
-
let mut parts = rest.split(':');
388
-
let host = parts
389
-
.next()
390
-
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
391
-
let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
392
-
let path: Vec<&str> = parts.collect();
393
-
if path.is_empty() {
394
-
url.set_path(".well-known/did.json");
395
-
} else {
396
-
// Append path segments and did.json
397
-
let mut segments = url
398
-
.path_segments_mut()
399
-
.map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
400
-
for seg in path {
401
-
// Minimally percent-decode each segment per spec guidance
402
-
let decoded = percent_decode_str(seg).decode_utf8_lossy();
403
-
segments.push(&decoded);
404
-
}
405
-
segments.push("did.json");
406
-
// drop segments
407
-
}
408
-
Ok(url)
409
-
}
410
-
411
-
#[cfg(test)]
412
-
fn test_did_web_url_raw(&self, s: &str) -> String {
413
-
let did = Did::new(s).unwrap();
414
-
self.did_web_url(&did).unwrap().to_string()
415
-
}
416
-
417
-
async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
418
-
let resp = self.http.get(url).send().await?;
419
-
let status = resp.status();
420
-
let buf = resp.bytes().await?;
421
-
Ok((buf, status))
422
-
}
423
-
424
-
async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
425
-
let resp = self.http.get(url).send().await?;
426
-
if resp.status() == StatusCode::OK {
427
-
Ok(resp.text().await?)
428
-
} else {
429
-
Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
430
-
}
431
-
}
432
-
433
-
#[cfg(feature = "dns")]
434
-
async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
435
-
let Some(dns) = &self.dns else {
436
-
return Ok(vec![]);
437
-
};
438
-
let fqdn = format!("_atproto.{name}.");
439
-
let response = dns.txt_lookup(fqdn).await?;
440
-
let mut out = Vec::new();
441
-
for txt in response.iter() {
442
-
for data in txt.txt_data().iter() {
443
-
out.push(String::from_utf8_lossy(data).to_string());
444
-
}
445
-
}
446
-
Ok(out)
447
-
}
448
-
449
-
fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
450
-
let line = body
451
-
.lines()
452
-
.find(|l| !l.trim().is_empty())
453
-
.ok_or(IdentityError::InvalidWellKnown)?;
454
-
let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
455
-
Ok(did.into_static())
456
-
}
457
-
}
458
-
459
-
impl DefaultResolver {
460
-
/// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
461
-
pub async fn resolve_handle_via_pds(
462
-
&self,
463
-
handle: &Handle<'_>,
464
-
) -> Result<Did<'static>, IdentityError> {
465
-
let pds = match &self.opts.pds_fallback {
466
-
Some(u) => u.clone(),
467
-
None => return Err(IdentityError::InvalidWellKnown),
468
-
};
469
-
let req = ResolveHandle::new().handle((*handle).clone()).build();
470
-
let resp = self
471
-
.http
472
-
.xrpc(pds)
473
-
.send(req)
474
-
.await
475
-
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
476
-
let out = resp
477
-
.into_output()
478
-
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
479
-
Did::new_owned(out.did.as_str())
480
-
.map(|d| d.into_static())
481
-
.map_err(|_| IdentityError::InvalidWellKnown)
482
-
}
483
-
484
-
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
485
-
pub async fn fetch_did_doc_via_pds_owned(
486
-
&self,
487
-
did: &Did<'_>,
488
-
) -> Result<DidDocument<'static>, IdentityError> {
489
-
let pds = match &self.opts.pds_fallback {
490
-
Some(u) => u.clone(),
491
-
None => return Err(IdentityError::InvalidWellKnown),
492
-
};
493
-
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
494
-
let resp = self
495
-
.http
496
-
.xrpc(pds)
497
-
.send(req)
498
-
.await
499
-
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
500
-
let out = resp
501
-
.into_output()
502
-
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
503
-
let doc_json = serde_json::to_value(&out.did_doc)?;
504
-
let s = serde_json::to_string(&doc_json)?;
505
-
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
506
-
Ok(doc_borrowed.into_static())
507
-
}
508
-
509
-
/// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
510
-
/// Returns the raw response wrapper for borrowed parsing and validation.
511
-
pub async fn fetch_mini_doc_via_slingshot(
512
-
&self,
513
-
did: &Did<'_>,
514
-
) -> Result<DidDocResponse, IdentityError> {
515
-
let base = match &self.opts.plc_source {
516
-
PlcSource::Slingshot { base } => base.clone(),
517
-
_ => {
518
-
return Err(IdentityError::UnsupportedDidMethod(
519
-
"mini-doc requires Slingshot source".into(),
520
-
));
521
-
}
522
-
};
523
-
let mut url = base;
524
-
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
525
-
if let Ok(qs) =
526
-
serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
527
-
{
528
-
url.set_query(Some(&qs));
529
-
}
530
-
let (buf, status) = self.get_json_bytes(url).await?;
531
-
Ok(DidDocResponse {
532
-
buffer: buf,
533
-
status,
534
-
requested: Some(did.clone().into_static()),
535
-
})
536
-
}
537
-
}
538
-
539
-
#[async_trait::async_trait]
540
-
impl IdentityResolver for DefaultResolver {
541
-
fn options(&self) -> &ResolverOptions {
542
-
&self.opts
543
-
}
544
-
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
545
-
let host = handle.as_str();
546
-
for step in &self.opts.handle_order {
547
-
match step {
548
-
HandleStep::DnsTxt => {
549
-
#[cfg(feature = "dns")]
550
-
{
551
-
if let Ok(txts) = self.dns_txt(host).await {
552
-
for txt in txts {
553
-
if let Some(did_str) = txt.strip_prefix("did=") {
554
-
if let Ok(did) = Did::new(did_str) {
555
-
return Ok(did.into_static());
556
-
}
557
-
}
558
-
}
559
-
}
560
-
}
561
-
}
562
-
HandleStep::HttpsWellKnown => {
563
-
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
564
-
if let Ok(text) = self.get_text(url).await {
565
-
if let Ok(did) = Self::parse_atproto_did_body(&text) {
566
-
return Ok(did);
567
-
}
568
-
}
569
-
}
570
-
HandleStep::PdsResolveHandle => {
571
-
// Prefer PDS XRPC via stateless client
572
-
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
573
-
return Ok(did);
574
-
}
575
-
// Public unauth fallback
576
-
if self.opts.public_fallback_for_handle {
577
-
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
578
-
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
579
-
if let Ok(qs) = serde_html_form::to_string(
580
-
&ResolveHandle::new().handle((*handle).clone()).build(),
581
-
) {
582
-
url.set_query(Some(&qs));
583
-
} else {
584
-
continue;
585
-
}
586
-
if let Ok((buf, status)) = self.get_json_bytes(url).await {
587
-
if status.is_success() {
588
-
if let Ok(val) =
589
-
serde_json::from_slice::<serde_json::Value>(&buf)
590
-
{
591
-
if let Some(did_str) =
592
-
val.get("did").and_then(|v| v.as_str())
593
-
{
594
-
if let Ok(did) = Did::new_owned(did_str) {
595
-
return Ok(did.into_static());
596
-
}
597
-
}
598
-
}
599
-
}
600
-
}
601
-
}
602
-
}
603
-
// Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
604
-
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
605
-
let mut url = base.clone();
606
-
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
607
-
if let Ok(qs) = serde_html_form::to_string(
608
-
&ResolveHandle::new().handle((*handle).clone()).build(),
609
-
) {
610
-
url.set_query(Some(&qs));
611
-
} else {
612
-
continue;
613
-
}
614
-
if let Ok((buf, status)) = self.get_json_bytes(url).await {
615
-
if status.is_success() {
616
-
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
617
-
if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
618
-
if let Ok(did) = Did::new_owned(did_str) {
619
-
return Ok(did.into_static());
620
-
}
621
-
}
622
-
}
623
-
}
624
-
}
625
-
}
626
-
}
627
-
}
628
-
}
629
-
Err(IdentityError::InvalidWellKnown)
630
-
}
631
-
632
-
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
633
-
let s = did.as_str();
634
-
for step in &self.opts.did_order {
635
-
match step {
636
-
DidStep::DidWebHttps if s.starts_with("did:web:") => {
637
-
let url = self.did_web_url(did)?;
638
-
if let Ok((buf, status)) = self.get_json_bytes(url).await {
639
-
return Ok(DidDocResponse {
640
-
buffer: buf,
641
-
status,
642
-
requested: Some(did.clone().into_static()),
643
-
});
644
-
}
645
-
}
646
-
DidStep::PlcHttp if s.starts_with("did:plc:") => {
647
-
let url = match &self.opts.plc_source {
648
-
PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
649
-
PlcSource::Slingshot { base } => base.join(did.as_str())?,
650
-
};
651
-
if let Ok((buf, status)) = self.get_json_bytes(url).await {
652
-
return Ok(DidDocResponse {
653
-
buffer: buf,
654
-
status,
655
-
requested: Some(did.clone().into_static()),
656
-
});
657
-
}
658
-
}
659
-
DidStep::PdsResolveDid => {
660
-
// Try PDS XRPC for full DID doc
661
-
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
662
-
let buf = serde_json::to_vec(&doc).unwrap_or_default();
663
-
return Ok(DidDocResponse {
664
-
buffer: Bytes::from(buf),
665
-
status: StatusCode::OK,
666
-
requested: Some(did.clone().into_static()),
667
-
});
668
-
}
669
-
// Fallback: if Slingshot configured, return mini-doc response (partial doc)
670
-
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
671
-
let url = self.slingshot_mini_doc_url(base, did.as_str())?;
672
-
let (buf, status) = self.get_json_bytes(url).await?;
673
-
return Ok(DidDocResponse {
674
-
buffer: buf,
675
-
status,
676
-
requested: Some(did.clone().into_static()),
677
-
});
678
-
}
679
-
}
680
-
_ => {}
681
-
}
682
-
}
683
-
Err(IdentityError::UnsupportedDidMethod(s.to_string()))
684
-
}
685
-
}
686
-
687
-
/// Warnings produced during identity checks that are not fatal
688
-
#[derive(Debug, Clone, PartialEq, Eq)]
689
-
pub enum IdentityWarning {
690
-
/// The DID doc did not contain the expected handle alias under alsoKnownAs
691
-
HandleAliasMismatch {
692
-
#[allow(missing_docs)]
693
-
expected: Handle<'static>,
694
-
},
695
-
}
696
-
697
-
impl DefaultResolver {
698
-
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
699
-
/// This applies the default equality check on the document id (error with doc if mismatch).
700
-
pub async fn resolve_handle_and_doc(
701
-
&self,
702
-
handle: &Handle<'_>,
703
-
) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
704
-
let did = self.resolve_handle(handle).await?;
705
-
let resp = self.resolve_did_doc(&did).await?;
706
-
let resp_for_parse = resp.clone();
707
-
let doc_borrowed = resp_for_parse.parse()?;
708
-
if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
709
-
return Err(IdentityError::DocIdMismatch {
710
-
expected: did.clone().into_static(),
711
-
doc: doc_borrowed.clone().into_static(),
712
-
});
713
-
}
714
-
let mut warnings = Vec::new();
715
-
// Check handle alias presence (soft warning)
716
-
let expected_alias = format!("at://{}", handle.as_str());
717
-
let has_alias = doc_borrowed
718
-
.also_known_as
719
-
.as_ref()
720
-
.map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
721
-
.unwrap_or(false);
722
-
if !has_alias {
723
-
warnings.push(IdentityWarning::HandleAliasMismatch {
724
-
expected: handle.clone().into_static(),
725
-
});
726
-
}
727
-
Ok((did, resp, warnings))
728
-
}
729
-
730
-
/// Build Slingshot mini-doc URL for an identifier (handle or DID)
731
-
fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
732
-
let mut url = base.clone();
733
-
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
734
-
url.set_query(Some(&format!(
735
-
"identifier={}",
736
-
urlencoding::Encoded::new(identifier)
737
-
)));
738
-
Ok(url)
739
-
}
740
-
741
-
/// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
742
-
pub async fn fetch_mini_doc_via_slingshot_identifier(
743
-
&self,
744
-
identifier: &AtIdentifier<'_>,
745
-
) -> Result<MiniDocResponse, IdentityError> {
746
-
let base = match &self.opts.plc_source {
747
-
PlcSource::Slingshot { base } => base.clone(),
748
-
_ => {
749
-
return Err(IdentityError::UnsupportedDidMethod(
750
-
"mini-doc requires Slingshot source".into(),
751
-
));
752
-
}
753
-
};
754
-
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
755
-
let (buf, status) = self.get_json_bytes(url).await?;
756
-
Ok(MiniDocResponse {
757
-
buffer: buf,
758
-
status,
759
-
})
760
-
}
761
-
}
762
-
763
-
/// Slingshot mini-doc JSON response wrapper
764
-
#[derive(Clone)]
765
-
pub struct MiniDocResponse {
766
-
buffer: Bytes,
767
-
status: StatusCode,
768
-
}
769
-
770
-
impl MiniDocResponse {
771
-
/// Parse borrowed MiniDoc
772
-
pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
773
-
if self.status.is_success() {
774
-
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
775
-
} else {
776
-
Err(IdentityError::HttpStatus(self.status))
777
-
}
778
-
}
779
-
}
780
-
781
-
/// Slingshot mini-doc data (subset of DID doc info)
782
-
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
783
-
#[serde(rename_all = "camelCase")]
784
-
#[allow(missing_docs)]
785
-
pub struct MiniDoc<'a> {
786
-
#[serde(borrow)]
787
-
pub did: Did<'a>,
788
-
#[serde(borrow)]
789
-
pub handle: Handle<'a>,
790
-
#[serde(borrow)]
791
-
pub pds: crate::CowStr<'a>,
792
-
#[serde(borrow, rename = "signingKey", alias = "signing_key")]
793
-
pub signing_key: crate::CowStr<'a>,
794
-
}
795
-
796
-
#[cfg(test)]
797
-
mod tests {
798
-
use super::*;
799
-
800
-
#[test]
801
-
fn did_web_urls() {
802
-
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
803
-
assert_eq!(
804
-
r.test_did_web_url_raw("did:web:example.com"),
805
-
"https://example.com/.well-known/did.json"
806
-
);
807
-
assert_eq!(
808
-
r.test_did_web_url_raw("did:web:example.com:user:alice"),
809
-
"https://example.com/user/alice/did.json"
810
-
);
811
-
}
812
-
813
-
#[test]
814
-
fn parse_validated_ok() {
815
-
let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
816
-
let requested = Did::new_owned("did:plc:alice").unwrap();
817
-
let resp = DidDocResponse {
818
-
buffer: buf,
819
-
status: StatusCode::OK,
820
-
requested: Some(requested),
821
-
};
822
-
let _doc = resp.parse_validated().expect("valid");
823
-
}
824
-
825
-
#[test]
826
-
fn parse_validated_mismatch() {
827
-
let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
828
-
let requested = Did::new_owned("did:plc:alice").unwrap();
829
-
let resp = DidDocResponse {
830
-
buffer: buf,
831
-
status: StatusCode::OK,
832
-
requested: Some(requested),
833
-
};
834
-
match resp.parse_validated() {
835
-
Err(IdentityError::DocIdMismatch { expected, doc }) => {
836
-
assert_eq!(expected.as_str(), "did:plc:alice");
837
-
assert_eq!(doc.id.as_str(), "did:plc:bob");
838
-
}
839
-
other => panic!("unexpected result: {:?}", other),
840
-
}
841
-
}
842
-
843
-
#[test]
844
-
fn slingshot_mini_doc_url_build() {
845
-
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
846
-
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
847
-
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
848
-
assert_eq!(
849
-
url.as_str(),
850
-
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
851
-
);
852
-
}
853
-
854
-
#[test]
855
-
fn slingshot_mini_doc_parse_success() {
856
-
let buf = Bytes::from_static(
857
-
br#"{
858
-
"did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
859
-
"handle": "bad-example.com",
860
-
"pds": "https://porcini.us-east.host.bsky.network",
861
-
"signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
862
-
}"#,
863
-
);
864
-
let resp = MiniDocResponse {
865
-
buffer: buf,
866
-
status: StatusCode::OK,
867
-
};
868
-
let doc = resp.parse().expect("parse mini-doc");
869
-
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
870
-
assert_eq!(doc.handle.as_str(), "bad-example.com");
871
-
assert_eq!(
872
-
doc.pds.as_ref(),
873
-
"https://porcini.us-east.host.bsky.network"
874
-
);
875
-
assert!(doc.signing_key.as_ref().starts_with('z'));
876
-
}
877
-
878
-
#[test]
879
-
fn slingshot_mini_doc_parse_error_status() {
880
-
let buf = Bytes::from_static(
881
-
br#"{
882
-
"error": "RecordNotFound",
883
-
"message": "This record was deleted"
884
-
}"#,
885
-
);
886
-
let resp = MiniDocResponse {
887
-
buffer: buf,
888
-
status: StatusCode::BAD_REQUEST,
889
-
};
890
-
match resp.parse() {
891
-
Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
892
-
other => panic!("unexpected: {:?}", other),
893
-
}
894
-
}
895
-
}
896
-
897
-
/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
898
-
pub type PublicResolver = DefaultResolver;
899
-
900
-
impl Default for PublicResolver {
901
-
/// Build a resolver with:
902
-
/// - reqwest HTTP client
903
-
/// - Public fallbacks enabled for handle resolution
904
-
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
905
-
///
906
-
/// Example
907
-
/// ```ignore
908
-
/// use jacquard::identity::resolver::PublicResolver;
909
-
/// let resolver = PublicResolver::default();
910
-
/// ```
911
-
fn default() -> Self {
912
-
let http = reqwest::Client::new();
913
-
let opts = ResolverOptions::default();
914
-
let resolver = DefaultResolver::new(http, opts);
915
-
#[cfg(feature = "dns")]
916
-
let resolver = resolver.with_system_dns();
917
-
resolver
918
-
}
919
-
}
920
-
921
-
/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
922
-
/// mini-doc fallbacks, unauthenticated by default.
923
-
pub fn slingshot_resolver_default() -> PublicResolver {
924
-
let http = reqwest::Client::new();
925
-
let mut opts = ResolverOptions::default();
926
-
opts.plc_source = PlcSource::slingshot_default();
927
-
let resolver = DefaultResolver::new(http, opts);
928
-
#[cfg(feature = "dns")]
929
-
let resolver = resolver.with_system_dns();
930
-
resolver
931
-
}
+50
-56
crates/jacquard/src/lib.rs
+50
-56
crates/jacquard/src/lib.rs
···
17
17
//!
18
18
//! ## Example
19
19
//!
20
-
//! Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
20
+
//! Dead simple API client: login with an app password, then fetch the latest 5 posts.
21
21
//!
22
22
//! ```no_run
23
23
//! # use clap::Parser;
24
24
//! # use jacquard::CowStr;
25
+
//! use std::sync::Arc;
25
26
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26
-
//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27
-
//! use jacquard::client::{BasicClient, Session};
27
+
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
28
+
//! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
29
+
//! use jacquard::identity::PublicResolver as JacquardResolver;
30
+
//! use jacquard::types::xrpc::XrpcClient;
28
31
//! # use miette::IntoDiagnostic;
29
32
//!
30
33
//! # #[derive(Parser, Debug)]
31
34
//! # #[command(author, version, about = "Jacquard - AT Protocol client demo")]
32
35
//! # struct Args {
33
-
//! # /// Username/handle (e.g., alice.mosphere.at)
36
+
//! # /// Username/handle (e.g., alice.bsky.social) or DID
34
37
//! # #[arg(short, long)]
35
38
//! # username: CowStr<'static>,
36
39
//! #
37
-
//! # /// PDS URL (e.g., https://bsky.social)
38
-
//! # #[arg(long, default_value = "https://bsky.social")]
39
-
//! # pds: CowStr<'static>,
40
-
//! #
41
40
//! # /// App password
42
41
//! # #[arg(short, long)]
43
42
//! # password: CowStr<'static>,
···
46
45
//! #[tokio::main]
47
46
//! async fn main() -> miette::Result<()> {
48
47
//! let args = Args::parse();
49
-
//! // Create HTTP client
50
-
//! let url = url::Url::parse(&args.pds).unwrap();
51
-
//! let client = BasicClient::new(url);
52
-
//! // Create session
53
-
//! let session = Session::from(
54
-
//! client
55
-
//! .send(
56
-
//! CreateSession::new()
57
-
//! .identifier(args.username)
58
-
//! .password(args.password)
59
-
//! .build(),
60
-
//! )
61
-
//! .await?
62
-
//! .into_output()?,
63
-
//! );
64
-
//! client.set_session(session).await.unwrap();
48
+
//! // Resolver + storage
49
+
//! let resolver = Arc::new(JacquardResolver::default());
50
+
//! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
51
+
//! let client = Arc::new(resolver.clone());
52
+
//! // Create session object with implicit public appview endpoint until login/restore
53
+
//! let session = CredentialSession::new(store, client);
54
+
//! // Log in (resolves PDS automatically) and persist as (did, "session")
55
+
//! session
56
+
//! .login(args.username.clone(), args.password.clone(), None, None, None)
57
+
//! .await
58
+
//! .into_diagnostic()?;
65
59
//! // Fetch timeline
66
-
//! println!("\nfetching timeline...");
67
-
//! let timeline = client
68
-
//! .send(GetTimeline::new().limit(5).build())
69
-
//! .await?
70
-
//! .into_output()?;
71
-
//! println!("\ntimeline ({} posts):", timeline.feed.len());
60
+
//! let timeline = session
61
+
//! .send(&GetTimeline::new().limit(5).build())
62
+
//! .await
63
+
//! .into_diagnostic()?
64
+
//! .into_output()
65
+
//! .into_diagnostic()?;
66
+
//! println!("timeline ({} posts):", timeline.feed.len());
72
67
//! for (i, post) in timeline.feed.iter().enumerate() {
73
-
//! println!("\n{}. by {}", i + 1, post.post.author.handle);
74
-
//! println!(
75
-
//! " {}",
76
-
//! serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
77
-
//! );
68
+
//! println!("{}. by {}", i + 1, post.post.author.handle);
78
69
//! }
79
70
//! Ok(())
80
71
//! }
···
87
78
//! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you
88
79
//! want to pass auth on each call or build advanced flows.
89
80
//! ```no_run
90
-
//! # use jacquard::client::XrpcExt;
81
+
//! # use jacquard::types::xrpc::XrpcExt;
91
82
//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
92
83
//! # use jacquard::types::ident::AtIdentifier;
84
+
//! # use miette::IntoDiagnostic;
93
85
//! #
94
86
//! #[tokio::main]
95
-
//! async fn main() -> anyhow::Result<()> {
87
+
//! async fn main() -> miette::Result<()> {
96
88
//! let http = reqwest::Client::new();
97
-
//! let base = url::Url::parse("https://public.api.bsky.app")?;
89
+
//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
98
90
//! let resp = http
99
91
//! .xrpc(base)
100
92
//! .send(
101
-
//! GetAuthorFeed::new()
93
+
//! &GetAuthorFeed::new()
102
94
//! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
103
95
//! .limit(5)
104
96
//! .build(),
···
109
101
//! Ok(())
110
102
//! }
111
103
//! ```
112
-
//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
113
-
//! `TokenStore` implementation. It automatically sets Authorization and can
114
-
//! auto-refresh a session when expired, retrying once.
115
-
//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
116
-
//! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor.
104
+
//! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and
105
+
//! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the
106
+
//! base endpoint to the user's PDS on login/restore.
117
107
//!
118
108
//! Per-request overrides (stateless)
119
109
//! ```no_run
120
-
//! # use jacquard::client::{XrpcExt, AuthorizationToken};
110
+
//! # use jacquard::AuthorizationToken;
111
+
//! # use jacquard::types::xrpc::XrpcExt;
121
112
//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
122
113
//! # use jacquard::types::ident::AtIdentifier;
123
114
//! # use jacquard::CowStr;
···
126
117
//! #[tokio::main]
127
118
//! async fn main() -> miette::Result<()> {
128
119
//! let http = reqwest::Client::new();
129
-
//! let base = url::Url::parse("https://public.api.bsky.app")?;
120
+
//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
130
121
//! let resp = http
131
122
//! .xrpc(base)
132
123
//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
133
124
//! .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
134
125
//! .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
135
126
//! .send(
136
-
//! GetAuthorFeed::new()
127
+
//! &GetAuthorFeed::new()
137
128
//! .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
138
129
//! .limit(5)
139
130
//! .build(),
···
146
137
//! ```
147
138
//!
148
139
//! Token storage:
149
-
//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs.
150
-
//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk.
151
-
//! See `client::token::FileTokenStore` docs for details.
140
+
//! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests.
141
+
//! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions
142
+
//! and OAuth `ClientAuthStore` (unified on-disk map).
152
143
//! ```no_run
153
-
//! use jacquard::client::{AtClient, FileTokenStore};
154
-
//! let base = url::Url::parse("https://bsky.social").unwrap();
155
-
//! let store = FileTokenStore::new("/tmp/jacquard-session.json");
156
-
//! let client = AtClient::new(reqwest::Client::new(), base, store);
144
+
//! use std::sync::Arc;
145
+
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
146
+
//! use jacquard::client::{AtpSession, FileAuthStore};
147
+
//! use jacquard::identity::PublicResolver;
148
+
//! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json"));
149
+
//! let client = Arc::new(PublicResolver::default());
150
+
//! let session = CredentialSession::new(store, client);
157
151
//! ```
158
152
//!
159
153
···
161
155
162
156
/// XRPC client traits and basic implementation
163
157
pub mod client;
158
+
/// OAuth usage helpers (discovery, PAR, token exchange)
164
159
165
160
#[cfg(feature = "api")]
166
161
/// If enabled, re-export the generated api crate
···
171
166
/// if enabled, reexport the attribute macros
172
167
pub use jacquard_derive::*;
173
168
174
-
/// Identity resolution helpers (DIDs, handles, PDS endpoints)
175
-
pub mod identity;
169
+
pub use jacquard_identity as identity;
+22
-34
crates/jacquard/src/main.rs
+22
-34
crates/jacquard/src/main.rs
···
1
+
use std::sync::Arc;
1
2
use clap::Parser;
2
3
use jacquard::CowStr;
3
4
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
4
-
use jacquard::api::com_atproto::server::create_session::CreateSession;
5
-
use jacquard::client::{BasicClient, Session};
6
-
use jacquard::identity::resolver::{IdentityResolver, slingshot_resolver_default};
7
-
use jacquard::types::string::Handle;
5
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
6
+
use jacquard::client::{AtpSession, MemorySessionStore};
7
+
use jacquard::identity::PublicResolver as JacquardResolver;
8
+
use jacquard::types::xrpc::XrpcClient;
8
9
use miette::IntoDiagnostic;
9
10
10
11
#[derive(Parser, Debug)]
11
12
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
12
13
struct Args {
13
-
/// Username/handle (e.g., alice.bsky.social)
14
+
/// Username/handle (e.g., alice.bsky.social) or DID
14
15
#[arg(short, long)]
15
16
username: CowStr<'static>,
16
17
17
-
/// PDS URL (e.g., https://bsky.social)
18
-
#[arg(long, default_value = "https://bsky.social")]
19
-
pds: CowStr<'static>,
20
-
21
18
/// App password
22
19
#[arg(short, long)]
23
20
password: CowStr<'static>,
···
26
23
async fn main() -> miette::Result<()> {
27
24
let args = Args::parse();
28
25
29
-
// Resolve PDS for the handle using the Slingshot-enabled resolver
30
-
let resolver = slingshot_resolver_default();
31
-
let handle = Handle::new(args.username.as_ref()).into_diagnostic()?;
32
-
let (_did, pds_url) = resolver.pds_for_handle(&handle).await.into_diagnostic()?;
33
-
let client = BasicClient::new(pds_url);
34
-
35
-
// Create session
36
-
let session = Session::from(
37
-
client
38
-
.send(
39
-
CreateSession::new()
40
-
.identifier(args.username)
41
-
.password(args.password)
42
-
.build(),
43
-
)
44
-
.await?
45
-
.into_output()?,
46
-
);
26
+
// Resolver + in-memory store
27
+
let resolver = Arc::new(JacquardResolver::default());
28
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
29
+
let client = Arc::new(resolver.clone());
30
+
let session = CredentialSession::new(store, client);
47
31
48
-
println!("logged in as {} ({})", session.handle, session.did);
49
-
client.set_session(session).await.into_diagnostic()?;
32
+
// Login; resolves PDS from handle/DID automatically. Persisted under (did, "session").
33
+
let _ = session
34
+
.login(args.username.clone(), args.password.clone(), None, None, None)
35
+
.await
36
+
.into_diagnostic()?;
50
37
51
38
// Fetch timeline
52
-
println!("\nfetching timeline...");
53
-
let timeline = client
54
-
.send(GetTimeline::new().limit(5).build())
55
-
.await?
56
-
.into_output()?;
39
+
let timeline = session
40
+
.send(&GetTimeline::new().limit(5).build())
41
+
.await
42
+
.into_diagnostic()?
43
+
.into_output()
44
+
.into_diagnostic()?;
57
45
58
46
println!("\ntimeline ({} posts):", timeline.feed.len());
59
47
for (i, post) in timeline.feed.iter().enumerate() {
+147
crates/jacquard/tests/agent.rs
+147
crates/jacquard/tests/agent.rs
···
1
+
use std::collections::VecDeque;
2
+
use std::sync::Arc;
3
+
4
+
use http::{HeaderValue, Response as HttpResponse, StatusCode};
5
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
6
+
use jacquard::client::{Agent, AtpSession};
7
+
use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions};
8
+
use jacquard::types::did::Did;
9
+
use jacquard::types::string::Handle;
10
+
use jacquard_common::http_client::HttpClient;
11
+
use jacquard_common::session::MemorySessionStore;
12
+
use tokio::sync::Mutex;
13
+
14
+
#[derive(Clone, Default)]
15
+
struct MockClient {
16
+
queue: Arc<Mutex<VecDeque<http::Response<Vec<u8>>>>>,
17
+
log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>,
18
+
}
19
+
20
+
impl MockClient {
21
+
async fn push(&self, resp: http::Response<Vec<u8>>) {
22
+
self.queue.lock().await.push_back(resp);
23
+
}
24
+
}
25
+
26
+
impl HttpClient for MockClient {
27
+
type Error = std::convert::Infallible;
28
+
fn send_http(
29
+
&self,
30
+
request: http::Request<Vec<u8>>,
31
+
) -> impl core::future::Future<
32
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
33
+
> + Send {
34
+
let log = self.log.clone();
35
+
let queue = self.queue.clone();
36
+
async move {
37
+
log.lock().await.push(request);
38
+
Ok(queue.lock().await.pop_front().expect("no queued response"))
39
+
}
40
+
}
41
+
}
42
+
43
+
#[async_trait::async_trait]
44
+
impl IdentityResolver for MockClient {
45
+
fn options(&self) -> &ResolverOptions {
46
+
use std::sync::LazyLock;
47
+
static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default);
48
+
&OPTS
49
+
}
50
+
async fn resolve_handle(
51
+
&self,
52
+
_handle: &Handle<'_>,
53
+
) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> {
54
+
Ok(Did::new_static("did:plc:alice").unwrap())
55
+
}
56
+
async fn resolve_did_doc(
57
+
&self,
58
+
_did: &Did<'_>,
59
+
) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> {
60
+
let doc = serde_json::json!({
61
+
"id": "did:plc:alice",
62
+
"service": [{
63
+
"id": "#pds",
64
+
"type": "AtprotoPersonalDataServer",
65
+
"serviceEndpoint": "https://pds"
66
+
}]
67
+
});
68
+
Ok(DidDocResponse {
69
+
buffer: bytes::Bytes::from(serde_json::to_vec(&doc).unwrap()),
70
+
status: StatusCode::OK,
71
+
requested: None,
72
+
})
73
+
}
74
+
}
75
+
76
+
// XrpcExt blanket impl applies via HttpClient
77
+
78
+
fn refresh_session_body(access: &str, refresh: &str) -> Vec<u8> {
79
+
serde_json::to_vec(&serde_json::json!({
80
+
"accessJwt": access,
81
+
"refreshJwt": refresh,
82
+
"did": "did:plc:alice",
83
+
"handle": "alice.bsky.social"
84
+
}))
85
+
.unwrap()
86
+
}
87
+
88
+
#[tokio::test]
89
+
async fn agent_delegates_to_session_and_refreshes() {
90
+
let client = Arc::new(MockClient::default());
91
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
92
+
let session = CredentialSession::new(store.clone(), client.clone());
93
+
94
+
// Seed a session in the store and activate it via restore (sets endpoint to PDS)
95
+
let atp = AtpSession {
96
+
access_jwt: "acc1".into(),
97
+
refresh_jwt: "ref1".into(),
98
+
did: Did::new_static("did:plc:alice").unwrap(),
99
+
handle: Handle::new_static("alice.bsky.social").unwrap(),
100
+
};
101
+
let key: SessionKey = (atp.did.clone(), "session".into());
102
+
jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), atp)
103
+
.await
104
+
.unwrap();
105
+
session
106
+
.restore(Did::new_static("did:plc:alice").unwrap(), "session".into())
107
+
.await
108
+
.unwrap();
109
+
110
+
let agent: Agent<_> = Agent::from(session);
111
+
assert_eq!(agent.kind(), jacquard::client::AgentKind::AppPassword);
112
+
let info = agent.info().await.expect("session info");
113
+
assert_eq!(info.0.as_str(), "did:plc:alice");
114
+
assert_eq!(info.1.as_ref().unwrap().as_str(), "session");
115
+
assert_eq!(agent.endpoint().await.as_str(), "https://pds/");
116
+
117
+
// Queue a refresh response and call agent.refresh(); Authorization header must use refresh token
118
+
client
119
+
.push(
120
+
HttpResponse::builder()
121
+
.status(StatusCode::OK)
122
+
.header(http::header::CONTENT_TYPE, "application/json")
123
+
.body(refresh_session_body("acc2", "ref2"))
124
+
.unwrap(),
125
+
)
126
+
.await;
127
+
128
+
let token = agent.refresh().await.expect("refresh ok");
129
+
match token {
130
+
jacquard::AuthorizationToken::Bearer(s) => assert_eq!(s.as_ref(), "acc2"),
131
+
_ => panic!("expected Bearer token"),
132
+
}
133
+
134
+
// Validate the refreshSession call used the refresh token header
135
+
let log = client.log.lock().await;
136
+
assert_eq!(log.len(), 1);
137
+
assert!(
138
+
log[0]
139
+
.uri()
140
+
.to_string()
141
+
.ends_with("/xrpc/com.atproto.server.refreshSession")
142
+
);
143
+
assert_eq!(
144
+
log[0].headers().get(http::header::AUTHORIZATION),
145
+
Some(&HeaderValue::from_static("Bearer ref1"))
146
+
);
147
+
}
+261
crates/jacquard/tests/credential_session.rs
+261
crates/jacquard/tests/credential_session.rs
···
1
+
use std::collections::VecDeque;
2
+
use std::sync::Arc;
3
+
4
+
use bytes::Bytes;
5
+
use http::{HeaderValue, Method, Response as HttpResponse, StatusCode};
6
+
use jacquard::client::AtpSession;
7
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
8
+
use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions};
9
+
use jacquard::types::did::Did;
10
+
use jacquard::types::string::Handle;
11
+
use jacquard::types::xrpc::XrpcClient;
12
+
use jacquard_common::http_client::HttpClient;
13
+
use jacquard_common::session::{MemorySessionStore, SessionStore};
14
+
use tokio::sync::{Mutex, RwLock};
15
+
16
+
#[derive(Clone, Default)]
17
+
struct MockClient {
18
+
// Queue of HTTP responses to pop for each send_http call
19
+
queue: Arc<Mutex<VecDeque<HttpResponse<Vec<u8>>>>>,
20
+
// Capture requests for assertions
21
+
log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>,
22
+
// Count calls to identity resolver helpers
23
+
did_doc_calls: Arc<RwLock<usize>>,
24
+
}
25
+
26
+
impl MockClient {
27
+
async fn push(&self, resp: HttpResponse<Vec<u8>>) {
28
+
self.queue.lock().await.push_back(resp);
29
+
}
30
+
async fn take_log(&self) -> Vec<http::Request<Vec<u8>>> {
31
+
let mut log = self.log.lock().await;
32
+
let out = log.clone();
33
+
log.clear();
34
+
out
35
+
}
36
+
}
37
+
38
+
impl HttpClient for MockClient {
39
+
type Error = std::convert::Infallible;
40
+
41
+
fn send_http(
42
+
&self,
43
+
request: http::Request<Vec<u8>>,
44
+
) -> impl core::future::Future<
45
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
46
+
> + Send {
47
+
let log = self.log.clone();
48
+
let queue = self.queue.clone();
49
+
async move {
50
+
log.lock().await.push(request);
51
+
Ok(queue.lock().await.pop_front().expect("no queued response"))
52
+
}
53
+
}
54
+
}
55
+
56
+
#[async_trait::async_trait]
57
+
impl IdentityResolver for MockClient {
58
+
fn options(&self) -> &ResolverOptions {
59
+
use std::sync::LazyLock;
60
+
static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default);
61
+
&OPTS
62
+
}
63
+
64
+
async fn resolve_handle(
65
+
&self,
66
+
handle: &Handle<'_>,
67
+
) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> {
68
+
// Return a fixed DID for any handle
69
+
assert!(handle.as_str().contains('.'));
70
+
Ok(Did::new_static("did:plc:alice").unwrap())
71
+
}
72
+
73
+
async fn resolve_did_doc(
74
+
&self,
75
+
did: &Did<'_>,
76
+
) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> {
77
+
// Track calls and return a minimal DID doc with a PDS endpoint
78
+
*self.did_doc_calls.write().await += 1;
79
+
assert_eq!(did.as_str(), "did:plc:alice");
80
+
let doc = serde_json::json!({
81
+
"id": "did:plc:alice",
82
+
"service": [{
83
+
"id": "#pds",
84
+
"type": "AtprotoPersonalDataServer",
85
+
"serviceEndpoint": "https://pds"
86
+
}]
87
+
});
88
+
Ok(DidDocResponse {
89
+
buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
90
+
status: StatusCode::OK,
91
+
requested: None,
92
+
})
93
+
}
94
+
}
95
+
96
+
// XrpcExt blanket impl applies via HttpClient
97
+
98
+
fn create_session_body() -> Vec<u8> {
99
+
serde_json::to_vec(&serde_json::json!({
100
+
"accessJwt": "acc1",
101
+
"refreshJwt": "ref1",
102
+
"did": "did:plc:alice",
103
+
"handle": "alice.bsky.social"
104
+
}))
105
+
.unwrap()
106
+
}
107
+
108
+
fn refresh_session_body(access: &str, refresh: &str) -> Vec<u8> {
109
+
serde_json::to_vec(&serde_json::json!({
110
+
"accessJwt": access,
111
+
"refreshJwt": refresh,
112
+
"did": "did:plc:alice",
113
+
"handle": "alice.bsky.social"
114
+
}))
115
+
.unwrap()
116
+
}
117
+
118
+
fn get_session_ok_body() -> Vec<u8> {
119
+
serde_json::to_vec(&serde_json::json!({
120
+
"did": "did:plc:alice",
121
+
"handle": "alice.bsky.social",
122
+
"active": true
123
+
}))
124
+
.unwrap()
125
+
}
126
+
127
+
#[tokio::test(flavor = "multi_thread")]
128
+
async fn credential_login_and_auto_refresh() {
129
+
let client = Arc::new(MockClient::default());
130
+
131
+
// Queue responses in order: createSession 200 โ getSession 401 โ refreshSession 200 โ getSession 200
132
+
client
133
+
.push(
134
+
HttpResponse::builder()
135
+
.status(StatusCode::OK)
136
+
.header(http::header::CONTENT_TYPE, "application/json")
137
+
.body(create_session_body())
138
+
.unwrap(),
139
+
)
140
+
.await;
141
+
client
142
+
.push(
143
+
HttpResponse::builder()
144
+
.status(StatusCode::UNAUTHORIZED)
145
+
.header(http::header::CONTENT_TYPE, "application/json")
146
+
.body(serde_json::to_vec(&serde_json::json!({"error":"ExpiredToken"})).unwrap())
147
+
.unwrap(),
148
+
)
149
+
.await;
150
+
client
151
+
.push(
152
+
HttpResponse::builder()
153
+
.status(StatusCode::OK)
154
+
.header(http::header::CONTENT_TYPE, "application/json")
155
+
.body(refresh_session_body("acc2", "ref2"))
156
+
.unwrap(),
157
+
)
158
+
.await;
159
+
client
160
+
.push(
161
+
HttpResponse::builder()
162
+
.status(StatusCode::OK)
163
+
.header(http::header::CONTENT_TYPE, "application/json")
164
+
.body(get_session_ok_body())
165
+
.unwrap(),
166
+
)
167
+
.await;
168
+
169
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
170
+
let session = CredentialSession::new(store.clone(), client.clone());
171
+
172
+
// Before login, default endpoint should be public appview
173
+
assert_eq!(
174
+
session.endpoint().await.as_str(),
175
+
"https://public.bsky.app/"
176
+
);
177
+
178
+
// Login using handle; resolves to PDS and persists session
179
+
session
180
+
.login(
181
+
jacquard::CowStr::from("alice.bsky.social"),
182
+
jacquard::CowStr::from("apppass"),
183
+
Some(jacquard::CowStr::from("session")),
184
+
None,
185
+
None,
186
+
)
187
+
.await
188
+
.expect("login ok");
189
+
190
+
// Endpoint switches to PDS
191
+
assert_eq!(session.endpoint().await.as_str(), "https://pds/");
192
+
193
+
// Send a request that will first 401 (ExpiredToken), then refresh, then succeed
194
+
let resp = session
195
+
.send(&jacquard::api::com_atproto::server::get_session::GetSession)
196
+
.await
197
+
.expect("xrpc send ok");
198
+
assert_eq!(resp.status(), StatusCode::OK);
199
+
let out = resp
200
+
.parse()
201
+
.expect("parse ok after refresh (GetSession output)");
202
+
assert_eq!(out.handle.as_str(), "alice.bsky.social");
203
+
204
+
// Verify request sequence and Authorization headers used
205
+
let log = client.take_log().await;
206
+
assert_eq!(log.len(), 4, "expected four HTTP calls");
207
+
// 0: createSession (no auth)
208
+
assert_eq!(log[0].method(), Method::POST);
209
+
assert!(
210
+
log[0]
211
+
.uri()
212
+
.to_string()
213
+
.ends_with("/xrpc/com.atproto.server.createSession")
214
+
);
215
+
assert!(log[0].headers().get(http::header::AUTHORIZATION).is_none());
216
+
// 1: getSession (uses access token acc1)
217
+
assert_eq!(log[1].method(), Method::GET);
218
+
assert!(
219
+
log[1]
220
+
.uri()
221
+
.to_string()
222
+
.ends_with("/xrpc/com.atproto.server.getSession")
223
+
);
224
+
assert_eq!(
225
+
log[1].headers().get(http::header::AUTHORIZATION),
226
+
Some(&HeaderValue::from_static("Bearer acc1"))
227
+
);
228
+
// 2: refreshSession (uses refresh token ref1)
229
+
assert_eq!(log[2].method(), Method::POST);
230
+
assert!(
231
+
log[2]
232
+
.uri()
233
+
.to_string()
234
+
.ends_with("/xrpc/com.atproto.server.refreshSession")
235
+
);
236
+
assert_eq!(
237
+
log[2].headers().get(http::header::AUTHORIZATION),
238
+
Some(&HeaderValue::from_static("Bearer ref1"))
239
+
);
240
+
// 3: getSession (re-sent with new access token acc2)
241
+
assert_eq!(log[3].method(), Method::GET);
242
+
assert!(
243
+
log[3]
244
+
.uri()
245
+
.to_string()
246
+
.ends_with("/xrpc/com.atproto.server.getSession")
247
+
);
248
+
assert_eq!(
249
+
log[3].headers().get(http::header::AUTHORIZATION),
250
+
Some(&HeaderValue::from_static("Bearer acc2"))
251
+
);
252
+
253
+
// Verify store updated with refreshed tokens
254
+
let key: SessionKey = (
255
+
Did::new_static("did:plc:alice").unwrap(),
256
+
jacquard::CowStr::from("session"),
257
+
);
258
+
let updated = store.get(&key).await.expect("session present");
259
+
assert_eq!(updated.access_jwt.as_ref(), "acc2");
260
+
assert_eq!(updated.refresh_jwt.as_ref(), "ref2");
261
+
}
+374
crates/jacquard/tests/oauth_auto_refresh.rs
+374
crates/jacquard/tests/oauth_auto_refresh.rs
···
1
+
use std::collections::VecDeque;
2
+
use std::sync::Arc;
3
+
4
+
use bytes::Bytes;
5
+
use http::{HeaderValue, Method, Response as HttpResponse, StatusCode};
6
+
use jacquard::client::Agent;
7
+
use jacquard::IntoStatic;
8
+
use jacquard::types::did::Did;
9
+
use jacquard::types::xrpc::XrpcClient;
10
+
use jacquard_common::http_client::HttpClient;
11
+
use jacquard_oauth::atproto::AtprotoClientMetadata;
12
+
use jacquard_oauth::client::OAuthSession;
13
+
use jacquard_oauth::session::SessionRegistry;
14
+
use jacquard_oauth::resolver::OAuthResolver;
15
+
use jacquard_oauth::scopes::Scope;
16
+
use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData};
17
+
use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet};
18
+
use tokio::sync::Mutex;
19
+
20
+
#[derive(Clone, Default)]
21
+
struct MockClient {
22
+
queue: Arc<Mutex<VecDeque<http::Response<Vec<u8>>>>>,
23
+
log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>,
24
+
}
25
+
26
+
impl MockClient {
27
+
async fn push(&self, resp: http::Response<Vec<u8>>) {
28
+
self.queue.lock().await.push_back(resp);
29
+
}
30
+
}
31
+
32
+
impl HttpClient for MockClient {
33
+
type Error = std::convert::Infallible;
34
+
fn send_http(
35
+
&self,
36
+
request: http::Request<Vec<u8>>,
37
+
) -> impl core::future::Future<
38
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
39
+
> + Send {
40
+
let log = self.log.clone();
41
+
let queue = self.queue.clone();
42
+
async move {
43
+
log.lock().await.push(request);
44
+
Ok(queue
45
+
.lock()
46
+
.await
47
+
.pop_front()
48
+
.expect("no queued response"))
49
+
}
50
+
}
51
+
}
52
+
53
+
#[async_trait::async_trait]
54
+
impl jacquard::identity::resolver::IdentityResolver for MockClient {
55
+
fn options(&self) -> &jacquard::identity::resolver::ResolverOptions {
56
+
use std::sync::LazyLock;
57
+
static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> =
58
+
LazyLock::new(jacquard::identity::resolver::ResolverOptions::default);
59
+
&OPTS
60
+
}
61
+
async fn resolve_handle(
62
+
&self,
63
+
_handle: &jacquard::types::string::Handle<'_>,
64
+
) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> {
65
+
Ok(Did::new_static("did:plc:alice").unwrap())
66
+
}
67
+
async fn resolve_did_doc(
68
+
&self,
69
+
_did: &Did<'_>,
70
+
) -> std::result::Result<jacquard::identity::resolver::DidDocResponse, jacquard::identity::resolver::IdentityError> {
71
+
let doc = serde_json::json!({
72
+
"id": "did:plc:alice",
73
+
"service": [{
74
+
"id": "#pds",
75
+
"type": "AtprotoPersonalDataServer",
76
+
"serviceEndpoint": "https://pds"
77
+
}]
78
+
});
79
+
Ok(jacquard::identity::resolver::DidDocResponse {
80
+
buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
81
+
status: StatusCode::OK,
82
+
requested: None,
83
+
})
84
+
}
85
+
}
86
+
87
+
#[async_trait::async_trait]
88
+
impl OAuthResolver for MockClient {
89
+
async fn get_authorization_server_metadata(
90
+
&self,
91
+
issuer: &url::Url,
92
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, jacquard_oauth::resolver::ResolverError> {
93
+
// Return minimal metadata with supported auth method "none" and DPoP support
94
+
let mut md = OAuthAuthorizationServerMetadata::default();
95
+
md.issuer = jacquard::CowStr::from(issuer.as_str());
96
+
md.token_endpoint = jacquard::CowStr::from(format!("{}/token", issuer));
97
+
md.authorization_endpoint = jacquard::CowStr::from(format!("{}/authorize", issuer));
98
+
md.require_pushed_authorization_requests = Some(true);
99
+
md.pushed_authorization_request_endpoint =
100
+
Some(jacquard::CowStr::from(format!("{}/par", issuer)));
101
+
md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]);
102
+
md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]);
103
+
use jacquard::IntoStatic;
104
+
Ok(md.into_static())
105
+
}
106
+
107
+
async fn get_resource_server_metadata(
108
+
&self,
109
+
_pds: &url::Url,
110
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, jacquard_oauth::resolver::ResolverError> {
111
+
// Return metadata pointing to the same issuer as above
112
+
let mut md = OAuthAuthorizationServerMetadata::default();
113
+
md.issuer = jacquard::CowStr::from("https://issuer");
114
+
md.token_endpoint = jacquard::CowStr::from("https://issuer/token");
115
+
md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize");
116
+
md.require_pushed_authorization_requests = Some(true);
117
+
md.pushed_authorization_request_endpoint = Some(jacquard::CowStr::from("https://issuer/par"));
118
+
md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]);
119
+
md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]);
120
+
Ok(md.into_static())
121
+
}
122
+
123
+
async fn verify_issuer(
124
+
&self,
125
+
_server_metadata: &OAuthAuthorizationServerMetadata<'_>,
126
+
_sub: &Did<'_>,
127
+
) -> Result<url::Url, jacquard_oauth::resolver::ResolverError> {
128
+
Ok(url::Url::parse("https://pds").unwrap())
129
+
}
130
+
}
131
+
132
+
fn get_session_unauthorized() -> http::Response<Vec<u8>> {
133
+
HttpResponse::builder()
134
+
.status(StatusCode::UNAUTHORIZED)
135
+
.header(
136
+
http::header::WWW_AUTHENTICATE,
137
+
HeaderValue::from_static("DPoP realm=\"pds\", error=\"invalid_token\""),
138
+
)
139
+
.body(Vec::new())
140
+
.unwrap()
141
+
}
142
+
143
+
fn get_session_unauthorized_body() -> http::Response<Vec<u8>> {
144
+
HttpResponse::builder()
145
+
.status(StatusCode::UNAUTHORIZED)
146
+
.header(http::header::CONTENT_TYPE, "application/json")
147
+
.body(
148
+
serde_json::to_vec(&serde_json::json!({
149
+
"error":"InvalidToken"
150
+
}))
151
+
.unwrap(),
152
+
)
153
+
.unwrap()
154
+
}
155
+
156
+
fn token_use_dpop_nonce() -> http::Response<Vec<u8>> {
157
+
HttpResponse::builder()
158
+
.status(StatusCode::BAD_REQUEST)
159
+
.header(http::header::CONTENT_TYPE, "application/json")
160
+
.header("DPoP-Nonce", HeaderValue::from_static("n1"))
161
+
.body(serde_json::to_vec(&serde_json::json!({"error":"use_dpop_nonce"})).unwrap())
162
+
.unwrap()
163
+
}
164
+
165
+
fn token_refresh_ok() -> http::Response<Vec<u8>> {
166
+
HttpResponse::builder()
167
+
.status(StatusCode::OK)
168
+
.header(http::header::CONTENT_TYPE, "application/json")
169
+
.body(
170
+
serde_json::to_vec(&serde_json::json!({
171
+
"access_token":"newacc",
172
+
"token_type":"DPoP",
173
+
"refresh_token":"newref",
174
+
"expires_in": 3600
175
+
}))
176
+
.unwrap(),
177
+
)
178
+
.unwrap()
179
+
}
180
+
181
+
fn get_session_ok() -> http::Response<Vec<u8>> {
182
+
HttpResponse::builder()
183
+
.status(StatusCode::OK)
184
+
.header(http::header::CONTENT_TYPE, "application/json")
185
+
.body(
186
+
serde_json::to_vec(&serde_json::json!({
187
+
"did":"did:plc:alice",
188
+
"handle":"alice.bsky.social",
189
+
"active":true
190
+
}))
191
+
.unwrap(),
192
+
)
193
+
.unwrap()
194
+
}
195
+
196
+
impl jacquard_oauth::dpop::DpopExt for MockClient {}
197
+
198
+
#[tokio::test(flavor = "multi_thread")]
199
+
async fn oauth_xrpc_invalid_token_triggers_refresh_and_retries() {
200
+
// (reopen test body since we inserted a trait impl)
201
+
let client = Arc::new(MockClient::default());
202
+
203
+
client.push(get_session_unauthorized()).await;
204
+
client.push(token_use_dpop_nonce()).await;
205
+
client.push(token_refresh_ok()).await;
206
+
client.push(get_session_ok()).await;
207
+
208
+
let mut path = std::env::temp_dir();
209
+
path.push(format!("jacquard-oauth-test-{}.json", std::process::id()));
210
+
std::fs::write(&path, "{}").unwrap();
211
+
let store = jacquard::client::FileAuthStore::new(&path);
212
+
213
+
let client_data = ClientData {
214
+
keyset: None,
215
+
config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
216
+
};
217
+
use jacquard::IntoStatic;
218
+
let session_data = ClientSessionData {
219
+
account_did: Did::new_static("did:plc:alice").unwrap(),
220
+
session_id: jacquard::CowStr::from("state"),
221
+
host_url: url::Url::parse("https://pds").unwrap(),
222
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
223
+
authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"),
224
+
authserver_revocation_endpoint: None,
225
+
scopes: vec![Scope::Atproto],
226
+
dpop_data: DpopClientData {
227
+
dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")])
228
+
.unwrap(),
229
+
dpop_authserver_nonce: jacquard::CowStr::from(""),
230
+
dpop_host_nonce: jacquard::CowStr::from(""),
231
+
},
232
+
token_set: TokenSet {
233
+
iss: jacquard::CowStr::from("https://issuer"),
234
+
sub: Did::new_static("did:plc:alice").unwrap(),
235
+
aud: jacquard::CowStr::from("https://pds"),
236
+
scope: None,
237
+
refresh_token: Some(jacquard::CowStr::from("rt1")),
238
+
access_token: jacquard::CowStr::from("atk1"),
239
+
token_type: OAuthTokenType::DPoP,
240
+
expires_at: None,
241
+
},
242
+
}
243
+
.into_static();
244
+
let client_arc = client.clone();
245
+
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
246
+
// Seed the store so refresh can load the session
247
+
let data_store = ClientSessionData {
248
+
account_did: Did::new_static("did:plc:alice").unwrap(),
249
+
session_id: jacquard::CowStr::from("state"),
250
+
host_url: url::Url::parse("https://pds").unwrap(),
251
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
252
+
authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"),
253
+
authserver_revocation_endpoint: None,
254
+
scopes: vec![Scope::Atproto],
255
+
dpop_data: DpopClientData {
256
+
dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")])
257
+
.unwrap(),
258
+
dpop_authserver_nonce: jacquard::CowStr::from(""),
259
+
dpop_host_nonce: jacquard::CowStr::from(""),
260
+
},
261
+
token_set: TokenSet {
262
+
iss: jacquard::CowStr::from("https://issuer"),
263
+
sub: Did::new_static("did:plc:alice").unwrap(),
264
+
aud: jacquard::CowStr::from("https://pds"),
265
+
scope: None,
266
+
refresh_token: Some(jacquard::CowStr::from("rt1")),
267
+
access_token: jacquard::CowStr::from("atk1"),
268
+
token_type: OAuthTokenType::DPoP,
269
+
expires_at: None,
270
+
},
271
+
}
272
+
.into_static();
273
+
registry.set(data_store).await.unwrap();
274
+
let session = OAuthSession::new(registry, client_arc, session_data);
275
+
276
+
let agent: Agent<_> = Agent::from(session);
277
+
let resp = agent
278
+
.send(&jacquard::api::com_atproto::server::get_session::GetSession)
279
+
.await
280
+
.expect("xrpc send ok after auto-refresh");
281
+
assert_eq!(resp.status(), StatusCode::OK);
282
+
283
+
// Inspect the request log
284
+
let log = client.log.lock().await;
285
+
assert_eq!(log.len(), 4, "expected 4 HTTP calls");
286
+
// 0: getSession with old token
287
+
assert_eq!(log[0].method(), Method::GET);
288
+
assert!(log[0].headers().get(http::header::AUTHORIZATION).unwrap().to_str().unwrap().starts_with("DPoP "));
289
+
assert!(log[0]
290
+
.uri()
291
+
.to_string()
292
+
.ends_with("/xrpc/com.atproto.server.getSession"));
293
+
// 1 and 2: token refresh attempts
294
+
assert_eq!(log[1].method(), Method::POST);
295
+
assert!(log[1].uri().to_string().ends_with("/token"));
296
+
assert!(log[1].headers().contains_key("DPoP"));
297
+
assert_eq!(log[2].method(), Method::POST);
298
+
assert!(log[2].uri().to_string().ends_with("/token"));
299
+
assert!(log[2].headers().contains_key("DPoP"));
300
+
// 3: retried getSession with new access token
301
+
assert_eq!(log[3].method(), Method::GET);
302
+
assert!(log[3]
303
+
.headers()
304
+
.get(http::header::AUTHORIZATION)
305
+
.unwrap()
306
+
.to_str()
307
+
.unwrap()
308
+
.starts_with("DPoP newacc"));
309
+
310
+
// Cleanup temp file
311
+
let _ = std::fs::remove_file(&path);
312
+
}
313
+
314
+
#[tokio::test(flavor = "multi_thread")]
315
+
async fn oauth_xrpc_invalid_token_body_triggers_refresh_and_retries() {
316
+
let client = Arc::new(MockClient::default());
317
+
318
+
// Queue responses: initial 401 with JSON body; token refresh 400(use_dpop_nonce); token refresh 200; retry getSession 200
319
+
client.push(get_session_unauthorized_body()).await;
320
+
client.push(token_use_dpop_nonce()).await;
321
+
client.push(token_refresh_ok()).await;
322
+
client.push(get_session_ok()).await;
323
+
324
+
let mut path = std::env::temp_dir();
325
+
path.push(format!("jacquard-oauth-test-body-{}.json", std::process::id()));
326
+
std::fs::write(&path, "{}").unwrap();
327
+
let store = jacquard::client::FileAuthStore::new(&path);
328
+
329
+
let client_data = ClientData {
330
+
keyset: None,
331
+
config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
332
+
};
333
+
use jacquard::IntoStatic;
334
+
let session_data = ClientSessionData {
335
+
account_did: Did::new_static("did:plc:alice").unwrap(),
336
+
session_id: jacquard::CowStr::from("state"),
337
+
host_url: url::Url::parse("https://pds").unwrap(),
338
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
339
+
authserver_token_endpoint: jacquard::CowStr::from("https://issuer/token"),
340
+
authserver_revocation_endpoint: None,
341
+
scopes: vec![Scope::Atproto],
342
+
dpop_data: DpopClientData {
343
+
dpop_key: jacquard_oauth::utils::generate_key(&[jacquard::CowStr::from("ES256")])
344
+
.unwrap(),
345
+
dpop_authserver_nonce: jacquard::CowStr::from(""),
346
+
dpop_host_nonce: jacquard::CowStr::from(""),
347
+
},
348
+
token_set: TokenSet {
349
+
iss: jacquard::CowStr::from("https://issuer"),
350
+
sub: Did::new_static("did:plc:alice").unwrap(),
351
+
aud: jacquard::CowStr::from("https://pds"),
352
+
scope: None,
353
+
refresh_token: Some(jacquard::CowStr::from("rt1")),
354
+
access_token: jacquard::CowStr::from("atk1"),
355
+
token_type: OAuthTokenType::DPoP,
356
+
expires_at: None,
357
+
},
358
+
}
359
+
.into_static();
360
+
let client_arc = client.clone();
361
+
let registry = Arc::new(SessionRegistry::new(store, client_arc.clone(), client_data));
362
+
registry.set(session_data.clone()).await.unwrap();
363
+
let session = OAuthSession::new(registry, client_arc, session_data);
364
+
365
+
let agent: Agent<_> = Agent::from(session);
366
+
let resp = agent
367
+
.send(&jacquard::api::com_atproto::server::get_session::GetSession)
368
+
.await
369
+
.expect("xrpc send ok after auto-refresh");
370
+
assert_eq!(resp.status(), StatusCode::OK);
371
+
372
+
// Cleanup temp file
373
+
let _ = std::fs::remove_file(&path);
374
+
}
+293
crates/jacquard/tests/oauth_flow.rs
+293
crates/jacquard/tests/oauth_flow.rs
···
1
+
use std::collections::VecDeque;
2
+
use std::sync::Arc;
3
+
4
+
use bytes::Bytes;
5
+
use http::{Response as HttpResponse, StatusCode};
6
+
use jacquard::IntoStatic;
7
+
use jacquard::client::Agent;
8
+
use jacquard::types::xrpc::XrpcClient;
9
+
use jacquard_common::http_client::HttpClient;
10
+
use jacquard_oauth::atproto::AtprotoClientMetadata;
11
+
use jacquard_oauth::authstore::ClientAuthStore;
12
+
use jacquard_oauth::client::OAuthClient;
13
+
use jacquard_oauth::resolver::OAuthResolver;
14
+
use jacquard_oauth::scopes::Scope;
15
+
use jacquard_oauth::session::ClientData;
16
+
17
+
#[derive(Clone, Default)]
18
+
struct MockClient {
19
+
queue: Arc<tokio::sync::Mutex<VecDeque<http::Response<Vec<u8>>>>>,
20
+
}
21
+
22
+
impl MockClient {
23
+
async fn push(&self, resp: http::Response<Vec<u8>>) {
24
+
self.queue.lock().await.push_back(resp);
25
+
}
26
+
}
27
+
28
+
impl HttpClient for MockClient {
29
+
type Error = std::convert::Infallible;
30
+
fn send_http(
31
+
&self,
32
+
_request: http::Request<Vec<u8>>,
33
+
) -> impl core::future::Future<
34
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
35
+
> + Send {
36
+
let queue = self.queue.clone();
37
+
async move { Ok(queue.lock().await.pop_front().expect("no queued response")) }
38
+
}
39
+
}
40
+
41
+
#[async_trait::async_trait]
42
+
impl jacquard::identity::resolver::IdentityResolver for MockClient {
43
+
fn options(&self) -> &jacquard::identity::resolver::ResolverOptions {
44
+
use std::sync::LazyLock;
45
+
static OPTS: LazyLock<jacquard::identity::resolver::ResolverOptions> =
46
+
LazyLock::new(jacquard::identity::resolver::ResolverOptions::default);
47
+
&OPTS
48
+
}
49
+
async fn resolve_handle(
50
+
&self,
51
+
_handle: &jacquard::types::string::Handle<'_>,
52
+
) -> std::result::Result<
53
+
jacquard::types::did::Did<'static>,
54
+
jacquard::identity::resolver::IdentityError,
55
+
> {
56
+
Ok(jacquard::types::did::Did::new_static("did:plc:alice").unwrap())
57
+
}
58
+
async fn resolve_did_doc(
59
+
&self,
60
+
_did: &jacquard::types::did::Did<'_>,
61
+
) -> std::result::Result<
62
+
jacquard::identity::resolver::DidDocResponse,
63
+
jacquard::identity::resolver::IdentityError,
64
+
> {
65
+
let doc = serde_json::json!({
66
+
"id": "did:plc:alice",
67
+
"service": [{
68
+
"id": "#pds",
69
+
"type": "AtprotoPersonalDataServer",
70
+
"serviceEndpoint": "https://pds"
71
+
}]
72
+
});
73
+
Ok(jacquard::identity::resolver::DidDocResponse {
74
+
buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
75
+
status: StatusCode::OK,
76
+
requested: None,
77
+
})
78
+
}
79
+
}
80
+
81
+
#[async_trait::async_trait]
82
+
impl OAuthResolver for MockClient {
83
+
async fn resolve_oauth(
84
+
&self,
85
+
_input: &str,
86
+
) -> Result<
87
+
(
88
+
jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>,
89
+
Option<jacquard_common::types::did_doc::DidDocument<'static>>,
90
+
),
91
+
jacquard_oauth::resolver::ResolverError,
92
+
> {
93
+
let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default();
94
+
md.issuer = jacquard::CowStr::from("https://issuer");
95
+
md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize");
96
+
md.token_endpoint = jacquard::CowStr::from("https://issuer/token");
97
+
md.require_pushed_authorization_requests = Some(true);
98
+
md.pushed_authorization_request_endpoint =
99
+
Some(jacquard::CowStr::from("https://issuer/par"));
100
+
md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]);
101
+
md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]);
102
+
103
+
// Simple DID doc pointing to https://pds
104
+
let doc = serde_json::json!({
105
+
"id": "did:plc:alice",
106
+
"service": [{
107
+
"id": "#pds",
108
+
"type": "AtprotoPersonalDataServer",
109
+
"serviceEndpoint": "https://pds"
110
+
}]
111
+
});
112
+
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
113
+
let did_doc_b: jacquard_common::types::did_doc::DidDocument<'_> =
114
+
serde_json::from_slice(&buf).unwrap();
115
+
let did_doc = did_doc_b.into_static();
116
+
Ok((md.into_static(), Some(did_doc)))
117
+
}
118
+
async fn get_authorization_server_metadata(
119
+
&self,
120
+
issuer: &url::Url,
121
+
) -> Result<
122
+
jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>,
123
+
jacquard_oauth::resolver::ResolverError,
124
+
> {
125
+
let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default();
126
+
md.issuer = jacquard::CowStr::from(issuer.as_str());
127
+
md.authorization_endpoint = jacquard::CowStr::from(format!("{}/authorize", issuer));
128
+
md.token_endpoint = jacquard::CowStr::from(format!("{}/token", issuer));
129
+
md.require_pushed_authorization_requests = Some(true);
130
+
md.pushed_authorization_request_endpoint =
131
+
Some(jacquard::CowStr::from(format!("{}/par", issuer)));
132
+
md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]);
133
+
md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]);
134
+
Ok(md.into_static())
135
+
}
136
+
137
+
async fn get_resource_server_metadata(
138
+
&self,
139
+
_pds: &url::Url,
140
+
) -> Result<
141
+
jacquard_oauth::types::OAuthAuthorizationServerMetadata<'static>,
142
+
jacquard_oauth::resolver::ResolverError,
143
+
> {
144
+
let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default();
145
+
md.issuer = jacquard::CowStr::from("https://issuer/");
146
+
md.authorization_endpoint = jacquard::CowStr::from("https://issuer/authorize");
147
+
md.token_endpoint = jacquard::CowStr::from("https://issuer/token");
148
+
md.require_pushed_authorization_requests = Some(true);
149
+
md.pushed_authorization_request_endpoint =
150
+
Some(jacquard::CowStr::from("https://issuer/par"));
151
+
md.token_endpoint_auth_methods_supported = Some(vec![jacquard::CowStr::from("none")]);
152
+
md.dpop_signing_alg_values_supported = Some(vec![jacquard::CowStr::from("ES256")]);
153
+
Ok(md.into_static())
154
+
}
155
+
}
156
+
157
+
impl jacquard_oauth::dpop::DpopExt for MockClient {}
158
+
159
+
#[tokio::test(flavor = "multi_thread")]
160
+
async fn oauth_end_to_end_mock_flow() {
161
+
let client = Arc::new(MockClient::default());
162
+
// Queue responses: PAR 201, token 200, XRPC getSession 200
163
+
client
164
+
.push(
165
+
HttpResponse::builder()
166
+
.status(StatusCode::CREATED)
167
+
.header(http::header::CONTENT_TYPE, "application/json")
168
+
.body(
169
+
serde_json::to_vec(&serde_json::json!({
170
+
"request_uri": "urn:par:abc",
171
+
"expires_in": 60
172
+
}))
173
+
.unwrap(),
174
+
)
175
+
.unwrap(),
176
+
)
177
+
.await;
178
+
client
179
+
.push(
180
+
HttpResponse::builder()
181
+
.status(StatusCode::OK)
182
+
.header(http::header::CONTENT_TYPE, "application/json")
183
+
.header("DPoP-Nonce", http::HeaderValue::from_static("n1"))
184
+
.body(
185
+
serde_json::to_vec(&serde_json::json!({
186
+
"access_token": "atk1",
187
+
"token_type": "DPoP",
188
+
"refresh_token": "rt1",
189
+
"sub": "did:plc:alice",
190
+
"iss": "https://issuer",
191
+
"aud": "https://pds",
192
+
"expires_in": 3600
193
+
}))
194
+
.unwrap(),
195
+
)
196
+
.unwrap(),
197
+
)
198
+
.await;
199
+
client
200
+
.push(
201
+
HttpResponse::builder()
202
+
.status(StatusCode::OK)
203
+
.header(http::header::CONTENT_TYPE, "application/json")
204
+
.body(
205
+
serde_json::to_vec(&serde_json::json!({
206
+
"did": "did:plc:alice",
207
+
"handle": "alice.bsky.social",
208
+
"active": true
209
+
}))
210
+
.unwrap(),
211
+
)
212
+
.unwrap(),
213
+
)
214
+
.await;
215
+
216
+
// File-backed store for auth state/session
217
+
let mut path = std::env::temp_dir();
218
+
path.push(format!("jacquard-oauth-flow-{}.json", std::process::id()));
219
+
std::fs::write(&path, "{}").unwrap();
220
+
let store = jacquard::client::FileAuthStore::new(&path);
221
+
222
+
let client_data: ClientData<'static> = ClientData {
223
+
keyset: None,
224
+
config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
225
+
};
226
+
let client_arc = client.clone();
227
+
let oauth = OAuthClient::new_from_resolver(store, (*client_arc).clone(), client_data);
228
+
229
+
// Build metadata and call PAR to get an AuthRequestData, then save in store
230
+
let (server_metadata, identity) = client.resolve_oauth("alice.bsky.social").await.unwrap();
231
+
let metadata = jacquard_oauth::request::OAuthMetadata {
232
+
server_metadata,
233
+
client_metadata: jacquard_oauth::atproto::atproto_client_metadata(
234
+
AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
235
+
&None,
236
+
)
237
+
.unwrap()
238
+
.into_static(),
239
+
keyset: None,
240
+
};
241
+
let login_hint = identity.map(|_| jacquard::CowStr::from("alice.bsky.social"));
242
+
let auth_req = jacquard_oauth::request::par(client.as_ref(), login_hint, None, &metadata)
243
+
.await
244
+
.unwrap();
245
+
// Construct authorization URL as OAuthClient::start_auth would do
246
+
#[derive(serde::Serialize)]
247
+
struct Parameters<'s> {
248
+
client_id: url::Url,
249
+
request_uri: jacquard::CowStr<'s>,
250
+
}
251
+
let auth_url = format!(
252
+
"{}?{}",
253
+
metadata.server_metadata.authorization_endpoint,
254
+
serde_html_form::to_string(Parameters {
255
+
client_id: metadata.client_metadata.client_id.clone(),
256
+
request_uri: auth_req.request_uri.clone(),
257
+
})
258
+
.unwrap()
259
+
);
260
+
assert!(auth_url.contains("/authorize?"));
261
+
assert!(auth_url.contains("request_uri"));
262
+
// keep state for the callback
263
+
let state = auth_req.state.clone();
264
+
oauth
265
+
.registry
266
+
.store
267
+
.save_auth_req_info(&auth_req)
268
+
.await
269
+
.unwrap();
270
+
271
+
// callback: exchange code, create session
272
+
use jacquard_oauth::types::CallbackParams;
273
+
let session = oauth
274
+
.callback(CallbackParams {
275
+
code: jacquard::CowStr::from("code123"),
276
+
state: Some(state.clone()),
277
+
// Callback compares exact string with metadata.issuer (which is a URL string
278
+
// including trailing slash). Use normalized form to match.
279
+
iss: Some(jacquard::CowStr::from("https://issuer/")),
280
+
})
281
+
.await
282
+
.unwrap();
283
+
284
+
// Wrap in Agent and send a resource XRPC call to verify Authorization works
285
+
let agent: Agent<_> = Agent::from(session);
286
+
let resp = agent
287
+
.send(&jacquard::api::com_atproto::server::get_session::GetSession)
288
+
.await
289
+
.unwrap();
290
+
assert_eq!(resp.status(), StatusCode::OK);
291
+
292
+
let _ = std::fs::remove_file(&path);
293
+
}
+125
crates/jacquard/tests/restore_pds_cache.rs
+125
crates/jacquard/tests/restore_pds_cache.rs
···
1
+
use std::sync::Arc;
2
+
3
+
use bytes::Bytes;
4
+
use http::{Response as HttpResponse, StatusCode};
5
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
6
+
use jacquard::client::{AtpSession, FileAuthStore};
7
+
use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions};
8
+
use jacquard::types::did::Did;
9
+
use jacquard::types::string::Handle;
10
+
use jacquard_common::http_client::HttpClient;
11
+
use jacquard_common::session::SessionStore;
12
+
use std::fs;
13
+
use std::path::PathBuf;
14
+
use tokio::sync::RwLock;
15
+
use url::Url;
16
+
17
+
#[derive(Clone, Default)]
18
+
struct MockResolver {
19
+
// Count calls to DID doc resolution
20
+
did_doc_calls: Arc<RwLock<usize>>,
21
+
}
22
+
23
+
impl HttpClient for MockResolver {
24
+
type Error = std::convert::Infallible;
25
+
fn send_http(
26
+
&self,
27
+
_request: http::Request<Vec<u8>>,
28
+
) -> impl core::future::Future<
29
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
30
+
> + Send {
31
+
async {
32
+
// Not used in this test
33
+
Ok(HttpResponse::builder()
34
+
.status(StatusCode::OK)
35
+
.body(Vec::new())
36
+
.unwrap())
37
+
}
38
+
}
39
+
}
40
+
41
+
#[async_trait::async_trait]
42
+
impl IdentityResolver for MockResolver {
43
+
fn options(&self) -> &ResolverOptions {
44
+
use std::sync::LazyLock;
45
+
static OPTS: LazyLock<ResolverOptions> = LazyLock::new(ResolverOptions::default);
46
+
&OPTS
47
+
}
48
+
async fn resolve_handle(
49
+
&self,
50
+
_handle: &Handle<'_>,
51
+
) -> std::result::Result<Did<'static>, jacquard::identity::resolver::IdentityError> {
52
+
Ok(Did::new_static("did:plc:alice").unwrap())
53
+
}
54
+
async fn resolve_did_doc(
55
+
&self,
56
+
_did: &Did<'_>,
57
+
) -> std::result::Result<DidDocResponse, jacquard::identity::resolver::IdentityError> {
58
+
*self.did_doc_calls.write().await += 1;
59
+
let doc = serde_json::json!({
60
+
"id": "did:plc:alice",
61
+
"service": [{
62
+
"id": "#pds",
63
+
"type": "AtprotoPersonalDataServer",
64
+
"serviceEndpoint": "https://pds-resolved"
65
+
}]
66
+
});
67
+
Ok(DidDocResponse {
68
+
buffer: Bytes::from(serde_json::to_vec(&doc).unwrap()),
69
+
status: StatusCode::OK,
70
+
requested: None,
71
+
})
72
+
}
73
+
}
74
+
75
+
fn temp_file() -> PathBuf {
76
+
let mut p = std::env::temp_dir();
77
+
p.push(format!("jacquard-test-restore-{}.json", std::process::id()));
78
+
p
79
+
}
80
+
81
+
#[tokio::test]
82
+
async fn restore_uses_cached_pds_when_present() {
83
+
let path = temp_file();
84
+
fs::write(&path, "{}").unwrap();
85
+
let store = Arc::new(FileAuthStore::new(&path));
86
+
let resolver = Arc::new(MockResolver::default());
87
+
88
+
// Seed an app-password session in the file store
89
+
let session = AtpSession {
90
+
access_jwt: "acc".into(),
91
+
refresh_jwt: "ref".into(),
92
+
did: Did::new_static("did:plc:alice").unwrap(),
93
+
handle: Handle::new_static("alice.bsky.social").unwrap(),
94
+
};
95
+
let key: SessionKey = (session.did.clone(), "session".into());
96
+
jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), session)
97
+
.await
98
+
.unwrap();
99
+
// Verify it is persisted
100
+
assert!(SessionStore::get(store.as_ref(), &key).await.is_some());
101
+
// Persist PDS endpoint cache to avoid DID resolution on restore
102
+
store
103
+
.set_atp_pds(&key, &Url::parse("https://pds-cached").unwrap())
104
+
.unwrap();
105
+
assert_eq!(
106
+
store
107
+
.get_atp_pds(&key)
108
+
.ok()
109
+
.flatten()
110
+
.expect("pds cached")
111
+
.as_str(),
112
+
"https://pds-cached/"
113
+
);
114
+
115
+
let session = CredentialSession::new(store.clone(), resolver.clone());
116
+
// Restore should pick cached PDS and NOT call resolve_did_doc
117
+
session
118
+
.restore(Did::new_static("did:plc:alice").unwrap(), "session".into())
119
+
.await
120
+
.expect("restore ok");
121
+
assert_eq!(session.endpoint().await.as_str(), "https://pds-cached/");
122
+
123
+
// Cleanup
124
+
let _ = fs::remove_file(&path);
125
+
}
+3
-3
crates/jacquard-api/Cargo.toml
+3
-3
crates/jacquard-api/Cargo.toml
···
22
22
tools_ozone = []
23
23
24
24
[dependencies]
25
-
bon = "3"
25
+
bon.workspace = true
26
26
bytes = { workspace = true, features = ["serde"] }
27
-
jacquard-common = { version = "0.2.0", path = "../jacquard-common" }
28
-
jacquard-derive = { version = "0.2.0", path = "../jacquard-derive" }
27
+
jacquard-common = { version = "0.3.0", path = "../jacquard-common" }
28
+
jacquard-derive = { version = "0.3.0", path = "../jacquard-derive" }
29
29
miette.workspace = true
30
30
serde.workspace = true
31
31
thiserror.workspace = true
+11
-6
crates/jacquard-common/Cargo.toml
+11
-6
crates/jacquard-common/Cargo.toml
···
13
13
14
14
15
15
[dependencies]
16
-
bon = "3"
17
-
base64 = "0.22.1"
16
+
bon.workspace = true
17
+
base64.workspace = true
18
18
bytes.workspace = true
19
-
chrono = "0.4.42"
19
+
chrono.workspace = true
20
20
cid = { version = "0.11.1", features = ["serde", "std"] }
21
-
enum_dispatch = "0.3.13"
22
21
ipld-core = { version = "0.4.2", features = ["serde"] }
23
22
langtag = { version = "0.4.0", features = ["serde"] }
24
23
miette.workspace = true
···
35
34
smol_str.workspace = true
36
35
thiserror.workspace = true
37
36
url.workspace = true
37
+
http.workspace = true
38
+
async-trait.workspace = true
39
+
tokio = { workspace = true, features = ["sync"] }
40
+
reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] }
41
+
serde_ipld_dagcbor.workspace = true
42
+
trait-variant.workspace = true
38
43
39
44
[features]
40
45
default = []
···
42
47
crypto-ed25519 = ["crypto", "dep:ed25519-dalek"]
43
48
crypto-k256 = ["crypto", "dep:k256"]
44
49
crypto-p256 = ["crypto", "dep:p256"]
50
+
reqwest-client = ["dep:reqwest"]
45
51
46
52
[dependencies.ed25519-dalek]
47
53
version = "2"
···
56
62
features = ["arithmetic"]
57
63
58
64
[dependencies.p256]
59
-
version = "0.13"
65
+
workspace = true
60
66
optional = true
61
-
default-features = false
62
67
features = ["arithmetic"]
63
68
64
69
[package.metadata.docs.rs]
+89
-1
crates/jacquard-common/src/cowstr.rs
+89
-1
crates/jacquard-common/src/cowstr.rs
···
63
63
pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self {
64
64
unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) }
65
65
}
66
+
67
+
/// Returns a reference to the underlying string slice.
68
+
#[inline]
69
+
pub fn as_str(&self) -> &str {
70
+
match self {
71
+
CowStr::Borrowed(s) => s,
72
+
CowStr::Owned(s) => s.as_str(),
73
+
}
74
+
}
66
75
}
67
76
68
77
impl AsRef<str> for CowStr<'_> {
···
105
114
}
106
115
}
107
116
117
+
impl Default for CowStr<'_> {
118
+
#[inline]
119
+
fn default() -> Self {
120
+
CowStr::new_static("")
121
+
}
122
+
}
123
+
108
124
impl From<String> for CowStr<'_> {
109
125
#[inline]
110
126
fn from(s: String) -> Self {
···
138
154
}
139
155
}
140
156
157
+
impl From<CowStr<'_>> for SmolStr {
158
+
#[inline]
159
+
fn from(s: CowStr<'_>) -> Self {
160
+
match s {
161
+
CowStr::Borrowed(s) => SmolStr::new(s),
162
+
CowStr::Owned(s) => SmolStr::new(s),
163
+
}
164
+
}
165
+
}
166
+
167
+
impl From<SmolStr> for CowStr<'_> {
168
+
#[inline]
169
+
fn from(s: SmolStr) -> Self {
170
+
CowStr::Owned(s)
171
+
}
172
+
}
173
+
141
174
impl From<CowStr<'_>> for Box<str> {
142
175
#[inline]
143
176
fn from(s: CowStr<'_>) -> Self {
···
250
283
}
251
284
}
252
285
253
-
impl<'de: 'a, 'a> Deserialize<'de> for CowStr<'a> {
286
+
// impl<'de> Deserialize<'de> for CowStr<'_> {
287
+
// #[inline]
288
+
// fn deserialize<D>(deserializer: D) -> Result<CowStr<'static>, D::Error>
289
+
// where
290
+
// D: serde::Deserializer<'de>,
291
+
// {
292
+
// struct CowStrVisitor;
293
+
294
+
// impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
295
+
// type Value = CowStr<'static>;
296
+
297
+
// #[inline]
298
+
// fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
299
+
// write!(formatter, "a string")
300
+
// }
301
+
302
+
// #[inline]
303
+
// fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
304
+
// where
305
+
// E: serde::de::Error,
306
+
// {
307
+
// Ok(CowStr::copy_from_str(v))
308
+
// }
309
+
310
+
// #[inline]
311
+
// fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
312
+
// where
313
+
// E: serde::de::Error,
314
+
// {
315
+
// Ok(v.into())
316
+
// }
317
+
// }
318
+
319
+
// deserializer.deserialize_str(CowStrVisitor)
320
+
// }
321
+
// }
322
+
323
+
impl<'de, 'a, 'b> Deserialize<'de> for CowStr<'a>
324
+
where
325
+
'de: 'a,
326
+
{
254
327
#[inline]
255
328
fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error>
256
329
where
···
292
365
}
293
366
294
367
deserializer.deserialize_str(CowStrVisitor)
368
+
}
369
+
}
370
+
371
+
/// Convert to a CowStr.
372
+
pub trait ToCowStr {
373
+
/// Convert to a CowStr.
374
+
fn to_cowstr(&self) -> CowStr<'_>;
375
+
}
376
+
377
+
impl<T> ToCowStr for T
378
+
where
379
+
T: fmt::Display + ?Sized,
380
+
{
381
+
fn to_cowstr(&self) -> CowStr<'_> {
382
+
CowStr::Owned(smol_str::format_smolstr!("{}", self))
295
383
}
296
384
}
297
385
+158
crates/jacquard-common/src/error.rs
+158
crates/jacquard-common/src/error.rs
···
1
+
//! Error types for XRPC client operations
2
+
3
+
use bytes::Bytes;
4
+
5
+
use crate::types::xrpc::EncodeError;
6
+
7
+
/// Client error type wrapping all possible error conditions
8
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
9
+
pub enum ClientError {
10
+
/// HTTP transport error
11
+
#[error("HTTP transport error: {0}")]
12
+
Transport(
13
+
#[from]
14
+
#[diagnostic_source]
15
+
TransportError,
16
+
),
17
+
18
+
/// Request serialization failed
19
+
#[error("{0}")]
20
+
Encode(
21
+
#[from]
22
+
#[diagnostic_source]
23
+
EncodeError,
24
+
),
25
+
26
+
/// Response deserialization failed
27
+
#[error("{0}")]
28
+
Decode(
29
+
#[from]
30
+
#[diagnostic_source]
31
+
DecodeError,
32
+
),
33
+
34
+
/// HTTP error response
35
+
#[error("HTTP {0}")]
36
+
Http(
37
+
#[from]
38
+
#[diagnostic_source]
39
+
HttpError,
40
+
),
41
+
42
+
/// Authentication error
43
+
#[error("Authentication error: {0}")]
44
+
Auth(
45
+
#[from]
46
+
#[diagnostic_source]
47
+
AuthError,
48
+
),
49
+
}
50
+
51
+
/// Transport-level errors that occur during HTTP communication
52
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
53
+
pub enum TransportError {
54
+
/// Failed to establish connection to server
55
+
#[error("Connection error: {0}")]
56
+
Connect(String),
57
+
58
+
/// Request timed out
59
+
#[error("Request timeout")]
60
+
Timeout,
61
+
62
+
/// Request construction failed (malformed URI, headers, etc.)
63
+
#[error("Invalid request: {0}")]
64
+
InvalidRequest(String),
65
+
66
+
/// Other transport error
67
+
#[error("Transport error: {0}")]
68
+
Other(Box<dyn std::error::Error + Send + Sync>),
69
+
}
70
+
71
+
/// Response deserialization errors
72
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
73
+
pub enum DecodeError {
74
+
/// JSON deserialization failed
75
+
#[error("Failed to deserialize JSON: {0}")]
76
+
Json(
77
+
#[from]
78
+
#[source]
79
+
serde_json::Error,
80
+
),
81
+
/// CBOR deserialization failed (local I/O)
82
+
#[error("Failed to deserialize CBOR: {0}")]
83
+
CborLocal(
84
+
#[from]
85
+
#[source]
86
+
serde_ipld_dagcbor::DecodeError<std::io::Error>,
87
+
),
88
+
/// CBOR deserialization failed (remote/reqwest)
89
+
#[error("Failed to deserialize CBOR: {0}")]
90
+
CborRemote(
91
+
#[from]
92
+
#[source]
93
+
serde_ipld_dagcbor::DecodeError<HttpError>,
94
+
),
95
+
}
96
+
97
+
/// HTTP error response (non-200 status codes outside of XRPC error handling)
98
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
99
+
pub struct HttpError {
100
+
/// HTTP status code
101
+
pub status: http::StatusCode,
102
+
/// Response body if available
103
+
pub body: Option<Bytes>,
104
+
}
105
+
106
+
impl std::fmt::Display for HttpError {
107
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108
+
write!(f, "HTTP {}", self.status)?;
109
+
if let Some(body) = &self.body {
110
+
if let Ok(s) = std::str::from_utf8(body) {
111
+
write!(f, ":\n{}", s)?;
112
+
}
113
+
}
114
+
Ok(())
115
+
}
116
+
}
117
+
118
+
/// Result type for client operations
119
+
pub type XrpcResult<T> = std::result::Result<T, ClientError>;
120
+
121
+
#[cfg(feature = "reqwest-client")]
122
+
impl From<reqwest::Error> for TransportError {
123
+
fn from(e: reqwest::Error) -> Self {
124
+
if e.is_timeout() {
125
+
Self::Timeout
126
+
} else if e.is_connect() {
127
+
Self::Connect(e.to_string())
128
+
} else if e.is_builder() || e.is_request() {
129
+
Self::InvalidRequest(e.to_string())
130
+
} else {
131
+
Self::Other(Box::new(e))
132
+
}
133
+
}
134
+
}
135
+
136
+
/// Authentication and authorization errors
137
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
138
+
pub enum AuthError {
139
+
/// Access token has expired (use refresh token to get a new one)
140
+
#[error("Access token expired")]
141
+
TokenExpired,
142
+
143
+
/// Access token is invalid or malformed
144
+
#[error("Invalid access token")]
145
+
InvalidToken,
146
+
147
+
/// Token refresh request failed
148
+
#[error("Token refresh failed")]
149
+
RefreshFailed,
150
+
151
+
/// Request requires authentication but none was provided
152
+
#[error("No authentication provided")]
153
+
NotAuthenticated,
154
+
155
+
/// Other authentication error
156
+
#[error("Authentication error: {0:?}")]
157
+
Other(http::HeaderValue),
158
+
}
+64
crates/jacquard-common/src/http_client.rs
+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
+
}
+24
crates/jacquard-common/src/lib.rs
+24
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;
16
19
pub mod macros;
20
+
/// Generic session storage traits and utilities.
21
+
pub mod session;
17
22
/// Baseline fundamental AT Protocol data types.
18
23
pub mod types;
24
+
25
+
/// Authorization token types for XRPC requests.
26
+
#[derive(Debug, Clone)]
27
+
pub enum AuthorizationToken<'s> {
28
+
/// Bearer token (access JWT, refresh JWT to refresh the session)
29
+
Bearer(CowStr<'s>),
30
+
/// DPoP token (proof-of-possession) for OAuth
31
+
Dpop(CowStr<'s>),
32
+
}
33
+
34
+
impl<'s> IntoStatic for AuthorizationToken<'s> {
35
+
type Output = AuthorizationToken<'static>;
36
+
fn into_static(self) -> AuthorizationToken<'static> {
37
+
match self {
38
+
AuthorizationToken::Bearer(token) => AuthorizationToken::Bearer(token.into_static()),
39
+
AuthorizationToken::Dpop(token) => AuthorizationToken::Dpop(token.into_static()),
40
+
}
41
+
}
42
+
}
+145
crates/jacquard-common/src/session.rs
+145
crates/jacquard-common/src/session.rs
···
1
+
//! Generic session storage traits and utilities.
2
+
3
+
use async_trait::async_trait;
4
+
use miette::Diagnostic;
5
+
use serde::Serialize;
6
+
use serde::de::DeserializeOwned;
7
+
use serde_json::Value;
8
+
use std::collections::HashMap;
9
+
use std::error::Error as StdError;
10
+
use std::fmt::Display;
11
+
use std::hash::Hash;
12
+
use std::path::{Path, PathBuf};
13
+
use std::sync::Arc;
14
+
use tokio::sync::RwLock;
15
+
16
+
/// Errors emitted by session stores.
17
+
#[derive(Debug, thiserror::Error, Diagnostic)]
18
+
pub enum SessionStoreError {
19
+
/// Filesystem or I/O error
20
+
#[error("I/O error: {0}")]
21
+
#[diagnostic(code(jacquard::session_store::io))]
22
+
Io(#[from] std::io::Error),
23
+
/// Serialization error (e.g., JSON)
24
+
#[error("serialization error: {0}")]
25
+
#[diagnostic(code(jacquard::session_store::serde))]
26
+
Serde(#[from] serde_json::Error),
27
+
/// Any other error from a backend implementation
28
+
#[error(transparent)]
29
+
#[diagnostic(code(jacquard::session_store::other))]
30
+
Other(#[from] Box<dyn StdError + Send + Sync>),
31
+
}
32
+
33
+
/// Pluggable storage for arbitrary session records.
34
+
#[async_trait]
35
+
pub trait SessionStore<K, T>: Send + Sync
36
+
where
37
+
K: Eq + Hash,
38
+
T: Clone,
39
+
{
40
+
/// Get the current session if present.
41
+
async fn get(&self, key: &K) -> Option<T>;
42
+
/// Persist the given session.
43
+
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>;
44
+
/// Delete the given session.
45
+
async fn del(&self, key: &K) -> Result<(), SessionStoreError>;
46
+
}
47
+
48
+
/// In-memory session store suitable for short-lived sessions and tests.
49
+
#[derive(Clone)]
50
+
pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>);
51
+
52
+
impl<K, T> Default for MemorySessionStore<K, T> {
53
+
fn default() -> Self {
54
+
Self(Arc::new(RwLock::new(HashMap::new())))
55
+
}
56
+
}
57
+
58
+
#[async_trait]
59
+
impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
60
+
where
61
+
K: Eq + Hash + Send + Sync,
62
+
T: Clone + Send + Sync + 'static,
63
+
{
64
+
async fn get(&self, key: &K) -> Option<T> {
65
+
self.0.read().await.get(key).cloned()
66
+
}
67
+
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
68
+
self.0.write().await.insert(key, session);
69
+
Ok(())
70
+
}
71
+
async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
72
+
self.0.write().await.remove(key);
73
+
Ok(())
74
+
}
75
+
}
76
+
77
+
/// File-backed token store using a JSON file.
78
+
///
79
+
/// NOT secure, only suitable for development.
80
+
///
81
+
/// Example
82
+
/// ```ignore
83
+
/// use jacquard::client::{AtClient, FileTokenStore};
84
+
/// let base = url::Url::parse("https://bsky.social").unwrap();
85
+
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
86
+
/// let client = AtClient::new(reqwest::Client::new(), base, store);
87
+
/// ```
88
+
#[derive(Clone, Debug)]
89
+
pub struct FileTokenStore {
90
+
/// Path to the JSON file.
91
+
pub path: PathBuf,
92
+
}
93
+
94
+
impl FileTokenStore {
95
+
/// Create a new file token store at the given path.
96
+
pub fn new(path: impl AsRef<Path>) -> Self {
97
+
Self {
98
+
path: path.as_ref().to_path_buf(),
99
+
}
100
+
}
101
+
}
102
+
103
+
#[async_trait::async_trait]
104
+
impl<
105
+
K: Eq + Hash + Display + Send + Sync + 'static,
106
+
T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
107
+
> SessionStore<K, T> for FileTokenStore
108
+
{
109
+
/// Get the current session if present.
110
+
async fn get(&self, key: &K) -> Option<T> {
111
+
let file = std::fs::read_to_string(&self.path).ok()?;
112
+
let store: Value = serde_json::from_str(&file).ok()?;
113
+
114
+
let session = store.get(key.to_string())?;
115
+
serde_json::from_value(session.clone()).ok()
116
+
}
117
+
/// Persist the given session.
118
+
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
119
+
let file = std::fs::read_to_string(&self.path)?;
120
+
let mut store: Value = serde_json::from_str(&file)?;
121
+
let key_string = key.to_string();
122
+
if let Some(store) = store.as_object_mut() {
123
+
store.insert(key_string, serde_json::to_value(session.clone())?);
124
+
125
+
std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
126
+
Ok(())
127
+
} else {
128
+
Err(SessionStoreError::Other("invalid store".into()))
129
+
}
130
+
}
131
+
/// Delete the given session.
132
+
async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
133
+
let file = std::fs::read_to_string(&self.path)?;
134
+
let mut store: Value = serde_json::from_str(&file)?;
135
+
let key_string = key.to_string();
136
+
if let Some(store) = store.as_object_mut() {
137
+
store.remove(&key_string);
138
+
139
+
std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
140
+
Ok(())
141
+
} else {
142
+
Err(SessionStoreError::Other("invalid store".into()))
143
+
}
144
+
}
145
+
}
-6
crates/jacquard-common/src/types/datetime.rs
-6
crates/jacquard-common/src/types/datetime.rs
+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}")]
+640
-1
crates/jacquard-common/src/types/xrpc.rs
+640
-1
crates/jacquard-common/src/types/xrpc.rs
···
1
+
//! Stateless XRPC utilities and request/response mapping
2
+
//!
3
+
//! Mapping overview:
4
+
//! - Success (2xx): parse body into the endpoint's typed output.
5
+
//! - 400: try typed error; on failure, fall back to a generic XRPC error (with
6
+
//! `nsid`, `method`, and `http_status`) and map common auth errors.
7
+
//! - 401: if `WWW-Authenticate` is present, return
8
+
//! `ClientError::Auth(AuthError::Other(header))` so higher layers (OAuth/DPoP)
9
+
//! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry.
10
+
//! If the header is absent, parse the body and map auth errors to
11
+
//! `AuthError::TokenExpired`/`InvalidToken`.
12
+
//!
13
+
use bytes::Bytes;
14
+
use http::{
15
+
HeaderName, HeaderValue, Request, StatusCode,
16
+
header::{AUTHORIZATION, CONTENT_TYPE},
17
+
};
1
18
use serde::{Deserialize, Serialize};
2
-
use std::error::Error;
19
+
use smol_str::SmolStr;
3
20
use std::fmt::{self, Debug};
21
+
use std::{error::Error, marker::PhantomData};
22
+
use url::Url;
4
23
5
24
use crate::IntoStatic;
25
+
use crate::error::TransportError;
26
+
use crate::http_client::HttpClient;
6
27
use crate::types::value::Data;
28
+
use crate::{AuthorizationToken, error::AuthError};
29
+
use crate::{CowStr, error::XrpcResult};
7
30
8
31
/// Error type for encoding XRPC requests
9
32
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
···
103
126
GenericError(self.0.into_static())
104
127
}
105
128
}
129
+
130
+
/// Per-request options for XRPC calls.
131
+
#[derive(Debug, Default, Clone)]
132
+
pub struct CallOptions<'a> {
133
+
/// Optional Authorization to apply (`Bearer` or `DPoP`).
134
+
pub auth: Option<AuthorizationToken<'a>>,
135
+
/// `atproto-proxy` header value.
136
+
pub atproto_proxy: Option<CowStr<'a>>,
137
+
/// `atproto-accept-labelers` header values.
138
+
pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
139
+
/// Extra headers to attach to this request.
140
+
pub extra_headers: Vec<(HeaderName, HeaderValue)>,
141
+
}
142
+
143
+
impl IntoStatic for CallOptions<'_> {
144
+
type Output = CallOptions<'static>;
145
+
146
+
fn into_static(self) -> Self::Output {
147
+
CallOptions {
148
+
auth: self.auth.map(|auth| auth.into_static()),
149
+
atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()),
150
+
atproto_accept_labelers: self
151
+
.atproto_accept_labelers
152
+
.map(|labelers| labelers.into_static()),
153
+
extra_headers: self.extra_headers,
154
+
}
155
+
}
156
+
}
157
+
158
+
/// Extension for stateless XRPC calls on any `HttpClient`.
159
+
///
160
+
/// Example
161
+
/// ```ignore
162
+
/// use jacquard::client::XrpcExt;
163
+
/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
164
+
/// use jacquard::types::ident::AtIdentifier;
165
+
/// use miette::IntoDiagnostic;
166
+
///
167
+
/// #[tokio::main]
168
+
/// async fn main() -> miette::Result<()> {
169
+
/// let http = reqwest::Client::new();
170
+
/// let base = url::Url::parse("https://public.api.bsky.app")?;
171
+
/// let resp = http
172
+
/// .xrpc(base)
173
+
/// .send(
174
+
/// GetAuthorFeed::new()
175
+
/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
176
+
/// .limit(5)
177
+
/// .build(),
178
+
/// )
179
+
/// .await?;
180
+
/// let out = resp.into_output()?;
181
+
/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
182
+
/// Ok(())
183
+
/// }
184
+
/// ```
185
+
pub trait XrpcExt: HttpClient {
186
+
/// Start building an XRPC call for the given base URL.
187
+
fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
188
+
where
189
+
Self: Sized,
190
+
{
191
+
XrpcCall {
192
+
client: self,
193
+
base,
194
+
opts: CallOptions::default(),
195
+
}
196
+
}
197
+
}
198
+
199
+
impl<T: HttpClient> XrpcExt for T {}
200
+
201
+
/// Stateless XRPC call builder.
202
+
///
203
+
/// Example (per-request overrides)
204
+
/// ```ignore
205
+
/// use jacquard::client::{XrpcExt, AuthorizationToken};
206
+
/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
207
+
/// use jacquard::types::ident::AtIdentifier;
208
+
/// use jacquard::CowStr;
209
+
/// use miette::IntoDiagnostic;
210
+
///
211
+
/// #[tokio::main]
212
+
/// async fn main() -> miette::Result<()> {
213
+
/// let http = reqwest::Client::new();
214
+
/// let base = url::Url::parse("https://public.api.bsky.app")?;
215
+
/// let resp = http
216
+
/// .xrpc(base)
217
+
/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
218
+
/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
219
+
/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
220
+
/// .send(
221
+
/// GetAuthorFeed::new()
222
+
/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
223
+
/// .limit(5)
224
+
/// .build(),
225
+
/// )
226
+
/// .await?;
227
+
/// let out = resp.into_output()?;
228
+
/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
229
+
/// Ok(())
230
+
/// }
231
+
/// ```
232
+
pub struct XrpcCall<'a, C: HttpClient> {
233
+
pub(crate) client: &'a C,
234
+
pub(crate) base: Url,
235
+
pub(crate) opts: CallOptions<'a>,
236
+
}
237
+
238
+
impl<'a, C: HttpClient> XrpcCall<'a, C> {
239
+
/// Apply Authorization to this call.
240
+
pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
241
+
self.opts.auth = Some(token);
242
+
self
243
+
}
244
+
/// Set `atproto-proxy` header for this call.
245
+
pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
246
+
self.opts.atproto_proxy = Some(proxy);
247
+
self
248
+
}
249
+
/// Set `atproto-accept-labelers` header(s) for this call.
250
+
pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
251
+
self.opts.atproto_accept_labelers = Some(labelers);
252
+
self
253
+
}
254
+
/// Add an extra header.
255
+
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
256
+
self.opts.extra_headers.push((name, value));
257
+
self
258
+
}
259
+
/// Replace the builder's options entirely.
260
+
pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
261
+
self.opts = opts;
262
+
self
263
+
}
264
+
265
+
/// Send the given typed XRPC request and return a response wrapper.
266
+
///
267
+
/// Note on 401 handling:
268
+
/// - When the server returns 401 with a `WWW-Authenticate` header, this surfaces as
269
+
/// `ClientError::Auth(AuthError::Other(header))` so higher layers (e.g., OAuth/DPoP) can
270
+
/// inspect the header for `error="invalid_token"` or `error="use_dpop_nonce"` and react
271
+
/// (refresh/retry). If the header is absent, the 401 body flows through to `Response` and
272
+
/// can be parsed/mapped to `AuthError` as appropriate.
273
+
pub async fn send<R: XrpcRequest + Send>(self, request: &R) -> XrpcResult<Response<R>> {
274
+
let http_request = build_http_request(&self.base, request, &self.opts)
275
+
.map_err(crate::error::TransportError::from)?;
276
+
277
+
let http_response = self
278
+
.client
279
+
.send_http(http_request)
280
+
.await
281
+
.map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
282
+
283
+
let status = http_response.status();
284
+
// If the server returned 401 with a WWW-Authenticate header, expose it so higher layers
285
+
// (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh.
286
+
if status.as_u16() == 401 {
287
+
if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) {
288
+
return Err(crate::error::ClientError::Auth(
289
+
crate::error::AuthError::Other(hv.clone()),
290
+
));
291
+
}
292
+
}
293
+
let buffer = Bytes::from(http_response.into_body());
294
+
295
+
if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
296
+
return Err(crate::error::HttpError {
297
+
status,
298
+
body: Some(buffer),
299
+
}
300
+
.into());
301
+
}
302
+
303
+
Ok(Response::new(buffer, status))
304
+
}
305
+
}
306
+
307
+
/// HTTP headers commonly used in XRPC requests
308
+
pub enum Header {
309
+
/// Content-Type header
310
+
ContentType,
311
+
/// Authorization header
312
+
Authorization,
313
+
/// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate.
314
+
///
315
+
/// See: <https://atproto.com/specs/xrpc#service-proxying>
316
+
AtprotoProxy,
317
+
/// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details.
318
+
AtprotoAcceptLabelers,
319
+
}
320
+
321
+
impl From<Header> for HeaderName {
322
+
fn from(value: Header) -> Self {
323
+
match value {
324
+
Header::ContentType => CONTENT_TYPE,
325
+
Header::Authorization => AUTHORIZATION,
326
+
Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
327
+
Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
328
+
}
329
+
}
330
+
}
331
+
332
+
/// Build an HTTP request for an XRPC call given base URL and options
333
+
pub fn build_http_request<R: XrpcRequest>(
334
+
base: &Url,
335
+
req: &R,
336
+
opts: &CallOptions<'_>,
337
+
) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> {
338
+
let mut url = base.clone();
339
+
let mut path = url.path().trim_end_matches('/').to_owned();
340
+
path.push_str("/xrpc/");
341
+
path.push_str(R::NSID);
342
+
url.set_path(&path);
343
+
344
+
if let XrpcMethod::Query = R::METHOD {
345
+
let qs = serde_html_form::to_string(&req)
346
+
.map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?;
347
+
if !qs.is_empty() {
348
+
url.set_query(Some(&qs));
349
+
} else {
350
+
url.set_query(None);
351
+
}
352
+
}
353
+
354
+
let method = match R::METHOD {
355
+
XrpcMethod::Query => http::Method::GET,
356
+
XrpcMethod::Procedure(_) => http::Method::POST,
357
+
};
358
+
359
+
let mut builder = Request::builder().method(method).uri(url.as_str());
360
+
361
+
if let XrpcMethod::Procedure(encoding) = R::METHOD {
362
+
builder = builder.header(Header::ContentType, encoding);
363
+
}
364
+
builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
365
+
366
+
if let Some(token) = &opts.auth {
367
+
let hv = match token {
368
+
AuthorizationToken::Bearer(t) => {
369
+
HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
370
+
}
371
+
AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
372
+
}
373
+
.map_err(|e| {
374
+
TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
375
+
})?;
376
+
builder = builder.header(Header::Authorization, hv);
377
+
}
378
+
379
+
if let Some(proxy) = &opts.atproto_proxy {
380
+
builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
381
+
}
382
+
if let Some(labelers) = &opts.atproto_accept_labelers {
383
+
if !labelers.is_empty() {
384
+
let joined = labelers
385
+
.iter()
386
+
.map(|s| s.as_ref())
387
+
.collect::<Vec<_>>()
388
+
.join(", ");
389
+
builder = builder.header(Header::AtprotoAcceptLabelers, joined);
390
+
}
391
+
}
392
+
for (name, value) in &opts.extra_headers {
393
+
builder = builder.header(name, value);
394
+
}
395
+
396
+
let body = if let XrpcMethod::Procedure(_) = R::METHOD {
397
+
req.encode_body()
398
+
.map_err(|e| TransportError::InvalidRequest(e.to_string()))?
399
+
} else {
400
+
vec![]
401
+
};
402
+
403
+
builder
404
+
.body(body)
405
+
.map_err(|e| TransportError::InvalidRequest(e.to_string()))
406
+
}
407
+
408
+
/// XRPC response wrapper that owns the response buffer
409
+
///
410
+
/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations.
411
+
/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`).
412
+
pub struct Response<R: XrpcRequest> {
413
+
buffer: Bytes,
414
+
status: StatusCode,
415
+
_marker: PhantomData<R>,
416
+
}
417
+
418
+
impl<R: XrpcRequest> Response<R> {
419
+
/// Create a new response from a buffer and status code
420
+
pub fn new(buffer: Bytes, status: StatusCode) -> Self {
421
+
Self {
422
+
buffer,
423
+
status,
424
+
_marker: PhantomData,
425
+
}
426
+
}
427
+
428
+
/// Get the HTTP status code
429
+
pub fn status(&self) -> StatusCode {
430
+
self.status
431
+
}
432
+
433
+
/// Parse the response, borrowing from the internal buffer
434
+
pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> {
435
+
// Use a helper to make lifetime inference work
436
+
fn parse_output<'b, R: XrpcRequest>(
437
+
buffer: &'b [u8],
438
+
) -> Result<R::Output<'b>, serde_json::Error> {
439
+
serde_json::from_slice(buffer)
440
+
}
441
+
442
+
fn parse_error<'b, R: XrpcRequest>(
443
+
buffer: &'b [u8],
444
+
) -> Result<R::Err<'b>, serde_json::Error> {
445
+
serde_json::from_slice(buffer)
446
+
}
447
+
448
+
// 200: parse as output
449
+
if self.status.is_success() {
450
+
match parse_output::<R>(&self.buffer) {
451
+
Ok(output) => Ok(output),
452
+
Err(e) => Err(XrpcError::Decode(e)),
453
+
}
454
+
// 400: try typed XRPC error, fallback to generic error
455
+
} else if self.status.as_u16() == 400 {
456
+
match parse_error::<R>(&self.buffer) {
457
+
Ok(error) => Err(XrpcError::Xrpc(error)),
458
+
Err(_) => {
459
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
460
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
461
+
Ok(mut generic) => {
462
+
generic.nsid = R::NSID;
463
+
generic.method = R::METHOD.as_str();
464
+
generic.http_status = self.status;
465
+
// Map auth-related errors to AuthError
466
+
match generic.error.as_str() {
467
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
468
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
469
+
_ => Err(XrpcError::Generic(generic)),
470
+
}
471
+
}
472
+
Err(e) => Err(XrpcError::Decode(e)),
473
+
}
474
+
}
475
+
}
476
+
// 401: always auth error
477
+
} else {
478
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
479
+
Ok(mut generic) => {
480
+
generic.nsid = R::NSID;
481
+
generic.method = R::METHOD.as_str();
482
+
generic.http_status = self.status;
483
+
match generic.error.as_str() {
484
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
485
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
486
+
_ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
487
+
}
488
+
}
489
+
Err(e) => Err(XrpcError::Decode(e)),
490
+
}
491
+
}
492
+
}
493
+
494
+
/// Parse the response into an owned output
495
+
pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>>
496
+
where
497
+
for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>,
498
+
for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>,
499
+
{
500
+
// Use a helper to make lifetime inference work
501
+
fn parse_output<'b, R: XrpcRequest>(
502
+
buffer: &'b [u8],
503
+
) -> Result<R::Output<'b>, serde_json::Error> {
504
+
serde_json::from_slice(buffer)
505
+
}
506
+
507
+
fn parse_error<'b, R: XrpcRequest>(
508
+
buffer: &'b [u8],
509
+
) -> Result<R::Err<'b>, serde_json::Error> {
510
+
serde_json::from_slice(buffer)
511
+
}
512
+
513
+
// 200: parse as output
514
+
if self.status.is_success() {
515
+
match parse_output::<R>(&self.buffer) {
516
+
Ok(output) => Ok(output.into_static()),
517
+
Err(e) => Err(XrpcError::Decode(e)),
518
+
}
519
+
// 400: try typed XRPC error, fallback to generic error
520
+
} else if self.status.as_u16() == 400 {
521
+
match parse_error::<R>(&self.buffer) {
522
+
Ok(error) => Err(XrpcError::Xrpc(error.into_static())),
523
+
Err(_) => {
524
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
525
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
526
+
Ok(mut generic) => {
527
+
generic.nsid = R::NSID;
528
+
generic.method = R::METHOD.as_str();
529
+
generic.http_status = self.status;
530
+
// Map auth-related errors to AuthError
531
+
match generic.error.as_ref() {
532
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
533
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
534
+
_ => Err(XrpcError::Generic(generic)),
535
+
}
536
+
}
537
+
Err(e) => Err(XrpcError::Decode(e)),
538
+
}
539
+
}
540
+
}
541
+
// 401: always auth error
542
+
} else {
543
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
544
+
Ok(mut generic) => {
545
+
let status = self.status;
546
+
generic.nsid = R::NSID;
547
+
generic.method = R::METHOD.as_str();
548
+
generic.http_status = status;
549
+
match generic.error.as_ref() {
550
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
551
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
552
+
_ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
553
+
}
554
+
}
555
+
Err(e) => Err(XrpcError::Decode(e)),
556
+
}
557
+
}
558
+
}
559
+
560
+
/// Get the raw buffer
561
+
pub fn buffer(&self) -> &Bytes {
562
+
&self.buffer
563
+
}
564
+
}
565
+
566
+
/// Generic XRPC error format for untyped errors like InvalidRequest
567
+
///
568
+
/// Used when the error doesn't match the endpoint's specific error enum
569
+
#[derive(Debug, Clone, Deserialize)]
570
+
pub struct GenericXrpcError {
571
+
/// Error code (e.g., "InvalidRequest")
572
+
pub error: SmolStr,
573
+
/// Optional error message with details
574
+
pub message: Option<SmolStr>,
575
+
/// XRPC method NSID that produced this error (context only; not serialized)
576
+
#[serde(skip)]
577
+
pub nsid: &'static str,
578
+
/// HTTP method used (GET/POST) (context only; not serialized)
579
+
#[serde(skip)]
580
+
pub method: &'static str,
581
+
/// HTTP status code (context only; not serialized)
582
+
#[serde(skip)]
583
+
pub http_status: StatusCode,
584
+
}
585
+
586
+
impl std::fmt::Display for GenericXrpcError {
587
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588
+
if let Some(msg) = &self.message {
589
+
write!(
590
+
f,
591
+
"{}: {} (nsid={}, method={}, status={})",
592
+
self.error, msg, self.nsid, self.method, self.http_status
593
+
)
594
+
} else {
595
+
write!(
596
+
f,
597
+
"{} (nsid={}, method={}, status={})",
598
+
self.error, self.nsid, self.method, self.http_status
599
+
)
600
+
}
601
+
}
602
+
}
603
+
604
+
impl std::error::Error for GenericXrpcError {}
605
+
606
+
/// XRPC-specific errors returned from endpoints
607
+
///
608
+
/// Represents errors returned in the response body
609
+
/// Type parameter `E` is the endpoint's specific error enum type.
610
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
611
+
pub enum XrpcError<E: std::error::Error + IntoStatic> {
612
+
/// Typed XRPC error from the endpoint's specific error enum
613
+
#[error("XRPC error: {0}")]
614
+
#[diagnostic(code(jacquard_common::xrpc::typed))]
615
+
Xrpc(E),
616
+
617
+
/// Authentication error (ExpiredToken, InvalidToken, etc.)
618
+
#[error("Authentication error: {0}")]
619
+
#[diagnostic(code(jacquard_common::xrpc::auth))]
620
+
Auth(#[from] AuthError),
621
+
622
+
/// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest)
623
+
#[error("XRPC error: {0}")]
624
+
#[diagnostic(code(jacquard_common::xrpc::generic))]
625
+
Generic(GenericXrpcError),
626
+
627
+
/// Failed to decode the response body
628
+
#[error("Failed to decode response: {0}")]
629
+
#[diagnostic(code(jacquard_common::xrpc::decode))]
630
+
Decode(#[from] serde_json::Error),
631
+
}
632
+
633
+
#[cfg(test)]
634
+
mod tests {
635
+
use super::*;
636
+
use serde::{Deserialize, Serialize};
637
+
638
+
#[derive(Serialize)]
639
+
struct DummyReq;
640
+
641
+
#[derive(Deserialize, Debug, thiserror::Error)]
642
+
#[error("{0}")]
643
+
struct DummyErr<'a>(#[serde(borrow)] CowStr<'a>);
644
+
645
+
impl IntoStatic for DummyErr<'_> {
646
+
type Output = DummyErr<'static>;
647
+
fn into_static(self) -> Self::Output {
648
+
DummyErr(self.0.into_static())
649
+
}
650
+
}
651
+
652
+
impl XrpcRequest for DummyReq {
653
+
const NSID: &'static str = "test.dummy";
654
+
const METHOD: XrpcMethod = XrpcMethod::Procedure("application/json");
655
+
const OUTPUT_ENCODING: &'static str = "application/json";
656
+
type Output<'de> = ();
657
+
type Err<'de> = DummyErr<'de>;
658
+
}
659
+
660
+
#[test]
661
+
fn generic_error_carries_context() {
662
+
let body = serde_json::json!({"error":"InvalidRequest","message":"missing"});
663
+
let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
664
+
let resp: Response<DummyReq> = Response::new(buf, StatusCode::BAD_REQUEST);
665
+
match resp.parse().unwrap_err() {
666
+
XrpcError::Generic(g) => {
667
+
assert_eq!(g.error.as_str(), "InvalidRequest");
668
+
assert_eq!(g.message.as_deref(), Some("missing"));
669
+
assert_eq!(g.nsid, DummyReq::NSID);
670
+
assert_eq!(g.method, DummyReq::METHOD.as_str());
671
+
assert_eq!(g.http_status, StatusCode::BAD_REQUEST);
672
+
}
673
+
other => panic!("unexpected: {other:?}"),
674
+
}
675
+
}
676
+
677
+
#[test]
678
+
fn auth_error_mapping() {
679
+
for (code, expect) in [
680
+
("ExpiredToken", AuthError::TokenExpired),
681
+
("InvalidToken", AuthError::InvalidToken),
682
+
] {
683
+
let body = serde_json::json!({"error": code});
684
+
let buf = Bytes::from(serde_json::to_vec(&body).unwrap());
685
+
let resp: Response<DummyReq> = Response::new(buf, StatusCode::UNAUTHORIZED);
686
+
match resp.parse().unwrap_err() {
687
+
XrpcError::Auth(e) => match (e, expect) {
688
+
(AuthError::TokenExpired, AuthError::TokenExpired) => {}
689
+
(AuthError::InvalidToken, AuthError::InvalidToken) => {}
690
+
other => panic!("mismatch: {other:?}"),
691
+
},
692
+
other => panic!("unexpected: {other:?}"),
693
+
}
694
+
}
695
+
}
696
+
697
+
#[test]
698
+
fn no_double_slash_in_path() {
699
+
#[derive(Serialize)]
700
+
struct Req;
701
+
#[derive(Deserialize, Debug, thiserror::Error)]
702
+
#[error("{0}")]
703
+
struct Err<'a>(#[serde(borrow)] CowStr<'a>);
704
+
impl IntoStatic for Err<'_> {
705
+
type Output = Err<'static>;
706
+
fn into_static(self) -> Self::Output { Err(self.0.into_static()) }
707
+
}
708
+
impl XrpcRequest for Req {
709
+
const NSID: &'static str = "com.example.test";
710
+
const METHOD: XrpcMethod = XrpcMethod::Query;
711
+
const OUTPUT_ENCODING: &'static str = "application/json";
712
+
type Output<'de> = ();
713
+
type Err<'de> = Err<'de>;
714
+
}
715
+
716
+
let opts = CallOptions::default();
717
+
for base in [
718
+
Url::parse("https://pds").unwrap(),
719
+
Url::parse("https://pds/").unwrap(),
720
+
Url::parse("https://pds/base/").unwrap(),
721
+
] {
722
+
let req = build_http_request(&base, &Req, &opts).unwrap();
723
+
let uri = req.uri().to_string();
724
+
assert!(uri.contains("/xrpc/com.example.test"));
725
+
assert!(!uri.contains("//xrpc"));
726
+
}
727
+
}
728
+
}
729
+
730
+
/// Stateful XRPC call trait
731
+
pub trait XrpcClient: HttpClient {
732
+
/// Get the base URI for the client.
733
+
fn base_uri(&self) -> Url;
734
+
735
+
/// Get the call options for the client.
736
+
fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
737
+
async { CallOptions::default() }
738
+
}
739
+
/// Send an XRPC request and parse the response
740
+
fn send<R: XrpcRequest + Send>(
741
+
self,
742
+
request: &R,
743
+
) -> impl Future<Output = XrpcResult<Response<R>>>;
744
+
}
+1
-1
crates/jacquard-derive/Cargo.toml
+1
-1
crates/jacquard-derive/Cargo.toml
+35
crates/jacquard-identity/Cargo.toml
+35
crates/jacquard-identity/Cargo.toml
···
1
+
[package]
2
+
name = "jacquard-identity"
3
+
edition.workspace = true
4
+
version.workspace = true
5
+
authors.workspace = true
6
+
repository.workspace = true
7
+
keywords.workspace = true
8
+
categories.workspace = true
9
+
readme.workspace = true
10
+
exclude.workspace = true
11
+
homepage.workspace = true
12
+
license.workspace = true
13
+
description.workspace = true
14
+
15
+
[features]
16
+
dns = ["dep:hickory-resolver"]
17
+
18
+
[dependencies]
19
+
async-trait.workspace = true
20
+
bon.workspace = true
21
+
bytes.workspace = true
22
+
jacquard-common = { version = "0.3", path = "../jacquard-common" }
23
+
percent-encoding.workspace = true
24
+
reqwest.workspace = true
25
+
url.workspace = true
26
+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] }
27
+
hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]}
28
+
serde.workspace = true
29
+
serde_json.workspace = true
30
+
thiserror.workspace = true
31
+
miette.workspace = true
32
+
http.workspace = true
33
+
jacquard-api = { version = "0.3.0", path = "../jacquard-api" }
34
+
serde_html_form.workspace = true
35
+
urlencoding.workspace = true
+630
crates/jacquard-identity/src/lib.rs
+630
crates/jacquard-identity/src/lib.rs
···
1
+
//! Identity resolution utilities: DID and handle resolution, DID document fetch,
2
+
//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
3
+
//! Identity resolution: handle โ DID and DID โ document, with smart fallbacks.
4
+
//!
5
+
//! Fallback order (default):
6
+
//! - Handle โ DID: DNS TXT (if `dns` feature) โ HTTPS well-known โ PDS XRPC
7
+
//! `resolveHandle` (when `pds_fallback` is configured) โ public API fallback โ Slingshot `resolveHandle` (if configured).
8
+
//! - DID โ Doc: did:web well-known โ PLC/Slingshot HTTP โ PDS XRPC `resolveDid` (when configured),
9
+
//! then Slingshot miniโdoc (partial) if configured.
10
+
//!
11
+
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
12
+
//! and optionally validate the document `id` against the requested DID.
13
+
14
+
// use crate::CowStr; // not currently needed directly here
15
+
pub mod resolver;
16
+
17
+
use crate::resolver::{
18
+
DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource,
19
+
ResolverOptions,
20
+
};
21
+
use bytes::Bytes;
22
+
use jacquard_api::com_atproto::identity::resolve_did;
23
+
use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle;
24
+
use jacquard_common::error::TransportError;
25
+
use jacquard_common::http_client::HttpClient;
26
+
use jacquard_common::types::did::Did;
27
+
use jacquard_common::types::did_doc::DidDocument;
28
+
use jacquard_common::types::ident::AtIdentifier;
29
+
use jacquard_common::types::xrpc::XrpcExt;
30
+
use jacquard_common::{IntoStatic, types::string::Handle};
31
+
use percent_encoding::percent_decode_str;
32
+
use reqwest::StatusCode;
33
+
use url::{ParseError, Url};
34
+
35
+
#[cfg(feature = "dns")]
36
+
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
37
+
38
+
/// Default resolver implementation with configurable fallback order.
39
+
pub struct JacquardResolver {
40
+
http: reqwest::Client,
41
+
opts: ResolverOptions,
42
+
#[cfg(feature = "dns")]
43
+
dns: Option<TokioAsyncResolver>,
44
+
}
45
+
46
+
impl JacquardResolver {
47
+
/// Create a new instance of the default resolver with all options (except DNS) up front
48
+
pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
49
+
Self {
50
+
http,
51
+
opts,
52
+
#[cfg(feature = "dns")]
53
+
dns: None,
54
+
}
55
+
}
56
+
57
+
#[cfg(feature = "dns")]
58
+
/// Create a new instance of the default resolver with all options, plus default DNS, up front
59
+
pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self {
60
+
Self {
61
+
http,
62
+
opts,
63
+
dns: Some(TokioAsyncResolver::tokio(
64
+
ResolverConfig::default(),
65
+
Default::default(),
66
+
)),
67
+
}
68
+
}
69
+
70
+
#[cfg(feature = "dns")]
71
+
/// Add default DNS resolution to the resolver
72
+
pub fn with_system_dns(mut self) -> Self {
73
+
self.dns = Some(TokioAsyncResolver::tokio(
74
+
ResolverConfig::default(),
75
+
Default::default(),
76
+
));
77
+
self
78
+
}
79
+
80
+
/// Set PLC source (PLC directory or Slingshot)
81
+
pub fn with_plc_source(mut self, source: PlcSource) -> Self {
82
+
self.opts.plc_source = source;
83
+
self
84
+
}
85
+
86
+
/// Enable/disable public unauthenticated fallback for resolveHandle
87
+
pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
88
+
self.opts.public_fallback_for_handle = enable;
89
+
self
90
+
}
91
+
92
+
/// Enable/disable doc id validation
93
+
pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
94
+
self.opts.validate_doc_id = enable;
95
+
self
96
+
}
97
+
98
+
/// Construct the well-known HTTPS URL for a `did:web` DID.
99
+
///
100
+
/// - `did:web:example.com` โ `https://example.com/.well-known/did.json`
101
+
/// - `did:web:example.com:user:alice` โ `https://example.com/user/alice/did.json`
102
+
fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
103
+
// did:web:example.com[:path:segments]
104
+
let s = did.as_str();
105
+
let rest = s
106
+
.strip_prefix("did:web:")
107
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
108
+
let mut parts = rest.split(':');
109
+
let host = parts
110
+
.next()
111
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
112
+
let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
113
+
let path: Vec<&str> = parts.collect();
114
+
if path.is_empty() {
115
+
url.set_path(".well-known/did.json");
116
+
} else {
117
+
// Append path segments and did.json
118
+
let mut segments = url
119
+
.path_segments_mut()
120
+
.map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
121
+
for seg in path {
122
+
// Minimally percent-decode each segment per spec guidance
123
+
let decoded = percent_decode_str(seg).decode_utf8_lossy();
124
+
segments.push(&decoded);
125
+
}
126
+
segments.push("did.json");
127
+
// drop segments
128
+
}
129
+
Ok(url)
130
+
}
131
+
132
+
#[cfg(test)]
133
+
fn test_did_web_url_raw(&self, s: &str) -> String {
134
+
let did = Did::new(s).unwrap();
135
+
self.did_web_url(&did).unwrap().to_string()
136
+
}
137
+
138
+
async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
139
+
let resp = self
140
+
.http
141
+
.get(url)
142
+
.send()
143
+
.await
144
+
.map_err(TransportError::from)?;
145
+
let status = resp.status();
146
+
let buf = resp.bytes().await.map_err(TransportError::from)?;
147
+
Ok((buf, status))
148
+
}
149
+
150
+
async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
151
+
let resp = self
152
+
.http
153
+
.get(url)
154
+
.send()
155
+
.await
156
+
.map_err(TransportError::from)?;
157
+
if resp.status() == StatusCode::OK {
158
+
Ok(resp.text().await.map_err(TransportError::from)?)
159
+
} else {
160
+
Err(IdentityError::Http(
161
+
resp.error_for_status().unwrap_err().into(),
162
+
))
163
+
}
164
+
}
165
+
166
+
#[cfg(feature = "dns")]
167
+
async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
168
+
let Some(dns) = &self.dns else {
169
+
return Ok(vec![]);
170
+
};
171
+
let fqdn = format!("_atproto.{name}.");
172
+
let response = dns.txt_lookup(fqdn).await?;
173
+
let mut out = Vec::new();
174
+
for txt in response.iter() {
175
+
for data in txt.txt_data().iter() {
176
+
out.push(String::from_utf8_lossy(data).to_string());
177
+
}
178
+
}
179
+
Ok(out)
180
+
}
181
+
182
+
fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
183
+
let line = body
184
+
.lines()
185
+
.find(|l| !l.trim().is_empty())
186
+
.ok_or(IdentityError::InvalidWellKnown)?;
187
+
let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
188
+
Ok(did.into_static())
189
+
}
190
+
}
191
+
192
+
impl JacquardResolver {
193
+
/// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
194
+
pub async fn resolve_handle_via_pds(
195
+
&self,
196
+
handle: &Handle<'_>,
197
+
) -> Result<Did<'static>, IdentityError> {
198
+
let pds = match &self.opts.pds_fallback {
199
+
Some(u) => u.clone(),
200
+
None => return Err(IdentityError::InvalidWellKnown),
201
+
};
202
+
let req = ResolveHandle::new().handle((*handle).clone()).build();
203
+
let resp = self
204
+
.http
205
+
.xrpc(pds)
206
+
.send(&req)
207
+
.await
208
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
209
+
let out = resp
210
+
.into_output()
211
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
212
+
Did::new_owned(out.did.as_str())
213
+
.map(|d| d.into_static())
214
+
.map_err(|_| IdentityError::InvalidWellKnown)
215
+
}
216
+
217
+
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
218
+
pub async fn fetch_did_doc_via_pds_owned(
219
+
&self,
220
+
did: &Did<'_>,
221
+
) -> Result<DidDocument<'static>, IdentityError> {
222
+
let pds = match &self.opts.pds_fallback {
223
+
Some(u) => u.clone(),
224
+
None => return Err(IdentityError::InvalidWellKnown),
225
+
};
226
+
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
227
+
let resp = self
228
+
.http
229
+
.xrpc(pds)
230
+
.send(&req)
231
+
.await
232
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
233
+
let out = resp
234
+
.into_output()
235
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
236
+
let doc_json = serde_json::to_value(&out.did_doc)?;
237
+
let s = serde_json::to_string(&doc_json)?;
238
+
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
239
+
Ok(doc_borrowed.into_static())
240
+
}
241
+
242
+
/// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
243
+
/// Returns the raw response wrapper for borrowed parsing and validation.
244
+
pub async fn fetch_mini_doc_via_slingshot(
245
+
&self,
246
+
did: &Did<'_>,
247
+
) -> Result<DidDocResponse, IdentityError> {
248
+
let base = match &self.opts.plc_source {
249
+
PlcSource::Slingshot { base } => base.clone(),
250
+
_ => {
251
+
return Err(IdentityError::UnsupportedDidMethod(
252
+
"mini-doc requires Slingshot source".into(),
253
+
));
254
+
}
255
+
};
256
+
let mut url = base;
257
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
258
+
if let Ok(qs) =
259
+
serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
260
+
{
261
+
url.set_query(Some(&qs));
262
+
}
263
+
let (buf, status) = self.get_json_bytes(url).await?;
264
+
Ok(DidDocResponse {
265
+
buffer: buf,
266
+
status,
267
+
requested: Some(did.clone().into_static()),
268
+
})
269
+
}
270
+
}
271
+
272
+
#[async_trait::async_trait]
273
+
impl IdentityResolver for JacquardResolver {
274
+
fn options(&self) -> &ResolverOptions {
275
+
&self.opts
276
+
}
277
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
278
+
let host = handle.as_str();
279
+
for step in &self.opts.handle_order {
280
+
match step {
281
+
HandleStep::DnsTxt => {
282
+
#[cfg(feature = "dns")]
283
+
{
284
+
if let Ok(txts) = self.dns_txt(host).await {
285
+
for txt in txts {
286
+
if let Some(did_str) = txt.strip_prefix("did=") {
287
+
if let Ok(did) = Did::new(did_str) {
288
+
return Ok(did.into_static());
289
+
}
290
+
}
291
+
}
292
+
}
293
+
}
294
+
}
295
+
HandleStep::HttpsWellKnown => {
296
+
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
297
+
if let Ok(text) = self.get_text(url).await {
298
+
if let Ok(did) = Self::parse_atproto_did_body(&text) {
299
+
return Ok(did);
300
+
}
301
+
}
302
+
}
303
+
HandleStep::PdsResolveHandle => {
304
+
// Prefer PDS XRPC via stateless client
305
+
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
306
+
return Ok(did);
307
+
}
308
+
// Public unauth fallback
309
+
if self.opts.public_fallback_for_handle {
310
+
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
311
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
312
+
if let Ok(qs) = serde_html_form::to_string(
313
+
&ResolveHandle::new().handle((*handle).clone()).build(),
314
+
) {
315
+
url.set_query(Some(&qs));
316
+
} else {
317
+
continue;
318
+
}
319
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
320
+
if status.is_success() {
321
+
if let Ok(val) =
322
+
serde_json::from_slice::<serde_json::Value>(&buf)
323
+
{
324
+
if let Some(did_str) =
325
+
val.get("did").and_then(|v| v.as_str())
326
+
{
327
+
if let Ok(did) = Did::new_owned(did_str) {
328
+
return Ok(did.into_static());
329
+
}
330
+
}
331
+
}
332
+
}
333
+
}
334
+
}
335
+
}
336
+
// Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
337
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
338
+
let mut url = base.clone();
339
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
340
+
if let Ok(qs) = serde_html_form::to_string(
341
+
&ResolveHandle::new().handle((*handle).clone()).build(),
342
+
) {
343
+
url.set_query(Some(&qs));
344
+
} else {
345
+
continue;
346
+
}
347
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
348
+
if status.is_success() {
349
+
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
350
+
if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
351
+
if let Ok(did) = Did::new_owned(did_str) {
352
+
return Ok(did.into_static());
353
+
}
354
+
}
355
+
}
356
+
}
357
+
}
358
+
}
359
+
}
360
+
}
361
+
}
362
+
Err(IdentityError::InvalidWellKnown)
363
+
}
364
+
365
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
366
+
let s = did.as_str();
367
+
for step in &self.opts.did_order {
368
+
match step {
369
+
DidStep::DidWebHttps if s.starts_with("did:web:") => {
370
+
let url = self.did_web_url(did)?;
371
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
372
+
return Ok(DidDocResponse {
373
+
buffer: buf,
374
+
status,
375
+
requested: Some(did.clone().into_static()),
376
+
});
377
+
}
378
+
}
379
+
DidStep::PlcHttp if s.starts_with("did:plc:") => {
380
+
let url = match &self.opts.plc_source {
381
+
PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
382
+
PlcSource::Slingshot { base } => base.join(did.as_str())?,
383
+
};
384
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
385
+
return Ok(DidDocResponse {
386
+
buffer: buf,
387
+
status,
388
+
requested: Some(did.clone().into_static()),
389
+
});
390
+
}
391
+
}
392
+
DidStep::PdsResolveDid => {
393
+
// Try PDS XRPC for full DID doc
394
+
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
395
+
let buf = serde_json::to_vec(&doc).unwrap_or_default();
396
+
return Ok(DidDocResponse {
397
+
buffer: Bytes::from(buf),
398
+
status: StatusCode::OK,
399
+
requested: Some(did.clone().into_static()),
400
+
});
401
+
}
402
+
// Fallback: if Slingshot configured, return mini-doc response (partial doc)
403
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
404
+
let url = self.slingshot_mini_doc_url(base, did.as_str())?;
405
+
let (buf, status) = self.get_json_bytes(url).await?;
406
+
return Ok(DidDocResponse {
407
+
buffer: buf,
408
+
status,
409
+
requested: Some(did.clone().into_static()),
410
+
});
411
+
}
412
+
}
413
+
_ => {}
414
+
}
415
+
}
416
+
Err(IdentityError::UnsupportedDidMethod(s.to_string()))
417
+
}
418
+
}
419
+
420
+
impl HttpClient for JacquardResolver {
421
+
async fn send_http(
422
+
&self,
423
+
request: http::Request<Vec<u8>>,
424
+
) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
425
+
self.http.send_http(request).await
426
+
}
427
+
428
+
type Error = reqwest::Error;
429
+
}
430
+
431
+
/// Warnings produced during identity checks that are not fatal
432
+
#[derive(Debug, Clone, PartialEq, Eq)]
433
+
pub enum IdentityWarning {
434
+
/// The DID doc did not contain the expected handle alias under alsoKnownAs
435
+
HandleAliasMismatch {
436
+
#[allow(missing_docs)]
437
+
expected: Handle<'static>,
438
+
},
439
+
}
440
+
441
+
impl JacquardResolver {
442
+
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
443
+
/// This applies the default equality check on the document id (error with doc if mismatch).
444
+
pub async fn resolve_handle_and_doc(
445
+
&self,
446
+
handle: &Handle<'_>,
447
+
) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
448
+
let did = self.resolve_handle(handle).await?;
449
+
let resp = self.resolve_did_doc(&did).await?;
450
+
let resp_for_parse = resp.clone();
451
+
let doc_borrowed = resp_for_parse.parse()?;
452
+
if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
453
+
return Err(IdentityError::DocIdMismatch {
454
+
expected: did.clone().into_static(),
455
+
doc: doc_borrowed.clone().into_static(),
456
+
});
457
+
}
458
+
let mut warnings = Vec::new();
459
+
// Check handle alias presence (soft warning)
460
+
let expected_alias = format!("at://{}", handle.as_str());
461
+
let has_alias = doc_borrowed
462
+
.also_known_as
463
+
.as_ref()
464
+
.map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
465
+
.unwrap_or(false);
466
+
if !has_alias {
467
+
warnings.push(IdentityWarning::HandleAliasMismatch {
468
+
expected: handle.clone().into_static(),
469
+
});
470
+
}
471
+
Ok((did, resp, warnings))
472
+
}
473
+
474
+
/// Build Slingshot mini-doc URL for an identifier (handle or DID)
475
+
fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
476
+
let mut url = base.clone();
477
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
478
+
url.set_query(Some(&format!(
479
+
"identifier={}",
480
+
urlencoding::Encoded::new(identifier)
481
+
)));
482
+
Ok(url)
483
+
}
484
+
485
+
/// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
486
+
pub async fn fetch_mini_doc_via_slingshot_identifier(
487
+
&self,
488
+
identifier: &AtIdentifier<'_>,
489
+
) -> Result<MiniDocResponse, IdentityError> {
490
+
let base = match &self.opts.plc_source {
491
+
PlcSource::Slingshot { base } => base.clone(),
492
+
_ => {
493
+
return Err(IdentityError::UnsupportedDidMethod(
494
+
"mini-doc requires Slingshot source".into(),
495
+
));
496
+
}
497
+
};
498
+
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
499
+
let (buf, status) = self.get_json_bytes(url).await?;
500
+
Ok(MiniDocResponse {
501
+
buffer: buf,
502
+
status,
503
+
})
504
+
}
505
+
}
506
+
507
+
/// Slingshot mini-doc JSON response wrapper
508
+
#[derive(Clone)]
509
+
pub struct MiniDocResponse {
510
+
buffer: Bytes,
511
+
status: StatusCode,
512
+
}
513
+
514
+
impl MiniDocResponse {
515
+
/// Parse borrowed MiniDoc
516
+
pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
517
+
if self.status.is_success() {
518
+
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
519
+
} else {
520
+
Err(IdentityError::HttpStatus(self.status))
521
+
}
522
+
}
523
+
}
524
+
525
+
/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
526
+
pub type PublicResolver = JacquardResolver;
527
+
528
+
impl Default for PublicResolver {
529
+
/// Build a resolver with:
530
+
/// - reqwest HTTP client
531
+
/// - Public fallbacks enabled for handle resolution
532
+
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
533
+
///
534
+
/// Example
535
+
/// ```ignore
536
+
/// use jacquard::identity::resolver::PublicResolver;
537
+
/// let resolver = PublicResolver::default();
538
+
/// ```
539
+
fn default() -> Self {
540
+
let http = reqwest::Client::new();
541
+
let opts = ResolverOptions::default();
542
+
let resolver = JacquardResolver::new(http, opts);
543
+
#[cfg(feature = "dns")]
544
+
let resolver = resolver.with_system_dns();
545
+
resolver
546
+
}
547
+
}
548
+
549
+
/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
550
+
/// mini-doc fallbacks, unauthenticated by default.
551
+
pub fn slingshot_resolver_default() -> PublicResolver {
552
+
let http = reqwest::Client::new();
553
+
let mut opts = ResolverOptions::default();
554
+
opts.plc_source = PlcSource::slingshot_default();
555
+
let resolver = JacquardResolver::new(http, opts);
556
+
#[cfg(feature = "dns")]
557
+
let resolver = resolver.with_system_dns();
558
+
resolver
559
+
}
560
+
561
+
#[cfg(test)]
562
+
mod tests {
563
+
use super::*;
564
+
565
+
#[test]
566
+
fn did_web_urls() {
567
+
let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
568
+
assert_eq!(
569
+
r.test_did_web_url_raw("did:web:example.com"),
570
+
"https://example.com/.well-known/did.json"
571
+
);
572
+
assert_eq!(
573
+
r.test_did_web_url_raw("did:web:example.com:user:alice"),
574
+
"https://example.com/user/alice/did.json"
575
+
);
576
+
}
577
+
578
+
#[test]
579
+
fn slingshot_mini_doc_url_build() {
580
+
let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
581
+
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
582
+
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
583
+
assert_eq!(
584
+
url.as_str(),
585
+
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
586
+
);
587
+
}
588
+
589
+
#[test]
590
+
fn slingshot_mini_doc_parse_success() {
591
+
let buf = Bytes::from_static(
592
+
br#"{
593
+
"did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
594
+
"handle": "bad-example.com",
595
+
"pds": "https://porcini.us-east.host.bsky.network",
596
+
"signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
597
+
}"#,
598
+
);
599
+
let resp = MiniDocResponse {
600
+
buffer: buf,
601
+
status: StatusCode::OK,
602
+
};
603
+
let doc = resp.parse().expect("parse mini-doc");
604
+
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
605
+
assert_eq!(doc.handle.as_str(), "bad-example.com");
606
+
assert_eq!(
607
+
doc.pds.as_ref(),
608
+
"https://porcini.us-east.host.bsky.network"
609
+
);
610
+
assert!(doc.signing_key.as_ref().starts_with('z'));
611
+
}
612
+
613
+
#[test]
614
+
fn slingshot_mini_doc_parse_error_status() {
615
+
let buf = Bytes::from_static(
616
+
br#"{
617
+
"error": "RecordNotFound",
618
+
"message": "This record was deleted"
619
+
}"#,
620
+
);
621
+
let resp = MiniDocResponse {
622
+
buffer: buf,
623
+
status: StatusCode::BAD_REQUEST,
624
+
};
625
+
match resp.parse() {
626
+
Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
627
+
other => panic!("unexpected: {:?}", other),
628
+
}
629
+
}
630
+
}
+428
crates/jacquard-identity/src/resolver.rs
+428
crates/jacquard-identity/src/resolver.rs
···
1
+
//! Identity resolution: handle โ DID and DID โ document, with smart fallbacks.
2
+
//!
3
+
//! Fallback order (default):
4
+
//! - Handle โ DID: DNS TXT (if `dns` feature) โ HTTPS well-known โ PDS XRPC
5
+
//! `resolveHandle` (when `pds_fallback` is configured) โ public API fallback โ Slingshot `resolveHandle` (if configured).
6
+
//! - DID โ Doc: did:web well-known โ PLC/Slingshot HTTP โ PDS XRPC `resolveDid` (when configured),
7
+
//! then Slingshot miniโdoc (partial) if configured.
8
+
//!
9
+
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
+
//! and optionally validate the document `id` against the requested DID.
11
+
12
+
use std::collections::BTreeMap;
13
+
use std::str::FromStr;
14
+
15
+
use bon::Builder;
16
+
use bytes::Bytes;
17
+
use http::StatusCode;
18
+
use jacquard_common::error::TransportError;
19
+
use jacquard_common::types::did::Did;
20
+
use jacquard_common::types::did_doc::{DidDocument, Service};
21
+
use jacquard_common::types::ident::AtIdentifier;
22
+
use jacquard_common::types::string::{AtprotoStr, Handle};
23
+
use jacquard_common::types::uri::Uri;
24
+
use jacquard_common::types::value::{AtDataError, Data};
25
+
use jacquard_common::{CowStr, IntoStatic};
26
+
use miette::Diagnostic;
27
+
use thiserror::Error;
28
+
use url::Url;
29
+
30
+
/// Errors that can occur during identity resolution.
31
+
///
32
+
/// Note: when validating a fetched DID document against a requested DID, a
33
+
/// `DocIdMismatch` error is returned that includes the owned document so callers
34
+
/// can inspect it and decide how to proceed.
35
+
#[derive(Debug, Error, Diagnostic)]
36
+
#[allow(missing_docs)]
37
+
pub enum IdentityError {
38
+
#[error("unsupported DID method: {0}")]
39
+
#[diagnostic(code(jacquard_identity::unsupported_did_method), help("supported DID methods: did:web, did:plc"))]
40
+
UnsupportedDidMethod(String),
41
+
#[error("invalid well-known atproto-did content")]
42
+
#[diagnostic(code(jacquard_identity::invalid_well_known), help("expected first non-empty line to be a DID"))]
43
+
InvalidWellKnown,
44
+
#[error("missing PDS endpoint in DID document")]
45
+
#[diagnostic(code(jacquard_identity::missing_pds_endpoint))]
46
+
MissingPdsEndpoint,
47
+
#[error("HTTP error: {0}")]
48
+
#[diagnostic(code(jacquard_identity::http), help("check network connectivity and TLS configuration"))]
49
+
Http(#[from] TransportError),
50
+
#[error("HTTP status {0}")]
51
+
#[diagnostic(code(jacquard_identity::http_status), help("verify well-known paths or PDS XRPC endpoints"))]
52
+
HttpStatus(StatusCode),
53
+
#[error("XRPC error: {0}")]
54
+
#[diagnostic(code(jacquard_identity::xrpc), help("enable PDS fallback or public resolver if needed"))]
55
+
Xrpc(String),
56
+
#[error("URL parse error: {0}")]
57
+
#[diagnostic(code(jacquard_identity::url))]
58
+
Url(#[from] url::ParseError),
59
+
#[error("DNS error: {0}")]
60
+
#[cfg(feature = "dns")]
61
+
#[diagnostic(code(jacquard_identity::dns))]
62
+
Dns(#[from] hickory_resolver::error::ResolveError),
63
+
#[error("serialize/deserialize error: {0}")]
64
+
#[diagnostic(code(jacquard_identity::serde))]
65
+
Serde(#[from] serde_json::Error),
66
+
#[error("invalid DID document: {0}")]
67
+
#[diagnostic(code(jacquard_identity::invalid_doc), help("validate keys and services; ensure AtprotoPersonalDataServer service exists"))]
68
+
InvalidDoc(String),
69
+
#[error(transparent)]
70
+
#[diagnostic(code(jacquard_identity::data))]
71
+
Data(#[from] AtDataError),
72
+
/// DID document id did not match requested DID; includes the fetched document
73
+
#[error("DID doc id mismatch")]
74
+
#[diagnostic(code(jacquard_identity::doc_id_mismatch), help("document id differs from requested DID; do not trust this document"))]
75
+
DocIdMismatch {
76
+
expected: Did<'static>,
77
+
doc: DidDocument<'static>,
78
+
},
79
+
}
80
+
81
+
/// Source to fetch PLC (did:plc) documents from.
82
+
///
83
+
/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
84
+
/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
85
+
/// `com.atproto.identity.resolveHandle` and a "mini-doc"
86
+
/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
87
+
#[derive(Debug, Clone, PartialEq, Eq)]
88
+
pub enum PlcSource {
89
+
/// Use the public PLC directory
90
+
PlcDirectory {
91
+
/// Base URL for the PLC directory
92
+
base: Url,
93
+
},
94
+
/// Use the slingshot mini-docs service
95
+
Slingshot {
96
+
/// Base URL for the Slingshot service
97
+
base: Url,
98
+
},
99
+
}
100
+
101
+
impl Default for PlcSource {
102
+
fn default() -> Self {
103
+
Self::PlcDirectory {
104
+
base: Url::parse("https://plc.directory/").expect("valid url"),
105
+
}
106
+
}
107
+
}
108
+
109
+
impl PlcSource {
110
+
/// Default Slingshot source (`https://slingshot.microcosm.blue`)
111
+
pub fn slingshot_default() -> Self {
112
+
PlcSource::Slingshot {
113
+
base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
114
+
}
115
+
}
116
+
}
117
+
118
+
/// DID Document fetch response for borrowed/owned parsing.
119
+
///
120
+
/// Carries the raw response bytes and the HTTP status, plus the requested DID
121
+
/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
122
+
/// or `parse_validated()` to also enforce that the doc `id` matches the
123
+
/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
124
+
/// mismatch). Use `into_owned()` to parse into an owned document.
125
+
#[derive(Clone)]
126
+
pub struct DidDocResponse {
127
+
#[allow(missing_docs)]
128
+
pub buffer: Bytes,
129
+
#[allow(missing_docs)]
130
+
pub status: StatusCode,
131
+
/// Optional DID we intended to resolve; used for validation helpers
132
+
pub requested: Option<Did<'static>>,
133
+
}
134
+
135
+
impl DidDocResponse {
136
+
/// Parse as borrowed DidDocument<'_>
137
+
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
138
+
if self.status.is_success() {
139
+
if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
140
+
Ok(doc)
141
+
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
142
+
Ok(DidDocument {
143
+
id: mini_doc.did,
144
+
also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
145
+
verification_method: None,
146
+
service: Some(vec![Service {
147
+
id: CowStr::new_static("#atproto_pds"),
148
+
r#type: CowStr::new_static("AtprotoPersonalDataServer"),
149
+
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
150
+
Url::from_str(&mini_doc.pds).unwrap(),
151
+
)))),
152
+
extra_data: BTreeMap::new(),
153
+
}]),
154
+
extra_data: BTreeMap::new(),
155
+
})
156
+
} else {
157
+
Err(IdentityError::MissingPdsEndpoint)
158
+
}
159
+
} else {
160
+
Err(IdentityError::HttpStatus(self.status))
161
+
}
162
+
}
163
+
164
+
/// Parse and validate that the DID in the document matches the requested DID if present.
165
+
///
166
+
/// On mismatch, returns an error that contains the owned document for inspection.
167
+
pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
168
+
let doc = self.parse()?;
169
+
if let Some(expected) = &self.requested {
170
+
if doc.id.as_str() != expected.as_str() {
171
+
return Err(IdentityError::DocIdMismatch {
172
+
expected: expected.clone(),
173
+
doc: doc.clone().into_static(),
174
+
});
175
+
}
176
+
}
177
+
Ok(doc)
178
+
}
179
+
180
+
/// Parse as owned DidDocument<'static>
181
+
pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
182
+
if self.status.is_success() {
183
+
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
184
+
Ok(doc.into_static())
185
+
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
186
+
Ok(DidDocument {
187
+
id: mini_doc.did,
188
+
also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
189
+
verification_method: None,
190
+
service: Some(vec![Service {
191
+
id: CowStr::new_static("#atproto_pds"),
192
+
r#type: CowStr::new_static("AtprotoPersonalDataServer"),
193
+
service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
194
+
Url::from_str(&mini_doc.pds).unwrap(),
195
+
)))),
196
+
extra_data: BTreeMap::new(),
197
+
}]),
198
+
extra_data: BTreeMap::new(),
199
+
}
200
+
.into_static())
201
+
} else {
202
+
Err(IdentityError::MissingPdsEndpoint)
203
+
}
204
+
} else {
205
+
Err(IdentityError::HttpStatus(self.status))
206
+
}
207
+
}
208
+
}
209
+
210
+
/// Slingshot mini-doc data (subset of DID doc info)
211
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
212
+
#[serde(rename_all = "camelCase")]
213
+
#[allow(missing_docs)]
214
+
pub struct MiniDoc<'a> {
215
+
#[serde(borrow)]
216
+
pub did: Did<'a>,
217
+
#[serde(borrow)]
218
+
pub handle: Handle<'a>,
219
+
#[serde(borrow)]
220
+
pub pds: CowStr<'a>,
221
+
#[serde(borrow, rename = "signingKey", alias = "signing_key")]
222
+
pub signing_key: CowStr<'a>,
223
+
}
224
+
225
+
/// Handle โ DID fallback step.
226
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227
+
pub enum HandleStep {
228
+
/// DNS TXT _atproto.\<handle\>
229
+
DnsTxt,
230
+
/// HTTPS GET https://\<handle\>/.well-known/atproto-did
231
+
HttpsWellKnown,
232
+
/// XRPC com.atproto.identity.resolveHandle against a provided PDS base
233
+
PdsResolveHandle,
234
+
}
235
+
236
+
/// DID โ Doc fallback step.
237
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238
+
pub enum DidStep {
239
+
/// For did:web: fetch from the well-known location
240
+
DidWebHttps,
241
+
/// For did:plc: fetch from PLC source
242
+
PlcHttp,
243
+
/// If a PDS base is known, ask it for the DID doc
244
+
PdsResolveDid,
245
+
}
246
+
247
+
/// Configurable resolver options.
248
+
///
249
+
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
250
+
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
251
+
/// XRPC over reqwest; authentication can be layered as needed).
252
+
/// - `handle_order`/`did_order`: ordered strategies for resolution.
253
+
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
254
+
/// returning `DocIdMismatch` with the fetched document on mismatch.
255
+
/// - `public_fallback_for_handle`: if true (default), attempt
256
+
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
257
+
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
258
+
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
259
+
#[derive(Debug, Clone, Builder)]
260
+
#[builder(start_fn = new)]
261
+
pub struct ResolverOptions {
262
+
/// PLC data source (directory or slingshot)
263
+
pub plc_source: PlcSource,
264
+
/// Optional PDS base to use for fallbacks
265
+
pub pds_fallback: Option<Url>,
266
+
/// Order of attempts for handle โ DID resolution
267
+
pub handle_order: Vec<HandleStep>,
268
+
/// Order of attempts for DID โ Doc resolution
269
+
pub did_order: Vec<DidStep>,
270
+
/// Validate that fetched DID document id matches the requested DID
271
+
pub validate_doc_id: bool,
272
+
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
273
+
pub public_fallback_for_handle: bool,
274
+
}
275
+
276
+
impl Default for ResolverOptions {
277
+
fn default() -> Self {
278
+
// By default, prefer DNS then HTTPS for handles, then PDS fallback
279
+
// For DID documents, prefer method-native sources, then PDS fallback
280
+
Self::new()
281
+
.plc_source(PlcSource::default())
282
+
.handle_order(vec![
283
+
HandleStep::DnsTxt,
284
+
HandleStep::HttpsWellKnown,
285
+
HandleStep::PdsResolveHandle,
286
+
])
287
+
.did_order(vec![
288
+
DidStep::DidWebHttps,
289
+
DidStep::PlcHttp,
290
+
DidStep::PdsResolveDid,
291
+
])
292
+
.validate_doc_id(true)
293
+
.public_fallback_for_handle(true)
294
+
.build()
295
+
}
296
+
}
297
+
298
+
/// Trait for identity resolution, for pluggable implementations.
299
+
///
300
+
/// The provided `DefaultResolver` supports:
301
+
/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
302
+
/// - HTTPS well-known for handles and `did:web`
303
+
/// - PLC directory or Slingshot for `did:plc`
304
+
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
305
+
/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
306
+
#[async_trait::async_trait()]
307
+
pub trait IdentityResolver {
308
+
/// Access options for validation decisions in default methods
309
+
fn options(&self) -> &ResolverOptions;
310
+
311
+
/// Resolve handle
312
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
313
+
314
+
/// Resolve DID document
315
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
316
+
317
+
/// Resolve DID doc from an identifier
318
+
async fn resolve_ident(
319
+
&self,
320
+
actor: &AtIdentifier<'_>,
321
+
) -> Result<DidDocResponse, IdentityError> {
322
+
match actor {
323
+
AtIdentifier::Did(did) => self.resolve_did_doc(&did).await,
324
+
AtIdentifier::Handle(handle) => {
325
+
let did = self.resolve_handle(&handle).await?;
326
+
self.resolve_did_doc(&did).await
327
+
}
328
+
}
329
+
}
330
+
331
+
/// Resolve DID doc from an identifier
332
+
async fn resolve_ident_owned(
333
+
&self,
334
+
actor: &AtIdentifier<'_>,
335
+
) -> Result<DidDocument<'static>, IdentityError> {
336
+
match actor {
337
+
AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await,
338
+
AtIdentifier::Handle(handle) => {
339
+
let did = self.resolve_handle(&handle).await?;
340
+
self.resolve_did_doc_owned(&did).await
341
+
}
342
+
}
343
+
}
344
+
345
+
/// Resolve the DID document and return an owned version
346
+
async fn resolve_did_doc_owned(
347
+
&self,
348
+
did: &Did<'_>,
349
+
) -> Result<DidDocument<'static>, IdentityError> {
350
+
self.resolve_did_doc(did).await?.into_owned()
351
+
}
352
+
/// Return the PDS url for a DID
353
+
async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
354
+
let resp = self.resolve_did_doc(did).await?;
355
+
let doc = resp.parse()?;
356
+
// Default-on doc id equality check
357
+
if self.options().validate_doc_id {
358
+
if doc.id.as_str() != did.as_str() {
359
+
return Err(IdentityError::DocIdMismatch {
360
+
expected: did.clone().into_static(),
361
+
doc: doc.clone().into_static(),
362
+
});
363
+
}
364
+
}
365
+
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
366
+
}
367
+
/// Return the DIS and PDS url for a handle
368
+
async fn pds_for_handle(
369
+
&self,
370
+
handle: &Handle<'_>,
371
+
) -> Result<(Did<'static>, Url), IdentityError> {
372
+
let did = self.resolve_handle(handle).await?;
373
+
let pds = self.pds_for_did(&did).await?;
374
+
Ok((did, pds))
375
+
}
376
+
}
377
+
378
+
#[async_trait::async_trait]
379
+
impl<T: IdentityResolver + Sync + Send> IdentityResolver for std::sync::Arc<T> {
380
+
fn options(&self) -> &ResolverOptions {
381
+
self.as_ref().options()
382
+
}
383
+
384
+
/// Resolve handle
385
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
386
+
self.as_ref().resolve_handle(handle).await
387
+
}
388
+
389
+
/// Resolve DID document
390
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
391
+
self.as_ref().resolve_did_doc(did).await
392
+
}
393
+
}
394
+
395
+
#[cfg(test)]
396
+
mod tests {
397
+
use super::*;
398
+
399
+
#[test]
400
+
fn parse_validated_ok() {
401
+
let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
402
+
let requested = Did::new_owned("did:plc:alice").unwrap();
403
+
let resp = DidDocResponse {
404
+
buffer: buf,
405
+
status: StatusCode::OK,
406
+
requested: Some(requested),
407
+
};
408
+
let _doc = resp.parse_validated().expect("valid");
409
+
}
410
+
411
+
#[test]
412
+
fn parse_validated_mismatch() {
413
+
let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
414
+
let requested = Did::new_owned("did:plc:alice").unwrap();
415
+
let resp = DidDocResponse {
416
+
buffer: buf,
417
+
status: StatusCode::OK,
418
+
requested: Some(requested),
419
+
};
420
+
match resp.parse_validated() {
421
+
Err(IdentityError::DocIdMismatch { expected, doc }) => {
422
+
assert_eq!(expected.as_str(), "did:plc:alice");
423
+
assert_eq!(doc.id.as_str(), "did:plc:bob");
424
+
}
425
+
other => panic!("unexpected result: {:?}", other),
426
+
}
427
+
}
428
+
}
+1
-1
crates/jacquard-lexicon/Cargo.toml
+1
-1
crates/jacquard-lexicon/Cargo.toml
···
19
19
clap.workspace = true
20
20
heck.workspace = true
21
21
itertools.workspace = true
22
-
jacquard-common = { version = "0.2.0", path = "../jacquard-common" }
22
+
jacquard-common = { version = "0.3.0", path = "../jacquard-common" }
23
23
miette = { workspace = true, features = ["fancy"] }
24
24
prettyplease.workspace = true
25
25
proc-macro2.workspace = true
+35
crates/jacquard-oauth/Cargo.toml
+35
crates/jacquard-oauth/Cargo.toml
···
1
+
[package]
2
+
name = "jacquard-oauth"
3
+
version = "0.3.0"
4
+
edition = "2024"
5
+
description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard"
6
+
license = "MPL-2.0"
7
+
8
+
[dependencies]
9
+
jacquard-common = { version = "0.3.0", path = "../jacquard-common" }
10
+
jacquard-identity = { version = "0.3.0", path = "../jacquard-identity" }
11
+
serde = { workspace = true, features = ["derive"] }
12
+
serde_json = { workspace = true }
13
+
url = { workspace = true }
14
+
smol_str = { workspace = true }
15
+
base64.workspace = true
16
+
sha2 = { version = "0.10" }
17
+
thiserror = { workspace = true }
18
+
serde_html_form = { workspace = true }
19
+
miette = { workspace = true }
20
+
uuid = { version = "1", features = ["v4","std"] }
21
+
p256 = { workspace = true, features = ["ecdsa"] }
22
+
signature = "2"
23
+
rand_core.workspace = true
24
+
jose-jwa = "0.1"
25
+
jose-jwk = { workspace = true, features = ["p256"] }
26
+
chrono.workspace = true
27
+
elliptic-curve = "0.13.8"
28
+
http.workspace = true
29
+
bytes.workspace = true
30
+
rand = { version = "0.8.5", features = ["small_rng"] }
31
+
async-trait.workspace = true
32
+
dashmap = "6.1.0"
33
+
tokio = { workspace = true, features = ["sync"] }
34
+
reqwest.workspace = true
35
+
trait-variant.workspace = true
+414
crates/jacquard-oauth/src/atproto.rs
+414
crates/jacquard-oauth/src/atproto.rs
···
1
+
use std::str::FromStr;
2
+
3
+
use crate::types::OAuthClientMetadata;
4
+
use crate::{keyset::Keyset, scopes::Scope};
5
+
use jacquard_common::CowStr;
6
+
use serde::{Deserialize, Serialize};
7
+
use thiserror::Error;
8
+
use url::Url;
9
+
10
+
#[derive(Error, Debug)]
11
+
pub enum Error {
12
+
#[error("`client_id` must be a valid URL")]
13
+
InvalidClientId,
14
+
#[error("`grant_types` must include `authorization_code`")]
15
+
InvalidGrantTypes,
16
+
#[error("`scope` must not include `atproto`")]
17
+
InvalidScope,
18
+
#[error("`redirect_uris` must not be empty")]
19
+
EmptyRedirectUris,
20
+
#[error("`private_key_jwt` auth method requires `jwks` keys")]
21
+
EmptyJwks,
22
+
#[error(
23
+
"`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
24
+
)]
25
+
AuthSigningAlg,
26
+
#[error(transparent)]
27
+
SerdeHtmlForm(#[from] serde_html_form::ser::Error),
28
+
#[error(transparent)]
29
+
LocalhostClient(#[from] LocalhostClientError),
30
+
}
31
+
32
+
#[derive(Error, Debug)]
33
+
pub enum LocalhostClientError {
34
+
#[error("invalid redirect_uri: {0}")]
35
+
Invalid(#[from] url::ParseError),
36
+
#[error("loopback client_id must use `http:` redirect_uri")]
37
+
NotHttpScheme,
38
+
#[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
39
+
Localhost,
40
+
#[error("loopback client_id must not use loopback addresses as redirect_uri")]
41
+
NotLoopbackHost,
42
+
}
43
+
44
+
pub type Result<T> = core::result::Result<T, Error>;
45
+
46
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47
+
#[serde(rename_all = "snake_case")]
48
+
pub enum AuthMethod {
49
+
None,
50
+
// https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
51
+
PrivateKeyJwt,
52
+
}
53
+
54
+
impl From<AuthMethod> for CowStr<'static> {
55
+
fn from(value: AuthMethod) -> Self {
56
+
match value {
57
+
AuthMethod::None => CowStr::new_static("none"),
58
+
AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"),
59
+
}
60
+
}
61
+
}
62
+
63
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64
+
#[serde(rename_all = "snake_case")]
65
+
pub enum GrantType {
66
+
AuthorizationCode,
67
+
RefreshToken,
68
+
}
69
+
70
+
impl From<GrantType> for CowStr<'static> {
71
+
fn from(value: GrantType) -> Self {
72
+
match value {
73
+
GrantType::AuthorizationCode => CowStr::new_static("authorization_code"),
74
+
GrantType::RefreshToken => CowStr::new_static("refresh_token"),
75
+
}
76
+
}
77
+
}
78
+
79
+
#[derive(Clone, Debug, PartialEq, Eq)]
80
+
pub struct AtprotoClientMetadata<'m> {
81
+
pub client_id: Url,
82
+
pub client_uri: Option<Url>,
83
+
pub redirect_uris: Vec<Url>,
84
+
pub grant_types: Vec<GrantType>,
85
+
pub scopes: Vec<Scope<'m>>,
86
+
pub jwks_uri: Option<Url>,
87
+
}
88
+
89
+
impl<'m> AtprotoClientMetadata<'m> {
90
+
pub fn new(
91
+
client_id: Url,
92
+
client_uri: Option<Url>,
93
+
redirect_uris: Vec<Url>,
94
+
grant_types: Vec<GrantType>,
95
+
scopes: Vec<Scope<'m>>,
96
+
jwks_uri: Option<Url>,
97
+
) -> Self {
98
+
Self {
99
+
client_id,
100
+
client_uri,
101
+
redirect_uris,
102
+
grant_types,
103
+
scopes,
104
+
jwks_uri,
105
+
}
106
+
}
107
+
108
+
pub fn new_localhost(
109
+
mut redirect_uris: Option<Vec<Url>>,
110
+
scopes: Option<Vec<Scope<'m>>>,
111
+
) -> Self {
112
+
// Coerce provided redirect URIs to http://localhost while preserving path
113
+
if let Some(redirect_uris) = &mut redirect_uris {
114
+
for redirect_uri in redirect_uris {
115
+
let _ = redirect_uri.set_scheme("http");
116
+
redirect_uri.set_host(Some("localhost")).unwrap();
117
+
let _ = redirect_uri.set_port(None);
118
+
}
119
+
}
120
+
// determine client_id
121
+
#[derive(serde::Serialize)]
122
+
struct Parameters<'a> {
123
+
#[serde(skip_serializing_if = "Option::is_none")]
124
+
redirect_uri: Option<Vec<Url>>,
125
+
#[serde(skip_serializing_if = "Option::is_none")]
126
+
scope: Option<CowStr<'a>>,
127
+
}
128
+
let query = serde_html_form::to_string(Parameters {
129
+
redirect_uri: redirect_uris.clone(),
130
+
scope: scopes
131
+
.as_ref()
132
+
.map(|s| Scope::serialize_multiple(s.as_slice())),
133
+
})
134
+
.ok();
135
+
let mut client_id = String::from("http://localhost");
136
+
if let Some(query) = query
137
+
&& !query.is_empty()
138
+
{
139
+
client_id.push_str(&format!("?{query}"));
140
+
}
141
+
Self {
142
+
client_id: Url::parse(&client_id).unwrap(),
143
+
client_uri: None,
144
+
redirect_uris: redirect_uris.unwrap_or(vec![
145
+
Url::from_str("http://127.0.0.1/").unwrap(),
146
+
Url::from_str("http://[::1]/").unwrap(),
147
+
]),
148
+
grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
149
+
scopes: scopes.unwrap_or(vec![Scope::Atproto]),
150
+
jwks_uri: None,
151
+
}
152
+
}
153
+
}
154
+
155
+
pub fn atproto_client_metadata<'m>(
156
+
metadata: AtprotoClientMetadata<'m>,
157
+
keyset: &Option<Keyset>,
158
+
) -> Result<OAuthClientMetadata<'m>> {
159
+
// For non-loopback clients, require a keyset/JWKs.
160
+
let is_loopback = metadata.client_id.scheme() == "http"
161
+
&& metadata.client_id.host_str() == Some("localhost");
162
+
if !is_loopback && keyset.is_none() {
163
+
return Err(Error::EmptyJwks);
164
+
}
165
+
if metadata.redirect_uris.is_empty() {
166
+
return Err(Error::EmptyRedirectUris);
167
+
}
168
+
if !metadata.grant_types.contains(&GrantType::AuthorizationCode) {
169
+
return Err(Error::InvalidGrantTypes);
170
+
}
171
+
if !metadata.scopes.contains(&Scope::Atproto) {
172
+
return Err(Error::InvalidScope);
173
+
}
174
+
let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
175
+
let jwks = if metadata.jwks_uri.is_none() {
176
+
Some(keyset.public_jwks())
177
+
} else {
178
+
None
179
+
};
180
+
(AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks)
181
+
} else {
182
+
(AuthMethod::None, None, None)
183
+
};
184
+
185
+
Ok(OAuthClientMetadata {
186
+
client_id: metadata.client_id,
187
+
client_uri: metadata.client_uri,
188
+
redirect_uris: metadata.redirect_uris,
189
+
token_endpoint_auth_method: Some(auth_method.into()),
190
+
grant_types: if keyset.is_some() {
191
+
Some(metadata.grant_types.into_iter().map(|v| v.into()).collect())
192
+
} else {
193
+
None
194
+
},
195
+
scope: if keyset.is_some() {
196
+
Some(Scope::serialize_multiple(metadata.scopes.as_slice()))
197
+
} else {
198
+
None
199
+
},
200
+
dpop_bound_access_tokens: if keyset.is_some() { Some(true) } else { None },
201
+
jwks_uri,
202
+
jwks,
203
+
token_endpoint_auth_signing_alg: if keyset.is_some() {
204
+
Some(CowStr::new_static("ES256"))
205
+
} else {
206
+
None
207
+
},
208
+
})
209
+
}
210
+
211
+
#[cfg(test)]
212
+
mod tests {
213
+
use std::str::FromStr;
214
+
215
+
use crate::scopes::TransitionScope;
216
+
217
+
use super::*;
218
+
use elliptic_curve::SecretKey;
219
+
use jose_jwk::{Jwk, Key, Parameters};
220
+
use p256::pkcs8::DecodePrivateKey;
221
+
222
+
const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
223
+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T
224
+
4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P
225
+
gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3
226
+
-----END PRIVATE KEY-----"#;
227
+
228
+
#[test]
229
+
fn test_localhost_client_metadata_default() {
230
+
assert_eq!(
231
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None)
232
+
.unwrap(),
233
+
OAuthClientMetadata {
234
+
client_id: Url::from_str("http://localhost").unwrap(),
235
+
client_uri: None,
236
+
redirect_uris: vec![
237
+
Url::from_str("http://127.0.0.1/").unwrap(),
238
+
Url::from_str("http://[::1]/").unwrap(),
239
+
],
240
+
scope: None,
241
+
grant_types: None,
242
+
token_endpoint_auth_method: Some(AuthMethod::None.into()),
243
+
dpop_bound_access_tokens: None,
244
+
jwks_uri: None,
245
+
jwks: None,
246
+
token_endpoint_auth_signing_alg: None,
247
+
}
248
+
);
249
+
}
250
+
251
+
#[test]
252
+
fn test_localhost_client_metadata_custom() {
253
+
assert_eq!(
254
+
atproto_client_metadata(AtprotoClientMetadata::new_localhost(
255
+
Some(vec![
256
+
Url::from_str("http://127.0.0.1/callback").unwrap(),
257
+
Url::from_str("http://[::1]/callback").unwrap(),
258
+
]),
259
+
Some(
260
+
vec![
261
+
Scope::Atproto,
262
+
Scope::Transition(TransitionScope::Generic),
263
+
Scope::parse("account:email").unwrap()
264
+
]
265
+
)
266
+
), &None)
267
+
.expect("failed to convert metadata"),
268
+
OAuthClientMetadata {
269
+
client_id: Url::from_str(
270
+
"http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric"
271
+
).unwrap(),
272
+
client_uri: None,
273
+
redirect_uris: vec![
274
+
Url::from_str("http://localhost/callback").unwrap(),
275
+
Url::from_str("http://localhost/callback").unwrap(),
276
+
],
277
+
scope: None,
278
+
grant_types: None,
279
+
token_endpoint_auth_method: Some(AuthMethod::None.into()),
280
+
dpop_bound_access_tokens: None,
281
+
jwks_uri: None,
282
+
jwks: None,
283
+
token_endpoint_auth_signing_alg: None,
284
+
}
285
+
);
286
+
}
287
+
288
+
#[test]
289
+
fn test_localhost_client_metadata_invalid() {
290
+
// Invalid inputs are coerced to http://localhost rather than failing
291
+
{
292
+
let out = atproto_client_metadata(
293
+
AtprotoClientMetadata::new_localhost(
294
+
Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]),
295
+
None,
296
+
),
297
+
&None,
298
+
)
299
+
.expect("should coerce to localhost");
300
+
assert_eq!(
301
+
out,
302
+
OAuthClientMetadata {
303
+
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
304
+
client_uri: None,
305
+
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
306
+
scope: None,
307
+
grant_types: None,
308
+
token_endpoint_auth_method: Some(AuthMethod::None.into()),
309
+
dpop_bound_access_tokens: None,
310
+
jwks_uri: None,
311
+
jwks: None,
312
+
token_endpoint_auth_signing_alg: None,
313
+
}
314
+
);
315
+
}
316
+
{
317
+
let out = atproto_client_metadata(
318
+
AtprotoClientMetadata::new_localhost(
319
+
Some(vec![Url::from_str("http://localhost:8000/").unwrap()]),
320
+
None,
321
+
),
322
+
&None,
323
+
)
324
+
.expect("should coerce to localhost");
325
+
assert_eq!(
326
+
out,
327
+
OAuthClientMetadata {
328
+
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
329
+
client_uri: None,
330
+
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
331
+
scope: None,
332
+
grant_types: None,
333
+
token_endpoint_auth_method: Some(AuthMethod::None.into()),
334
+
dpop_bound_access_tokens: None,
335
+
jwks_uri: None,
336
+
jwks: None,
337
+
token_endpoint_auth_signing_alg: None,
338
+
}
339
+
);
340
+
}
341
+
{
342
+
let out = atproto_client_metadata(
343
+
AtprotoClientMetadata::new_localhost(
344
+
Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]),
345
+
None,
346
+
),
347
+
&None,
348
+
)
349
+
.expect("should coerce to localhost");
350
+
assert_eq!(
351
+
out,
352
+
OAuthClientMetadata {
353
+
client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(),
354
+
client_uri: None,
355
+
redirect_uris: vec![Url::from_str("http://localhost/").unwrap()],
356
+
scope: None,
357
+
grant_types: None,
358
+
token_endpoint_auth_method: Some(AuthMethod::None.into()),
359
+
dpop_bound_access_tokens: None,
360
+
jwks_uri: None,
361
+
jwks: None,
362
+
token_endpoint_auth_signing_alg: None,
363
+
}
364
+
);
365
+
}
366
+
}
367
+
368
+
#[test]
369
+
fn test_client_metadata() {
370
+
let metadata = AtprotoClientMetadata {
371
+
client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(),
372
+
client_uri: Some(Url::from_str("https://example.com").unwrap()),
373
+
redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()],
374
+
grant_types: vec![GrantType::AuthorizationCode],
375
+
scopes: vec![Scope::Atproto],
376
+
jwks_uri: None,
377
+
};
378
+
{
379
+
// Non-loopback clients without a keyset should fail (must provide JWKS)
380
+
let metadata = metadata.clone();
381
+
let err = atproto_client_metadata(metadata, &None).expect_err("expected to fail");
382
+
assert!(matches!(err, Error::EmptyJwks));
383
+
}
384
+
{
385
+
let metadata = metadata.clone();
386
+
let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY)
387
+
.expect("failed to parse private key");
388
+
let keys = vec![Jwk {
389
+
key: Key::from(&secret_key.into()),
390
+
prm: Parameters {
391
+
kid: Some(String::from("kid00")),
392
+
..Default::default()
393
+
},
394
+
}];
395
+
let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset");
396
+
assert_eq!(
397
+
atproto_client_metadata(metadata, &Some(keyset.clone()))
398
+
.expect("failed to convert metadata"),
399
+
OAuthClientMetadata {
400
+
client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(),
401
+
client_uri: Some(Url::from_str("https://example.com").unwrap()),
402
+
redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()],
403
+
scope: Some(CowStr::new_static("atproto")),
404
+
grant_types: Some(vec![CowStr::new_static("authorization_code")]),
405
+
token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
406
+
dpop_bound_access_tokens: Some(true),
407
+
jwks_uri: None,
408
+
jwks: Some(keyset.public_jwks()),
409
+
token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
410
+
}
411
+
);
412
+
}
413
+
}
414
+
}
+138
crates/jacquard-oauth/src/authstore.rs
+138
crates/jacquard-oauth/src/authstore.rs
···
1
+
use std::sync::Arc;
2
+
3
+
use dashmap::DashMap;
4
+
use jacquard_common::{
5
+
IntoStatic,
6
+
session::{SessionStore, SessionStoreError},
7
+
types::did::Did,
8
+
};
9
+
use smol_str::{SmolStr, ToSmolStr, format_smolstr};
10
+
11
+
use crate::session::{AuthRequestData, ClientSessionData};
12
+
13
+
#[async_trait::async_trait]
14
+
pub trait ClientAuthStore {
15
+
async fn get_session(
16
+
&self,
17
+
did: &Did<'_>,
18
+
session_id: &str,
19
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>;
20
+
21
+
async fn upsert_session(&self, session: ClientSessionData<'_>)
22
+
-> Result<(), SessionStoreError>;
23
+
24
+
async fn delete_session(
25
+
&self,
26
+
did: &Did<'_>,
27
+
session_id: &str,
28
+
) -> Result<(), SessionStoreError>;
29
+
30
+
async fn get_auth_req_info(
31
+
&self,
32
+
state: &str,
33
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>;
34
+
35
+
async fn save_auth_req_info(
36
+
&self,
37
+
auth_req_info: &AuthRequestData<'_>,
38
+
) -> Result<(), SessionStoreError>;
39
+
40
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>;
41
+
}
42
+
43
+
pub struct MemoryAuthStore {
44
+
sessions: DashMap<SmolStr, ClientSessionData<'static>>,
45
+
auth_reqs: DashMap<SmolStr, AuthRequestData<'static>>,
46
+
}
47
+
48
+
impl MemoryAuthStore {
49
+
pub fn new() -> Self {
50
+
Self {
51
+
sessions: DashMap::new(),
52
+
auth_reqs: DashMap::new(),
53
+
}
54
+
}
55
+
}
56
+
57
+
#[async_trait::async_trait]
58
+
impl ClientAuthStore for MemoryAuthStore {
59
+
async fn get_session(
60
+
&self,
61
+
did: &Did<'_>,
62
+
session_id: &str,
63
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> {
64
+
let key = format_smolstr!("{}_{}", did, session_id);
65
+
Ok(self.sessions.get(&key).map(|v| v.clone()))
66
+
}
67
+
68
+
async fn upsert_session(
69
+
&self,
70
+
session: ClientSessionData<'_>,
71
+
) -> Result<(), SessionStoreError> {
72
+
let key = format_smolstr!("{}_{}", session.account_did, session.session_id);
73
+
self.sessions.insert(key, session.into_static());
74
+
Ok(())
75
+
}
76
+
77
+
async fn delete_session(
78
+
&self,
79
+
did: &Did<'_>,
80
+
session_id: &str,
81
+
) -> Result<(), SessionStoreError> {
82
+
let key = format_smolstr!("{}_{}", did, session_id);
83
+
self.sessions.remove(&key);
84
+
Ok(())
85
+
}
86
+
87
+
async fn get_auth_req_info(
88
+
&self,
89
+
state: &str,
90
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> {
91
+
Ok(self.auth_reqs.get(state).map(|v| v.clone()))
92
+
}
93
+
94
+
async fn save_auth_req_info(
95
+
&self,
96
+
auth_req_info: &AuthRequestData<'_>,
97
+
) -> Result<(), SessionStoreError> {
98
+
self.auth_reqs.insert(
99
+
auth_req_info.state.clone().to_smolstr(),
100
+
auth_req_info.clone().into_static(),
101
+
);
102
+
Ok(())
103
+
}
104
+
105
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> {
106
+
self.auth_reqs.remove(state);
107
+
Ok(())
108
+
}
109
+
}
110
+
111
+
#[async_trait::async_trait]
112
+
impl<T: ClientAuthStore + Send + Sync>
113
+
SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T>
114
+
{
115
+
/// Get the current session if present.
116
+
async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> {
117
+
let (did, session_id) = key;
118
+
self.as_ref()
119
+
.get_session(did, session_id)
120
+
.await
121
+
.ok()
122
+
.flatten()
123
+
.into_static()
124
+
}
125
+
/// Persist the given session.
126
+
async fn set(
127
+
&self,
128
+
_key: (Did<'static>, SmolStr),
129
+
session: ClientSessionData<'static>,
130
+
) -> Result<(), SessionStoreError> {
131
+
self.as_ref().upsert_session(session).await
132
+
}
133
+
/// Delete the given session.
134
+
async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> {
135
+
let (did, session_id) = key;
136
+
self.as_ref().delete_session(did, session_id).await
137
+
}
138
+
}
+358
crates/jacquard-oauth/src/client.rs
+358
crates/jacquard-oauth/src/client.rs
···
1
+
use crate::{
2
+
atproto::atproto_client_metadata,
3
+
authstore::ClientAuthStore,
4
+
dpop::DpopExt,
5
+
error::{CallbackError, Result},
6
+
request::{OAuthMetadata, exchange_code, par},
7
+
resolver::OAuthResolver,
8
+
scopes::Scope,
9
+
session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
10
+
types::{AuthorizeOptions, CallbackParams},
11
+
};
12
+
use jacquard_common::{
13
+
AuthorizationToken, CowStr, IntoStatic,
14
+
error::{AuthError, ClientError, TransportError, XrpcResult},
15
+
http_client::HttpClient,
16
+
types::{
17
+
did::Did,
18
+
xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest},
19
+
},
20
+
};
21
+
use jacquard_identity::JacquardResolver;
22
+
use jose_jwk::JwkSet;
23
+
use std::sync::Arc;
24
+
use tokio::sync::RwLock;
25
+
use url::Url;
26
+
27
+
pub struct OAuthClient<T, S>
28
+
where
29
+
T: OAuthResolver,
30
+
S: ClientAuthStore,
31
+
{
32
+
pub registry: Arc<SessionRegistry<T, S>>,
33
+
pub client: Arc<T>,
34
+
}
35
+
36
+
impl<S: ClientAuthStore> OAuthClient<JacquardResolver, S> {
37
+
pub fn new(store: S, client_data: ClientData<'static>) -> Self {
38
+
let client = JacquardResolver::default();
39
+
Self::new_from_resolver(store, client, client_data)
40
+
}
41
+
}
42
+
43
+
impl<T, S> OAuthClient<T, S>
44
+
where
45
+
T: OAuthResolver,
46
+
S: ClientAuthStore,
47
+
{
48
+
pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self {
49
+
let client = Arc::new(client);
50
+
let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data));
51
+
Self { registry, client }
52
+
}
53
+
}
54
+
55
+
impl<T, S> OAuthClient<T, S>
56
+
where
57
+
S: ClientAuthStore + Send + Sync + 'static,
58
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
59
+
{
60
+
pub fn jwks(&self) -> JwkSet {
61
+
self.registry
62
+
.client_data
63
+
.keyset
64
+
.as_ref()
65
+
.map(|keyset| keyset.public_jwks())
66
+
.unwrap_or_default()
67
+
}
68
+
pub async fn start_auth(
69
+
&self,
70
+
input: impl AsRef<str>,
71
+
options: AuthorizeOptions<'_>,
72
+
) -> Result<String> {
73
+
let client_metadata = atproto_client_metadata(
74
+
self.registry.client_data.config.clone(),
75
+
&self.registry.client_data.keyset,
76
+
)?;
77
+
78
+
let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?;
79
+
let login_hint = if identity.is_some() {
80
+
Some(input.as_ref().into())
81
+
} else {
82
+
None
83
+
};
84
+
let metadata = OAuthMetadata {
85
+
server_metadata,
86
+
client_metadata,
87
+
keyset: self.registry.client_data.keyset.clone(),
88
+
};
89
+
let auth_req_info =
90
+
par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?;
91
+
92
+
#[derive(serde::Serialize)]
93
+
struct Parameters<'s> {
94
+
client_id: Url,
95
+
request_uri: CowStr<'s>,
96
+
}
97
+
Ok(metadata.server_metadata.authorization_endpoint.to_string()
98
+
+ "?"
99
+
+ &serde_html_form::to_string(Parameters {
100
+
client_id: metadata.client_metadata.client_id.clone(),
101
+
request_uri: auth_req_info.request_uri,
102
+
})
103
+
.unwrap())
104
+
}
105
+
106
+
pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> {
107
+
let Some(state_key) = params.state else {
108
+
return Err(CallbackError::MissingState.into());
109
+
};
110
+
111
+
let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else {
112
+
return Err(CallbackError::MissingState.into());
113
+
};
114
+
115
+
self.registry.store.delete_auth_req_info(&state_key).await?;
116
+
117
+
let metadata = self
118
+
.client
119
+
.get_authorization_server_metadata(&auth_req_info.authserver_url)
120
+
.await?;
121
+
122
+
if let Some(iss) = params.iss {
123
+
if !crate::resolver::issuer_equivalent(&iss, &metadata.issuer) {
124
+
return Err(CallbackError::IssuerMismatch { expected: metadata.issuer.to_string(), got: iss.to_string() }.into());
125
+
}
126
+
} else if metadata.authorization_response_iss_parameter_supported == Some(true) {
127
+
return Err(CallbackError::MissingIssuer.into());
128
+
}
129
+
let metadata = OAuthMetadata {
130
+
server_metadata: metadata,
131
+
client_metadata: atproto_client_metadata(
132
+
self.registry.client_data.config.clone(),
133
+
&self.registry.client_data.keyset,
134
+
)?,
135
+
keyset: self.registry.client_data.keyset.clone(),
136
+
};
137
+
let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone();
138
+
139
+
match exchange_code(
140
+
self.client.as_ref(),
141
+
&mut auth_req_info.dpop_data.clone(),
142
+
¶ms.code,
143
+
&auth_req_info.pkce_verifier,
144
+
&metadata,
145
+
)
146
+
.await
147
+
{
148
+
Ok(token_set) => {
149
+
let scopes = if let Some(scope) = &token_set.scope {
150
+
Scope::parse_multiple_reduced(&scope)
151
+
.expect("Failed to parse scopes")
152
+
.into_static()
153
+
} else {
154
+
vec![]
155
+
};
156
+
let client_data = ClientSessionData {
157
+
account_did: token_set.sub.clone(),
158
+
session_id: auth_req_info.state,
159
+
host_url: Url::parse(&token_set.iss).expect("Failed to parse host URL"),
160
+
authserver_url: auth_req_info.authserver_url,
161
+
authserver_token_endpoint: auth_req_info.authserver_token_endpoint,
162
+
authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint,
163
+
scopes,
164
+
dpop_data: DpopClientData {
165
+
dpop_key: auth_req_info.dpop_data.dpop_key.clone(),
166
+
dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()),
167
+
dpop_host_nonce: auth_req_info
168
+
.dpop_data
169
+
.dpop_authserver_nonce
170
+
.unwrap_or(CowStr::default()),
171
+
},
172
+
token_set,
173
+
};
174
+
175
+
self.create_session(client_data).await
176
+
}
177
+
Err(e) => Err(e.into()),
178
+
}
179
+
}
180
+
181
+
async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> {
182
+
Ok(OAuthSession::new(
183
+
self.registry.clone(),
184
+
self.client.clone(),
185
+
data.into_static(),
186
+
))
187
+
}
188
+
189
+
pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> {
190
+
self.create_session(self.registry.get(did, session_id, false).await?)
191
+
.await
192
+
}
193
+
194
+
pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
195
+
Ok(self.registry.del(did, session_id).await?)
196
+
}
197
+
}
198
+
199
+
pub struct OAuthSession<T, S>
200
+
where
201
+
T: OAuthResolver,
202
+
S: ClientAuthStore,
203
+
{
204
+
pub registry: Arc<SessionRegistry<T, S>>,
205
+
pub client: Arc<T>,
206
+
pub data: RwLock<ClientSessionData<'static>>,
207
+
pub options: RwLock<CallOptions<'static>>,
208
+
}
209
+
210
+
impl<T, S> OAuthSession<T, S>
211
+
where
212
+
T: OAuthResolver,
213
+
S: ClientAuthStore,
214
+
{
215
+
pub fn new(
216
+
registry: Arc<SessionRegistry<T, S>>,
217
+
client: Arc<T>,
218
+
data: ClientSessionData<'static>,
219
+
) -> Self {
220
+
Self {
221
+
registry,
222
+
client,
223
+
data: RwLock::new(data),
224
+
options: RwLock::new(CallOptions::default()),
225
+
}
226
+
}
227
+
228
+
pub fn with_options(self, options: CallOptions<'_>) -> Self {
229
+
Self {
230
+
registry: self.registry,
231
+
client: self.client,
232
+
data: self.data,
233
+
options: RwLock::new(options.into_static()),
234
+
}
235
+
}
236
+
237
+
pub async fn set_options(&self, options: CallOptions<'_>) {
238
+
*self.options.write().await = options.into_static();
239
+
}
240
+
241
+
pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) {
242
+
let data = self.data.read().await;
243
+
(data.account_did.clone(), data.session_id.clone())
244
+
}
245
+
246
+
pub async fn endpoint(&self) -> Url {
247
+
self.data.read().await.host_url.clone()
248
+
}
249
+
250
+
pub async fn access_token(&self) -> AuthorizationToken<'_> {
251
+
AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone())
252
+
}
253
+
254
+
pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
255
+
self.data.read().await.token_set.refresh_token.as_ref().map(|t| AuthorizationToken::Dpop(t.clone()))
256
+
}
257
+
}
258
+
impl<T, S> OAuthSession<T, S>
259
+
where
260
+
S: ClientAuthStore + Send + Sync + 'static,
261
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
262
+
{
263
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> {
264
+
// Read identifiers without holding the lock across await
265
+
let (did, sid) = {
266
+
let data = self.data.read().await;
267
+
(data.account_did.clone(), data.session_id.clone())
268
+
};
269
+
let refreshed = self
270
+
.registry
271
+
.as_ref()
272
+
.get(&did, &sid, true)
273
+
.await?;
274
+
let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone());
275
+
// Write back updated session
276
+
*self.data.write().await = refreshed.into_static();
277
+
Ok(token)
278
+
}
279
+
}
280
+
281
+
impl<T, S> HttpClient for OAuthSession<T, S>
282
+
where
283
+
S: ClientAuthStore + Send + Sync + 'static,
284
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
285
+
{
286
+
type Error = T::Error;
287
+
288
+
async fn send_http(
289
+
&self,
290
+
request: http::Request<Vec<u8>>,
291
+
) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
292
+
self.client.send_http(request).await
293
+
}
294
+
}
295
+
296
+
impl<T, S> XrpcClient for OAuthSession<T, S>
297
+
where
298
+
S: ClientAuthStore + Send + Sync + 'static,
299
+
T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
300
+
{
301
+
fn base_uri(&self) -> Url {
302
+
// base_uri is a synchronous trait method; we must avoid async `.read().await`.
303
+
// Use `block_in_place` under Tokio to perform a blocking RwLock read safely.
304
+
if tokio::runtime::Handle::try_current().is_ok() {
305
+
tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone())
306
+
} else {
307
+
self.data.blocking_read().host_url.clone()
308
+
}
309
+
}
310
+
311
+
async fn opts(&self) -> CallOptions<'_> {
312
+
self.options.read().await.clone()
313
+
}
314
+
315
+
async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>(
316
+
self,
317
+
request: &R,
318
+
) -> XrpcResult<Response<R>> {
319
+
let base_uri = self.base_uri();
320
+
let auth = self.access_token().await;
321
+
let mut opts = self.options.read().await.clone();
322
+
opts.auth = Some(auth);
323
+
let res = self
324
+
.client
325
+
.xrpc(base_uri.clone())
326
+
.with_options(opts.clone())
327
+
.send(request)
328
+
.await;
329
+
if is_invalid_token_response(&res) {
330
+
opts.auth = Some(
331
+
self.refresh()
332
+
.await
333
+
.map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?,
334
+
);
335
+
self.client
336
+
.xrpc(base_uri)
337
+
.with_options(opts)
338
+
.send(request)
339
+
.await
340
+
} else {
341
+
res
342
+
}
343
+
}
344
+
}
345
+
346
+
fn is_invalid_token_response<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool {
347
+
match response {
348
+
Err(ClientError::Auth(AuthError::InvalidToken)) => true,
349
+
Err(ClientError::Auth(AuthError::Other(value))) => value
350
+
.to_str()
351
+
.is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")),
352
+
Ok(resp) => match resp.parse() {
353
+
Err(jacquard_common::types::xrpc::XrpcError::Auth(AuthError::InvalidToken)) => true,
354
+
_ => false,
355
+
},
356
+
_ => false,
357
+
}
358
+
}
+247
crates/jacquard-oauth/src/dpop.rs
+247
crates/jacquard-oauth/src/dpop.rs
···
1
+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2
+
use chrono::Utc;
3
+
use http::{Request, Response, header::InvalidHeaderValue};
4
+
use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient};
5
+
use jose_jwa::{Algorithm, Signing};
6
+
use jose_jwk::{Jwk, Key, crypto};
7
+
use p256::ecdsa::SigningKey;
8
+
use rand::{RngCore, SeedableRng};
9
+
use sha2::Digest;
10
+
11
+
use crate::{
12
+
jose::{
13
+
create_signed_jwt,
14
+
jws::RegisteredHeader,
15
+
jwt::{Claims, PublicClaims, RegisteredClaims},
16
+
},
17
+
session::DpopDataSource,
18
+
};
19
+
20
+
pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt";
21
+
22
+
#[derive(serde::Deserialize)]
23
+
struct ErrorResponse {
24
+
error: String,
25
+
}
26
+
27
+
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
28
+
pub enum Error {
29
+
#[error(transparent)]
30
+
InvalidHeaderValue(#[from] InvalidHeaderValue),
31
+
#[error("crypto error: {0:?}")]
32
+
JwkCrypto(crypto::Error),
33
+
#[error("key does not match any alg supported by the server")]
34
+
UnsupportedKey,
35
+
#[error(transparent)]
36
+
SerdeJson(#[from] serde_json::Error),
37
+
#[error("Inner: {0}")]
38
+
Inner(#[source] Box<dyn std::error::Error + Send + Sync>),
39
+
}
40
+
41
+
type Result<T> = core::result::Result<T, Error>;
42
+
43
+
#[async_trait::async_trait]
44
+
pub trait DpopClient: HttpClient {
45
+
async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
46
+
async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
47
+
async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>;
48
+
}
49
+
50
+
pub trait DpopExt: HttpClient {
51
+
fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D>
52
+
where
53
+
Self: Sized,
54
+
D: DpopDataSource,
55
+
{
56
+
DpopCall::server(self, data_source)
57
+
}
58
+
59
+
fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N>
60
+
where
61
+
Self: Sized,
62
+
N: DpopDataSource,
63
+
{
64
+
DpopCall::client(self, data_source)
65
+
}
66
+
}
67
+
68
+
pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> {
69
+
pub client: &'r C,
70
+
pub is_to_auth_server: bool,
71
+
pub data_source: &'r mut D,
72
+
}
73
+
74
+
impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> {
75
+
pub fn server(client: &'r C, data_source: &'r mut N) -> Self {
76
+
Self {
77
+
client,
78
+
is_to_auth_server: true,
79
+
data_source,
80
+
}
81
+
}
82
+
83
+
pub fn client(client: &'r C, data_source: &'r mut N) -> Self {
84
+
Self {
85
+
client,
86
+
is_to_auth_server: false,
87
+
data_source,
88
+
}
89
+
}
90
+
91
+
pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> {
92
+
wrap_request_with_dpop(
93
+
self.client,
94
+
self.data_source,
95
+
self.is_to_auth_server,
96
+
request,
97
+
)
98
+
.await
99
+
}
100
+
}
101
+
102
+
pub async fn wrap_request_with_dpop<T, N>(
103
+
client: &T,
104
+
data_source: &mut N,
105
+
is_to_auth_server: bool,
106
+
mut request: Request<Vec<u8>>,
107
+
) -> Result<Response<Vec<u8>>>
108
+
where
109
+
T: HttpClient,
110
+
N: DpopDataSource,
111
+
{
112
+
let uri = request.uri().clone();
113
+
let method = request.method().to_cowstr().into_static();
114
+
let uri = uri.to_cowstr();
115
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
116
+
let ath = request
117
+
.headers()
118
+
.get("Authorization")
119
+
.filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP ")))
120
+
.map(|auth| {
121
+
URL_SAFE_NO_PAD
122
+
.encode(sha2::Sha256::digest(&auth.as_bytes()[5..]))
123
+
.into()
124
+
});
125
+
126
+
let init_nonce = if is_to_auth_server {
127
+
data_source.authserver_nonce()
128
+
} else {
129
+
data_source.host_nonce()
130
+
};
131
+
let init_proof = build_dpop_proof(
132
+
data_source.key(),
133
+
method.clone(),
134
+
uri.clone(),
135
+
init_nonce.clone(),
136
+
ath.clone(),
137
+
)?;
138
+
request.headers_mut().insert("DPoP", init_proof.parse()?);
139
+
let response = client
140
+
.send_http(request.clone())
141
+
.await
142
+
.map_err(|e| Error::Inner(e.into()))?;
143
+
144
+
let next_nonce = response
145
+
.headers()
146
+
.get("DPoP-Nonce")
147
+
.and_then(|v| v.to_str().ok())
148
+
.map(|c| c.to_cowstr());
149
+
match &next_nonce {
150
+
Some(s) if next_nonce != init_nonce => {
151
+
// Store the fresh nonce for future requests
152
+
if is_to_auth_server {
153
+
data_source.set_authserver_nonce(s.clone());
154
+
} else {
155
+
data_source.set_host_nonce(s.clone());
156
+
}
157
+
}
158
+
_ => {
159
+
// No nonce was returned or it is the same as the one we sent. No need to
160
+
// update the nonce store, or retry the request.
161
+
return Ok(response);
162
+
}
163
+
}
164
+
165
+
if !is_use_dpop_nonce_error(is_to_auth_server, &response) {
166
+
return Ok(response);
167
+
}
168
+
let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?;
169
+
request.headers_mut().insert("DPoP", next_proof.parse()?);
170
+
let response = client
171
+
.send_http(request)
172
+
.await
173
+
.map_err(|e| Error::Inner(e.into()))?;
174
+
Ok(response)
175
+
}
176
+
177
+
#[inline]
178
+
fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool {
179
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
180
+
if is_to_auth_server {
181
+
if response.status() == 400 {
182
+
if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) {
183
+
return res.error == "use_dpop_nonce";
184
+
};
185
+
}
186
+
}
187
+
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
188
+
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
189
+
else if response.status() == 401 {
190
+
if let Some(www_auth) = response
191
+
.headers()
192
+
.get("WWW-Authenticate")
193
+
.and_then(|v| v.to_str().ok())
194
+
{
195
+
return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#);
196
+
}
197
+
}
198
+
false
199
+
}
200
+
201
+
#[inline]
202
+
pub(crate) fn generate_jti() -> CowStr<'static> {
203
+
let mut rng = rand::rngs::SmallRng::from_entropy();
204
+
let mut bytes = [0u8; 12];
205
+
rng.fill_bytes(&mut bytes);
206
+
URL_SAFE_NO_PAD.encode(bytes).into()
207
+
}
208
+
209
+
/// Build a compact JWS (ES256) for DPoP with embedded public JWK.
210
+
#[inline]
211
+
pub fn build_dpop_proof<'s>(
212
+
key: &Key,
213
+
method: CowStr<'s>,
214
+
url: CowStr<'s>,
215
+
nonce: Option<CowStr<'s>>,
216
+
ath: Option<CowStr<'s>>,
217
+
) -> Result<CowStr<'s>> {
218
+
let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? {
219
+
crypto::Key::P256(crypto::Kind::Secret(sk)) => sk,
220
+
_ => return Err(Error::UnsupportedKey),
221
+
};
222
+
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
223
+
header.typ = Some(JWT_HEADER_TYP_DPOP.into());
224
+
header.jwk = Some(Jwk {
225
+
key: Key::from(&crypto::Key::from(secret.public_key())),
226
+
prm: Default::default(),
227
+
});
228
+
229
+
let claims = Claims {
230
+
registered: RegisteredClaims {
231
+
jti: Some(generate_jti()),
232
+
iat: Some(Utc::now().timestamp()),
233
+
..Default::default()
234
+
},
235
+
public: PublicClaims {
236
+
htm: Some(method),
237
+
htu: Some(url),
238
+
ath: ath,
239
+
nonce: nonce,
240
+
},
241
+
};
242
+
Ok(create_signed_jwt(
243
+
SigningKey::from(secret.clone()),
244
+
header.into(),
245
+
claims,
246
+
)?)
247
+
}
+69
crates/jacquard-oauth/src/error.rs
+69
crates/jacquard-oauth/src/error.rs
···
1
+
use jacquard_common::session::SessionStoreError;
2
+
use miette::Diagnostic;
3
+
4
+
use crate::request::RequestError;
5
+
use crate::resolver::ResolverError;
6
+
7
+
/// High-level errors emitted by OAuth helpers.
8
+
#[derive(Debug, thiserror::Error, Diagnostic)]
9
+
pub enum OAuthError {
10
+
#[error(transparent)]
11
+
#[diagnostic(code(jacquard_oauth::resolver))]
12
+
Resolver(#[from] ResolverError),
13
+
14
+
#[error(transparent)]
15
+
#[diagnostic(code(jacquard_oauth::request))]
16
+
Request(#[from] RequestError),
17
+
18
+
#[error(transparent)]
19
+
#[diagnostic(code(jacquard_oauth::storage))]
20
+
Storage(#[from] SessionStoreError),
21
+
22
+
#[error(transparent)]
23
+
#[diagnostic(code(jacquard_oauth::dpop))]
24
+
Dpop(#[from] crate::dpop::Error),
25
+
26
+
#[error(transparent)]
27
+
#[diagnostic(code(jacquard_oauth::keyset))]
28
+
Keyset(#[from] crate::keyset::Error),
29
+
30
+
#[error(transparent)]
31
+
#[diagnostic(code(jacquard_oauth::atproto))]
32
+
Atproto(#[from] crate::atproto::Error),
33
+
34
+
#[error(transparent)]
35
+
#[diagnostic(code(jacquard_oauth::session))]
36
+
Session(#[from] crate::session::Error),
37
+
38
+
#[error(transparent)]
39
+
#[diagnostic(code(jacquard_oauth::serde_json))]
40
+
SerdeJson(#[from] serde_json::Error),
41
+
42
+
#[error(transparent)]
43
+
#[diagnostic(code(jacquard_oauth::url))]
44
+
Url(#[from] url::ParseError),
45
+
46
+
#[error(transparent)]
47
+
#[diagnostic(code(jacquard_oauth::form))]
48
+
Form(#[from] serde_html_form::ser::Error),
49
+
50
+
#[error(transparent)]
51
+
#[diagnostic(code(jacquard_oauth::callback))]
52
+
Callback(#[from] CallbackError),
53
+
}
54
+
55
+
/// Typed callback validation errors (redirect handling).
56
+
#[derive(Debug, thiserror::Error, Diagnostic)]
57
+
pub enum CallbackError {
58
+
#[error("missing state parameter in callback")]
59
+
#[diagnostic(code(jacquard_oauth::callback::missing_state))]
60
+
MissingState,
61
+
#[error("missing `iss` parameter")]
62
+
#[diagnostic(code(jacquard_oauth::callback::missing_iss))]
63
+
MissingIssuer,
64
+
#[error("issuer mismatch: expected {expected}, got {got}")]
65
+
#[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))]
66
+
IssuerMismatch { expected: String, got: String },
67
+
}
68
+
69
+
pub type Result<T> = core::result::Result<T, OAuthError>;
+85
crates/jacquard-oauth/src/jose/jws.rs
+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
+
}
+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;
+127
crates/jacquard-oauth/src/keyset.rs
+127
crates/jacquard-oauth/src/keyset.rs
···
1
+
use crate::jose::create_signed_jwt;
2
+
use crate::jose::jws::RegisteredHeader;
3
+
use crate::jose::jwt::Claims;
4
+
use jacquard_common::{CowStr, IntoStatic};
5
+
use jose_jwa::{Algorithm, Signing};
6
+
use jose_jwk::{Class, EcCurves, crypto};
7
+
use jose_jwk::{Jwk, JwkSet, Key};
8
+
use std::collections::HashSet;
9
+
use thiserror::Error;
10
+
11
+
#[derive(Error, Debug)]
12
+
pub enum Error {
13
+
#[error("duplicate kid: {0}")]
14
+
DuplicateKid(String),
15
+
#[error("keys must not be empty")]
16
+
EmptyKeys,
17
+
#[error("key must have a `kid`")]
18
+
EmptyKid,
19
+
#[error("no signing key found for algorithms: {0:?}")]
20
+
NotFound(Vec<CowStr<'static>>),
21
+
#[error("key for signing must be a secret key")]
22
+
PublicKey,
23
+
#[error("crypto error: {0:?}")]
24
+
JwkCrypto(crypto::Error),
25
+
#[error(transparent)]
26
+
SerdeJson(#[from] serde_json::Error),
27
+
}
28
+
29
+
pub type Result<T> = core::result::Result<T, Error>;
30
+
31
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
32
+
pub struct Keyset(Vec<Jwk>);
33
+
34
+
impl Keyset {
35
+
const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [
36
+
"EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512",
37
+
];
38
+
pub fn public_jwks(&self) -> JwkSet {
39
+
let mut keys = Vec::with_capacity(self.0.len());
40
+
for mut key in self.0.clone() {
41
+
match key.key {
42
+
Key::Ec(ref mut ec) => {
43
+
ec.d = None;
44
+
}
45
+
_ => unimplemented!(),
46
+
}
47
+
keys.push(key);
48
+
}
49
+
JwkSet { keys }
50
+
}
51
+
pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> {
52
+
let Some(jwk) = self.find_key(algs, Class::Signing) else {
53
+
return Err(Error::NotFound(algs.to_vec().into_static()));
54
+
};
55
+
self.create_jwt_with_key(jwk, claims)
56
+
}
57
+
fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> {
58
+
let candidates = self
59
+
.0
60
+
.iter()
61
+
.filter_map(|key| {
62
+
if key.prm.cls.is_some_and(|c| c != cls) {
63
+
return None;
64
+
}
65
+
let alg = match &key.key {
66
+
Key::Ec(ec) => match ec.crv {
67
+
EcCurves::P256 => "ES256",
68
+
_ => unimplemented!(),
69
+
},
70
+
_ => unimplemented!(),
71
+
};
72
+
Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg)))
73
+
})
74
+
.collect::<Vec<_>>();
75
+
for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
76
+
for (alg, key) in &candidates {
77
+
if alg == &pref_alg {
78
+
return Some(key);
79
+
}
80
+
}
81
+
}
82
+
None
83
+
}
84
+
fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> {
85
+
let kid = key.prm.kid.clone().unwrap();
86
+
match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
87
+
crypto::Key::P256(crypto::Kind::Secret(secret_key)) => {
88
+
let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
89
+
header.kid = Some(kid.into());
90
+
Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?)
91
+
}
92
+
_ => unimplemented!(),
93
+
}
94
+
}
95
+
}
96
+
97
+
impl TryFrom<Vec<Jwk>> for Keyset {
98
+
type Error = Error;
99
+
100
+
fn try_from(keys: Vec<Jwk>) -> Result<Self> {
101
+
if keys.is_empty() {
102
+
return Err(Error::EmptyKeys);
103
+
}
104
+
let mut v = Vec::with_capacity(keys.len());
105
+
let mut hs = HashSet::with_capacity(keys.len());
106
+
for key in keys {
107
+
if let Some(kid) = key.prm.kid.clone() {
108
+
if hs.contains(&kid) {
109
+
return Err(Error::DuplicateKid(kid));
110
+
}
111
+
hs.insert(kid);
112
+
// ensure that the key is a secret key
113
+
if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
114
+
crypto::Key::P256(crypto::Kind::Public(_)) => true,
115
+
crypto::Key::P256(crypto::Kind::Secret(_)) => false,
116
+
_ => unimplemented!(),
117
+
} {
118
+
return Err(Error::PublicKey);
119
+
}
120
+
v.push(key);
121
+
} else {
122
+
return Err(Error::EmptyKid);
123
+
}
124
+
}
125
+
Ok(Self(v))
126
+
}
127
+
}
+18
crates/jacquard-oauth/src/lib.rs
+18
crates/jacquard-oauth/src/lib.rs
···
1
+
//! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard.
2
+
//! Transport, discovery, and orchestration live in `jacquard`.
3
+
4
+
pub mod atproto;
5
+
pub mod authstore;
6
+
pub mod client;
7
+
pub mod dpop;
8
+
pub mod error;
9
+
pub mod jose;
10
+
pub mod keyset;
11
+
pub mod request;
12
+
pub mod resolver;
13
+
pub mod scopes;
14
+
pub mod session;
15
+
pub mod types;
16
+
pub mod utils;
17
+
18
+
pub const FALLBACK_ALG: &str = "ES256";
+736
crates/jacquard-oauth/src/request.rs
+736
crates/jacquard-oauth/src/request.rs
···
1
+
use chrono::{TimeDelta, Utc};
2
+
use http::{Method, Request, StatusCode};
3
+
use jacquard_common::{
4
+
CowStr, IntoStatic,
5
+
cowstr::ToCowStr,
6
+
http_client::HttpClient,
7
+
session::SessionStoreError,
8
+
types::{
9
+
did::Did,
10
+
string::{AtStrError, Datetime},
11
+
},
12
+
};
13
+
use jacquard_identity::resolver::IdentityError;
14
+
use serde::Serialize;
15
+
use serde_json::Value;
16
+
use smol_str::ToSmolStr;
17
+
use thiserror::Error;
18
+
19
+
use crate::{
20
+
FALLBACK_ALG,
21
+
atproto::atproto_client_metadata,
22
+
dpop::DpopExt,
23
+
jose::jwt::{RegisteredClaims, RegisteredClaimsAud},
24
+
keyset::Keyset,
25
+
resolver::OAuthResolver,
26
+
scopes::Scope,
27
+
session::{
28
+
AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData,
29
+
},
30
+
types::{
31
+
AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt,
32
+
OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse,
33
+
OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters,
34
+
TokenGrantType, TokenRequestParameters, TokenSet,
35
+
},
36
+
utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce},
37
+
};
38
+
39
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
40
+
const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
41
+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
42
+
43
+
#[derive(Error, Debug, miette::Diagnostic)]
44
+
pub enum RequestError {
45
+
#[error("no {0} endpoint available")]
46
+
#[diagnostic(
47
+
code(jacquard_oauth::request::no_endpoint),
48
+
help("server does not advertise this endpoint")
49
+
)]
50
+
NoEndpoint(CowStr<'static>),
51
+
#[error("token response verification failed")]
52
+
#[diagnostic(code(jacquard_oauth::request::token_verification))]
53
+
TokenVerification,
54
+
#[error("unsupported authentication method")]
55
+
#[diagnostic(
56
+
code(jacquard_oauth::request::unsupported_auth_method),
57
+
help(
58
+
"server must support `private_key_jwt` or `none`; configure client metadata accordingly"
59
+
)
60
+
)]
61
+
UnsupportedAuthMethod,
62
+
#[error("no refresh token available")]
63
+
#[diagnostic(code(jacquard_oauth::request::no_refresh_token))]
64
+
NoRefreshToken,
65
+
#[error("failed to parse DID: {0}")]
66
+
#[diagnostic(code(jacquard_oauth::request::invalid_did))]
67
+
InvalidDid(#[from] AtStrError),
68
+
#[error(transparent)]
69
+
#[diagnostic(code(jacquard_oauth::request::dpop))]
70
+
DpopClient(#[from] crate::dpop::Error),
71
+
#[error(transparent)]
72
+
#[diagnostic(code(jacquard_oauth::request::storage))]
73
+
Storage(#[from] SessionStoreError),
74
+
75
+
#[error(transparent)]
76
+
#[diagnostic(code(jacquard_oauth::request::resolver))]
77
+
ResolverError(#[from] crate::resolver::ResolverError),
78
+
// #[error(transparent)]
79
+
// OAuthSession(#[from] crate::oauth_session::Error),
80
+
#[error(transparent)]
81
+
#[diagnostic(code(jacquard_oauth::request::http_build))]
82
+
Http(#[from] http::Error),
83
+
#[error("http status: {0}")]
84
+
#[diagnostic(
85
+
code(jacquard_oauth::request::http_status),
86
+
help("see server response for details")
87
+
)]
88
+
HttpStatus(StatusCode),
89
+
#[error("http status: {0}, body: {1:?}")]
90
+
#[diagnostic(
91
+
code(jacquard_oauth::request::http_status_body),
92
+
help("server returned error JSON; inspect fields like `error`, `error_description`")
93
+
)]
94
+
HttpStatusWithBody(StatusCode, Value),
95
+
#[error(transparent)]
96
+
#[diagnostic(code(jacquard_oauth::request::identity))]
97
+
Identity(#[from] IdentityError),
98
+
#[error(transparent)]
99
+
#[diagnostic(code(jacquard_oauth::request::keyset))]
100
+
Keyset(#[from] crate::keyset::Error),
101
+
#[error(transparent)]
102
+
#[diagnostic(code(jacquard_oauth::request::serde_form))]
103
+
SerdeHtmlForm(#[from] serde_html_form::ser::Error),
104
+
#[error(transparent)]
105
+
#[diagnostic(code(jacquard_oauth::request::serde_json))]
106
+
SerdeJson(#[from] serde_json::Error),
107
+
#[error(transparent)]
108
+
#[diagnostic(code(jacquard_oauth::request::atproto))]
109
+
Atproto(#[from] crate::atproto::Error),
110
+
}
111
+
112
+
pub type Result<T> = core::result::Result<T, RequestError>;
113
+
114
+
#[allow(dead_code)]
115
+
pub enum OAuthRequest<'a> {
116
+
Token(TokenRequestParameters<'a>),
117
+
Refresh(RefreshRequestParameters<'a>),
118
+
Revocation(RevocationRequestParameters<'a>),
119
+
Introspection,
120
+
PushedAuthorizationRequest(ParParameters<'a>),
121
+
}
122
+
123
+
impl OAuthRequest<'_> {
124
+
pub fn name(&self) -> CowStr<'static> {
125
+
CowStr::new_static(match self {
126
+
Self::Token(_) => "token",
127
+
Self::Refresh(_) => "refresh",
128
+
Self::Revocation(_) => "revocation",
129
+
Self::Introspection => "introspection",
130
+
Self::PushedAuthorizationRequest(_) => "pushed_authorization_request",
131
+
})
132
+
}
133
+
pub fn expected_status(&self) -> StatusCode {
134
+
match self {
135
+
Self::Token(_) | Self::Refresh(_) => StatusCode::OK,
136
+
Self::PushedAuthorizationRequest(_) => StatusCode::CREATED,
137
+
// Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`.
138
+
Self::Revocation(_) => StatusCode::NO_CONTENT,
139
+
_ => unimplemented!(),
140
+
}
141
+
}
142
+
}
143
+
144
+
#[cfg(test)]
145
+
mod tests {
146
+
use super::*;
147
+
use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
148
+
use bytes::Bytes;
149
+
use http::{Response as HttpResponse, StatusCode};
150
+
use jacquard_common::http_client::HttpClient;
151
+
use jacquard_identity::resolver::IdentityResolver;
152
+
use std::sync::Arc;
153
+
use tokio::sync::Mutex;
154
+
155
+
#[derive(Clone, Default)]
156
+
struct MockClient {
157
+
resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
158
+
}
159
+
160
+
impl HttpClient for MockClient {
161
+
type Error = std::convert::Infallible;
162
+
fn send_http(
163
+
&self,
164
+
_request: http::Request<Vec<u8>>,
165
+
) -> impl core::future::Future<
166
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
167
+
> + Send {
168
+
let resp = self.resp.clone();
169
+
async move { Ok(resp.lock().await.take().unwrap()) }
170
+
}
171
+
}
172
+
173
+
// IdentityResolver methods won't be called in these tests; provide stubs.
174
+
#[async_trait::async_trait]
175
+
impl IdentityResolver for MockClient {
176
+
fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
177
+
use std::sync::LazyLock;
178
+
static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
179
+
LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
180
+
&OPTS
181
+
}
182
+
async fn resolve_handle(
183
+
&self,
184
+
_handle: &jacquard_common::types::string::Handle<'_>,
185
+
) -> std::result::Result<
186
+
jacquard_common::types::string::Did<'static>,
187
+
jacquard_identity::resolver::IdentityError,
188
+
> {
189
+
Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap())
190
+
}
191
+
async fn resolve_did_doc(
192
+
&self,
193
+
_did: &jacquard_common::types::string::Did<'_>,
194
+
) -> std::result::Result<
195
+
jacquard_identity::resolver::DidDocResponse,
196
+
jacquard_identity::resolver::IdentityError,
197
+
> {
198
+
let doc = serde_json::json!({
199
+
"id": "did:plc:alice",
200
+
"service": [{
201
+
"id": "#pds",
202
+
"type": "AtprotoPersonalDataServer",
203
+
"serviceEndpoint": "https://pds"
204
+
}]
205
+
});
206
+
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
207
+
Ok(jacquard_identity::resolver::DidDocResponse {
208
+
buffer: buf,
209
+
status: StatusCode::OK,
210
+
requested: None,
211
+
})
212
+
}
213
+
}
214
+
215
+
// Allow using DPoP helpers on MockClient
216
+
impl crate::dpop::DpopExt for MockClient {}
217
+
impl crate::resolver::OAuthResolver for MockClient {}
218
+
219
+
fn base_metadata() -> OAuthMetadata {
220
+
let mut server = OAuthAuthorizationServerMetadata::default();
221
+
server.issuer = CowStr::from("https://issuer");
222
+
server.authorization_endpoint = CowStr::from("https://issuer/authorize");
223
+
server.token_endpoint = CowStr::from("https://issuer/token");
224
+
OAuthMetadata {
225
+
server_metadata: server,
226
+
client_metadata: OAuthClientMetadata {
227
+
client_id: url::Url::parse("https://client").unwrap(),
228
+
client_uri: None,
229
+
redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()],
230
+
scope: Some(CowStr::from("atproto")),
231
+
grant_types: None,
232
+
token_endpoint_auth_method: Some(CowStr::from("none")),
233
+
dpop_bound_access_tokens: None,
234
+
jwks_uri: None,
235
+
jwks: None,
236
+
token_endpoint_auth_signing_alg: None,
237
+
},
238
+
keyset: None,
239
+
}
240
+
}
241
+
242
+
#[tokio::test]
243
+
async fn par_missing_endpoint() {
244
+
let mut meta = base_metadata();
245
+
meta.server_metadata.require_pushed_authorization_requests = Some(true);
246
+
meta.server_metadata.pushed_authorization_request_endpoint = None;
247
+
// require_pushed_authorization_requests is true and no endpoint
248
+
let err = super::par(&MockClient::default(), None, None, &meta)
249
+
.await
250
+
.unwrap_err();
251
+
match err {
252
+
RequestError::NoEndpoint(name) => {
253
+
assert_eq!(name.as_ref(), "pushed_authorization_request");
254
+
}
255
+
other => panic!("unexpected: {other:?}"),
256
+
}
257
+
}
258
+
259
+
#[tokio::test]
260
+
async fn refresh_no_refresh_token() {
261
+
let client = MockClient::default();
262
+
let meta = base_metadata();
263
+
let session = ClientSessionData {
264
+
account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
265
+
session_id: CowStr::from("state"),
266
+
host_url: url::Url::parse("https://pds").unwrap(),
267
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
268
+
authserver_token_endpoint: CowStr::from("https://issuer/token"),
269
+
authserver_revocation_endpoint: None,
270
+
scopes: vec![],
271
+
dpop_data: DpopClientData {
272
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
273
+
dpop_authserver_nonce: CowStr::from(""),
274
+
dpop_host_nonce: CowStr::from(""),
275
+
},
276
+
token_set: crate::types::TokenSet {
277
+
iss: CowStr::from("https://issuer"),
278
+
sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
279
+
aud: CowStr::from("https://pds"),
280
+
scope: None,
281
+
refresh_token: None,
282
+
access_token: CowStr::from("abc"),
283
+
token_type: crate::types::OAuthTokenType::DPoP,
284
+
expires_at: None,
285
+
},
286
+
};
287
+
let err = super::refresh(&client, session, &meta).await.unwrap_err();
288
+
matches!(err, RequestError::NoRefreshToken);
289
+
}
290
+
291
+
#[tokio::test]
292
+
async fn exchange_code_missing_sub() {
293
+
let client = MockClient::default();
294
+
// set mock HTTP response body: token response without `sub`
295
+
*client.resp.lock().await = Some(
296
+
HttpResponse::builder()
297
+
.status(StatusCode::OK)
298
+
.body(
299
+
serde_json::to_vec(&serde_json::json!({
300
+
"access_token":"tok",
301
+
"token_type":"DPoP",
302
+
"expires_in": 3600
303
+
}))
304
+
.unwrap(),
305
+
)
306
+
.unwrap(),
307
+
);
308
+
let meta = base_metadata();
309
+
let mut dpop = DpopReqData {
310
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
311
+
dpop_authserver_nonce: None,
312
+
};
313
+
let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
314
+
.await
315
+
.unwrap_err();
316
+
matches!(err, RequestError::TokenVerification);
317
+
}
318
+
}
319
+
320
+
#[derive(Debug, Serialize)]
321
+
pub struct RequestPayload<'a, T>
322
+
where
323
+
T: Serialize,
324
+
{
325
+
client_id: CowStr<'a>,
326
+
#[serde(skip_serializing_if = "Option::is_none")]
327
+
client_assertion_type: Option<CowStr<'a>>,
328
+
#[serde(skip_serializing_if = "Option::is_none")]
329
+
client_assertion: Option<CowStr<'a>>,
330
+
#[serde(flatten)]
331
+
parameters: T,
332
+
}
333
+
334
+
#[derive(Debug, Clone)]
335
+
pub struct OAuthMetadata {
336
+
pub server_metadata: OAuthAuthorizationServerMetadata<'static>,
337
+
pub client_metadata: OAuthClientMetadata<'static>,
338
+
pub keyset: Option<Keyset>,
339
+
}
340
+
341
+
impl OAuthMetadata {
342
+
pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>(
343
+
client: &T,
344
+
ClientData { keyset, config }: &ClientData<'r>,
345
+
session_data: &ClientSessionData<'r>,
346
+
) -> Result<Self> {
347
+
Ok(OAuthMetadata {
348
+
server_metadata: client
349
+
.get_authorization_server_metadata(&session_data.authserver_url)
350
+
.await?,
351
+
client_metadata: atproto_client_metadata(config.clone(), &keyset)
352
+
.unwrap()
353
+
.into_static(),
354
+
keyset: keyset.clone(),
355
+
})
356
+
}
357
+
}
358
+
359
+
pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>(
360
+
client: &T,
361
+
login_hint: Option<CowStr<'r>>,
362
+
prompt: Option<AuthorizeOptionPrompt>,
363
+
metadata: &OAuthMetadata,
364
+
) -> crate::request::Result<AuthRequestData<'r>> {
365
+
let state = generate_nonce();
366
+
let (code_challenge, verifier) = generate_pkce();
367
+
368
+
let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else {
369
+
return Err(RequestError::TokenVerification);
370
+
};
371
+
let mut dpop_data = DpopReqData {
372
+
dpop_key,
373
+
dpop_authserver_nonce: None,
374
+
};
375
+
let parameters = ParParameters {
376
+
response_type: AuthorizationResponseType::Code,
377
+
redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(),
378
+
state: state.clone(),
379
+
scope: metadata.client_metadata.scope.clone(),
380
+
response_mode: None,
381
+
code_challenge,
382
+
code_challenge_method: AuthorizationCodeChallengeMethod::S256,
383
+
login_hint: login_hint,
384
+
prompt: prompt.map(CowStr::from),
385
+
};
386
+
if metadata
387
+
.server_metadata
388
+
.pushed_authorization_request_endpoint
389
+
.is_some()
390
+
{
391
+
let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>(
392
+
&client,
393
+
&mut dpop_data,
394
+
OAuthRequest::PushedAuthorizationRequest(parameters),
395
+
metadata,
396
+
)
397
+
.await?;
398
+
399
+
let scopes = if let Some(scope) = &metadata.client_metadata.scope {
400
+
Scope::parse_multiple_reduced(&scope)
401
+
.expect("Failed to parse scopes")
402
+
.into_static()
403
+
} else {
404
+
vec![]
405
+
};
406
+
let auth_req_data = AuthRequestData {
407
+
state,
408
+
authserver_url: url::Url::parse(&metadata.server_metadata.issuer)
409
+
.expect("Failed to parse issuer URL"),
410
+
account_did: None,
411
+
scopes,
412
+
request_uri: par_response.request_uri.to_cowstr().into_static(),
413
+
authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(),
414
+
authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(),
415
+
pkce_verifier: verifier,
416
+
dpop_data,
417
+
};
418
+
419
+
Ok(auth_req_data)
420
+
} else if metadata
421
+
.server_metadata
422
+
.require_pushed_authorization_requests
423
+
== Some(true)
424
+
{
425
+
Err(RequestError::NoEndpoint(CowStr::new_static(
426
+
"pushed_authorization_request",
427
+
)))
428
+
} else {
429
+
todo!("use of PAR is mandatory")
430
+
}
431
+
}
432
+
433
+
pub async fn refresh<'r, T>(
434
+
client: &T,
435
+
mut session_data: ClientSessionData<'r>,
436
+
metadata: &OAuthMetadata,
437
+
) -> Result<ClientSessionData<'r>>
438
+
where
439
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
440
+
{
441
+
let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else {
442
+
return Err(RequestError::NoRefreshToken);
443
+
};
444
+
445
+
// /!\ IMPORTANT /!\
446
+
//
447
+
// The "sub" MUST be a DID, whose issuer authority is indeed the server we
448
+
// are trying to obtain credentials from. Note that we are doing this
449
+
// *before* we actually try to refresh the token:
450
+
// 1) To avoid unnecessary refresh
451
+
// 2) So that the refresh is the last async operation, ensuring as few
452
+
// async operations happen before the result gets a chance to be stored.
453
+
let aud = client
454
+
.verify_issuer(&metadata.server_metadata, &session_data.token_set.sub)
455
+
.await?;
456
+
let iss = metadata.server_metadata.issuer.clone();
457
+
458
+
let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>(
459
+
client,
460
+
&mut session_data.dpop_data,
461
+
OAuthRequest::Refresh(RefreshRequestParameters {
462
+
grant_type: TokenGrantType::RefreshToken,
463
+
refresh_token: refresh_token.clone(),
464
+
scope: None,
465
+
}),
466
+
metadata,
467
+
)
468
+
.await?;
469
+
470
+
let expires_at = response.expires_in.and_then(|expires_in| {
471
+
let now = Datetime::now();
472
+
now.as_ref()
473
+
.checked_add_signed(TimeDelta::seconds(expires_in))
474
+
.map(Datetime::new)
475
+
});
476
+
477
+
session_data.update_with_tokens(TokenSet {
478
+
iss,
479
+
sub: session_data.token_set.sub.clone(),
480
+
aud: CowStr::Owned(aud.to_smolstr()),
481
+
scope: response.scope.map(CowStr::Owned),
482
+
access_token: CowStr::Owned(response.access_token),
483
+
refresh_token: response.refresh_token.map(CowStr::Owned),
484
+
token_type: response.token_type,
485
+
expires_at,
486
+
});
487
+
488
+
Ok(session_data)
489
+
}
490
+
491
+
pub async fn exchange_code<'r, T, D>(
492
+
client: &T,
493
+
data_source: &'r mut D,
494
+
code: &str,
495
+
verifier: &str,
496
+
metadata: &OAuthMetadata,
497
+
) -> Result<TokenSet<'r>>
498
+
where
499
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
500
+
D: DpopDataSource,
501
+
{
502
+
let token_response = oauth_request::<OAuthTokenResponse, T, D>(
503
+
client,
504
+
data_source,
505
+
OAuthRequest::Token(TokenRequestParameters {
506
+
grant_type: TokenGrantType::AuthorizationCode,
507
+
code: code.into(),
508
+
redirect_uri: CowStr::Owned(
509
+
metadata.client_metadata.redirect_uris[0]
510
+
.clone()
511
+
.to_smolstr(),
512
+
), // ?
513
+
code_verifier: verifier.into(),
514
+
}),
515
+
metadata,
516
+
)
517
+
.await?;
518
+
let Some(sub) = token_response.sub else {
519
+
return Err(RequestError::TokenVerification);
520
+
};
521
+
let sub = Did::new_owned(sub)?;
522
+
let iss = metadata.server_metadata.issuer.clone();
523
+
// /!\ IMPORTANT /!\
524
+
//
525
+
// The token_response MUST always be valid before the "sub" it contains
526
+
// can be trusted (see Atproto's OAuth spec for details).
527
+
let aud = client
528
+
.verify_issuer(&metadata.server_metadata, &sub)
529
+
.await?;
530
+
531
+
let expires_at = token_response.expires_in.and_then(|expires_in| {
532
+
Datetime::now()
533
+
.as_ref()
534
+
.checked_add_signed(TimeDelta::seconds(expires_in))
535
+
.map(Datetime::new)
536
+
});
537
+
Ok(TokenSet {
538
+
iss,
539
+
sub,
540
+
aud: CowStr::Owned(aud.to_smolstr()),
541
+
scope: token_response.scope.map(CowStr::Owned),
542
+
access_token: CowStr::Owned(token_response.access_token),
543
+
refresh_token: token_response.refresh_token.map(CowStr::Owned),
544
+
token_type: token_response.token_type,
545
+
expires_at,
546
+
})
547
+
}
548
+
549
+
pub async fn revoke<'r, T, D>(
550
+
client: &T,
551
+
data_source: &'r mut D,
552
+
token: &str,
553
+
metadata: &OAuthMetadata,
554
+
) -> Result<()>
555
+
where
556
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
557
+
D: DpopDataSource,
558
+
{
559
+
oauth_request::<(), T, D>(
560
+
client,
561
+
data_source,
562
+
OAuthRequest::Revocation(RevocationRequestParameters {
563
+
token: token.into(),
564
+
}),
565
+
metadata,
566
+
)
567
+
.await?;
568
+
Ok(())
569
+
}
570
+
571
+
pub async fn oauth_request<'de: 'r, 'r, O, T, D>(
572
+
client: &T,
573
+
data_source: &'r mut D,
574
+
request: OAuthRequest<'r>,
575
+
metadata: &OAuthMetadata,
576
+
) -> Result<O>
577
+
where
578
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
579
+
O: serde::de::DeserializeOwned,
580
+
D: DpopDataSource,
581
+
{
582
+
let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else {
583
+
return Err(RequestError::NoEndpoint(request.name()));
584
+
};
585
+
let client_assertions = build_auth(
586
+
metadata.keyset.as_ref(),
587
+
&metadata.server_metadata,
588
+
&metadata.client_metadata,
589
+
)?;
590
+
let body = match &request {
591
+
OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?,
592
+
OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?,
593
+
OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?,
594
+
OAuthRequest::PushedAuthorizationRequest(params) => {
595
+
build_oauth_req_body(client_assertions, params)?
596
+
}
597
+
_ => unimplemented!(),
598
+
};
599
+
let req = Request::builder()
600
+
.uri(url.to_string())
601
+
.method(Method::POST)
602
+
.header("Content-Type", "application/x-www-form-urlencoded")
603
+
.body(body.into_bytes())?;
604
+
let res = client
605
+
.dpop_server_call(data_source)
606
+
.send(req)
607
+
.await
608
+
.map_err(RequestError::DpopClient)?;
609
+
if res.status() == request.expected_status() {
610
+
let body = res.body();
611
+
if body.is_empty() {
612
+
// since an empty body cannot be deserialized, use โnullโ temporarily to allow deserialization to `()`.
613
+
Ok(serde_json::from_slice(b"null")?)
614
+
} else {
615
+
let output: O = serde_json::from_slice(body)?;
616
+
Ok(output)
617
+
}
618
+
} else if res.status().is_client_error() {
619
+
Err(RequestError::HttpStatusWithBody(
620
+
res.status(),
621
+
serde_json::from_slice(res.body())?,
622
+
))
623
+
} else {
624
+
Err(RequestError::HttpStatus(res.status()))
625
+
}
626
+
}
627
+
628
+
#[inline]
629
+
fn endpoint_for_req<'a, 'r>(
630
+
server_metadata: &'r OAuthAuthorizationServerMetadata<'a>,
631
+
request: &'r OAuthRequest,
632
+
) -> Option<&'r CowStr<'a>> {
633
+
match request {
634
+
OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint),
635
+
OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(),
636
+
OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(),
637
+
OAuthRequest::PushedAuthorizationRequest(_) => server_metadata
638
+
.pushed_authorization_request_endpoint
639
+
.as_ref(),
640
+
}
641
+
}
642
+
643
+
#[inline]
644
+
fn build_oauth_req_body<'a, S>(client_assertions: ClientAuth<'a>, parameters: S) -> Result<String>
645
+
where
646
+
S: Serialize,
647
+
{
648
+
Ok(serde_html_form::to_string(RequestPayload {
649
+
client_id: client_assertions.client_id,
650
+
client_assertion_type: client_assertions.assertion_type,
651
+
client_assertion: client_assertions.assertion,
652
+
parameters,
653
+
})?)
654
+
}
655
+
656
+
#[derive(Debug, Clone, Default)]
657
+
pub struct ClientAuth<'a> {
658
+
client_id: CowStr<'a>,
659
+
assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER`
660
+
assertion: Option<CowStr<'a>>,
661
+
}
662
+
663
+
impl<'s> ClientAuth<'s> {
664
+
pub fn new_id(client_id: CowStr<'s>) -> Self {
665
+
Self {
666
+
client_id,
667
+
assertion_type: None,
668
+
assertion: None,
669
+
}
670
+
}
671
+
}
672
+
673
+
fn build_auth<'a>(
674
+
keyset: Option<&Keyset>,
675
+
server_metadata: &OAuthAuthorizationServerMetadata<'a>,
676
+
client_metadata: &OAuthClientMetadata<'a>,
677
+
) -> Result<ClientAuth<'a>> {
678
+
let method_supported = server_metadata
679
+
.token_endpoint_auth_methods_supported
680
+
.as_ref();
681
+
682
+
let client_id = client_metadata.client_id.to_cowstr().into_static();
683
+
if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() {
684
+
match (*method).as_ref() {
685
+
"private_key_jwt"
686
+
if method_supported
687
+
.as_ref()
688
+
.is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) =>
689
+
{
690
+
if let Some(keyset) = &keyset {
691
+
let mut algs = server_metadata
692
+
.token_endpoint_auth_signing_alg_values_supported
693
+
.clone()
694
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
695
+
algs.sort_by(compare_algos);
696
+
let iat = Utc::now().timestamp();
697
+
return Ok(ClientAuth {
698
+
client_id: client_id.clone(),
699
+
assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)),
700
+
assertion: Some(
701
+
keyset.create_jwt(
702
+
&algs,
703
+
// https://datatracker.ietf.org/doc/html/rfc7523#section-3
704
+
RegisteredClaims {
705
+
iss: Some(client_id.clone()),
706
+
sub: Some(client_id),
707
+
aud: Some(RegisteredClaimsAud::Single(
708
+
server_metadata.issuer.clone(),
709
+
)),
710
+
exp: Some(iat + 60),
711
+
// "iat" is required and **MUST** be less than one minute
712
+
// https://datatracker.ietf.org/doc/html/rfc9101
713
+
iat: Some(iat),
714
+
// atproto oauth-provider requires "jti" to be present
715
+
jti: Some(generate_nonce()),
716
+
..Default::default()
717
+
}
718
+
.into(),
719
+
)?,
720
+
),
721
+
});
722
+
}
723
+
}
724
+
"none"
725
+
if method_supported
726
+
.as_ref()
727
+
.is_some_and(|v| v.contains(&CowStr::new_static("none"))) =>
728
+
{
729
+
return Ok(ClientAuth::new_id(client_id));
730
+
}
731
+
_ => {}
732
+
}
733
+
}
734
+
735
+
Err(RequestError::UnsupportedAuthMethod)
736
+
}
+342
crates/jacquard-oauth/src/resolver.rs
+342
crates/jacquard-oauth/src/resolver.rs
···
1
+
use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata};
2
+
use http::{Request, StatusCode};
3
+
use jacquard_common::{IntoStatic, error::TransportError};
4
+
use jacquard_common::types::did_doc::DidDocument;
5
+
use jacquard_common::types::ident::AtIdentifier;
6
+
use jacquard_common::{http_client::HttpClient, types::did::Did};
7
+
use jacquard_identity::resolver::{IdentityError, IdentityResolver};
8
+
use url::Url;
9
+
10
+
/// Compare two issuer strings strictly but without spuriously failing on trivial differences.
11
+
///
12
+
/// Rules:
13
+
/// - Schemes must match exactly.
14
+
/// - Hostnames and effective ports must match (treat missing port the same as default port).
15
+
/// - Path must match, except that an empty path and `/` are equivalent.
16
+
/// - Query/fragment are not considered; if present on either side, the comparison fails.
17
+
pub(crate) fn issuer_equivalent(a: &str, b: &str) -> bool {
18
+
fn normalize(url: &Url) -> Option<(String, String, u16, String)> {
19
+
if url.query().is_some() || url.fragment().is_some() {
20
+
return None;
21
+
}
22
+
let scheme = url.scheme().to_string();
23
+
let host = url.host_str()?.to_string();
24
+
let port = url.port_or_known_default()?;
25
+
let path = match url.path() {
26
+
"" => "/".to_string(),
27
+
"/" => "/".to_string(),
28
+
other => other.to_string(),
29
+
};
30
+
Some((scheme, host, port, path))
31
+
}
32
+
33
+
match (Url::parse(a), Url::parse(b)) {
34
+
(Ok(ua), Ok(ub)) => match (normalize(&ua), normalize(&ub)) {
35
+
(Some((sa, ha, pa, pa_path)), Some((sb, hb, pb, pb_path))) => {
36
+
if sa != sb || ha != hb || pa != pb {
37
+
return false;
38
+
}
39
+
if pa_path == "/" && pb_path == "/" {
40
+
return true;
41
+
}
42
+
pa_path == pb_path
43
+
}
44
+
_ => false,
45
+
},
46
+
_ => a == b,
47
+
}
48
+
}
49
+
50
+
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
51
+
pub enum ResolverError {
52
+
#[error("resource not found")]
53
+
#[diagnostic(code(jacquard_oauth::resolver::not_found), help("check the base URL or identifier"))]
54
+
NotFound,
55
+
#[error("invalid at identifier: {0}")]
56
+
#[diagnostic(code(jacquard_oauth::resolver::at_identifier), help("ensure a valid handle or DID was provided"))]
57
+
AtIdentifier(String),
58
+
#[error("invalid did: {0}")]
59
+
#[diagnostic(code(jacquard_oauth::resolver::did), help("ensure DID is correctly formed (did:plc or did:web)"))]
60
+
Did(String),
61
+
#[error("invalid did document: {0}")]
62
+
#[diagnostic(code(jacquard_oauth::resolver::did_document), help("verify the DID document structure and service entries"))]
63
+
DidDocument(String),
64
+
#[error("protected resource metadata is invalid: {0}")]
65
+
#[diagnostic(code(jacquard_oauth::resolver::protected_resource_metadata), help("PDS must advertise an authorization server in its protected resource metadata"))]
66
+
ProtectedResourceMetadata(String),
67
+
#[error("authorization server metadata is invalid: {0}")]
68
+
#[diagnostic(code(jacquard_oauth::resolver::authorization_server_metadata), help("issuer must match and include the PDS resource"))]
69
+
AuthorizationServerMetadata(String),
70
+
#[error("error resolving identity: {0}")]
71
+
#[diagnostic(code(jacquard_oauth::resolver::identity))]
72
+
IdentityResolverError(#[from] IdentityError),
73
+
#[error("unsupported did method: {0:?}")]
74
+
#[diagnostic(code(jacquard_oauth::resolver::unsupported_did_method), help("supported DID methods: did:web, did:plc"))]
75
+
UnsupportedDidMethod(Did<'static>),
76
+
#[error(transparent)]
77
+
#[diagnostic(code(jacquard_oauth::resolver::transport))]
78
+
Transport(#[from] TransportError),
79
+
#[error("http status: {0:?}")]
80
+
#[diagnostic(code(jacquard_oauth::resolver::http_status), help("check well-known paths and server configuration"))]
81
+
HttpStatus(StatusCode),
82
+
#[error(transparent)]
83
+
#[diagnostic(code(jacquard_oauth::resolver::serde_json))]
84
+
SerdeJson(#[from] serde_json::Error),
85
+
#[error(transparent)]
86
+
#[diagnostic(code(jacquard_oauth::resolver::serde_form))]
87
+
SerdeHtmlForm(#[from] serde_html_form::ser::Error),
88
+
#[error(transparent)]
89
+
#[diagnostic(code(jacquard_oauth::resolver::url))]
90
+
Uri(#[from] url::ParseError),
91
+
}
92
+
93
+
#[async_trait::async_trait]
94
+
pub trait OAuthResolver: IdentityResolver + HttpClient {
95
+
async fn verify_issuer(
96
+
&self,
97
+
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
98
+
sub: &Did<'_>,
99
+
) -> Result<Url, ResolverError> {
100
+
let (metadata, identity) = self.resolve_from_identity(sub).await?;
101
+
if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
102
+
return Err(ResolverError::AuthorizationServerMetadata(
103
+
"issuer mismatch".to_string(),
104
+
));
105
+
}
106
+
Ok(identity
107
+
.pds_endpoint()
108
+
.ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
109
+
}
110
+
async fn resolve_oauth(
111
+
&self,
112
+
input: &str,
113
+
) -> Result<
114
+
(
115
+
OAuthAuthorizationServerMetadata<'static>,
116
+
Option<DidDocument<'static>>,
117
+
),
118
+
ResolverError,
119
+
> {
120
+
// Allow using an entryway, or PDS url, directly as login input (e.g.
121
+
// when the user forgot their handle, or when the handle does not
122
+
// resolve to a DID)
123
+
Ok(if input.starts_with("https://") {
124
+
let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
125
+
(self.resolve_from_service(&url).await?, None)
126
+
} else {
127
+
let (metadata, identity) = self.resolve_from_identity(input).await?;
128
+
(metadata, Some(identity))
129
+
})
130
+
}
131
+
async fn resolve_from_service(
132
+
&self,
133
+
input: &Url,
134
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
135
+
// Assume first that input is a PDS URL (as required by ATPROTO)
136
+
if let Ok(metadata) = self.get_resource_server_metadata(input).await {
137
+
return Ok(metadata);
138
+
}
139
+
// Fallback to trying to fetch as an issuer (Entryway)
140
+
self.get_authorization_server_metadata(input).await
141
+
}
142
+
async fn resolve_from_identity(
143
+
&self,
144
+
input: &str,
145
+
) -> Result<
146
+
(
147
+
OAuthAuthorizationServerMetadata<'static>,
148
+
DidDocument<'static>,
149
+
),
150
+
ResolverError,
151
+
> {
152
+
let actor = AtIdentifier::new(input)
153
+
.map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
154
+
let identity = self.resolve_ident_owned(&actor).await?;
155
+
if let Some(pds) = &identity.pds_endpoint() {
156
+
let metadata = self.get_resource_server_metadata(pds).await?;
157
+
Ok((metadata, identity))
158
+
} else {
159
+
Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
160
+
}
161
+
}
162
+
async fn get_authorization_server_metadata(
163
+
&self,
164
+
issuer: &Url,
165
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
166
+
let mut md = resolve_authorization_server(self, issuer).await?;
167
+
// Normalize issuer string to the input URL representation to avoid slash quirks
168
+
md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
169
+
Ok(md)
170
+
}
171
+
async fn get_resource_server_metadata(
172
+
&self,
173
+
pds: &Url,
174
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
175
+
let rs_metadata = resolve_protected_resource_info(self, pds).await?;
176
+
// ATPROTO requires one, and only one, authorization server entry
177
+
// > That document MUST contain a single item in the authorization_servers array.
178
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
179
+
let issuer = match &rs_metadata.authorization_servers {
180
+
Some(servers) if !servers.is_empty() => {
181
+
if servers.len() > 1 {
182
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
183
+
"unable to determine authorization server for PDS: {pds}"
184
+
)));
185
+
}
186
+
&servers[0]
187
+
}
188
+
_ => {
189
+
return Err(ResolverError::ProtectedResourceMetadata(format!(
190
+
"no authorization server found for PDS: {pds}"
191
+
)));
192
+
}
193
+
};
194
+
let as_metadata = self.get_authorization_server_metadata(issuer).await?;
195
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
196
+
if let Some(protected_resources) = &as_metadata.protected_resources {
197
+
if !protected_resources.contains(&rs_metadata.resource) {
198
+
return Err(ResolverError::AuthorizationServerMetadata(format!(
199
+
"pds {pds} does not protected by issuer: {issuer}",
200
+
)));
201
+
}
202
+
}
203
+
204
+
// TODO: atproot specific validation?
205
+
// https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
206
+
//
207
+
// eg.
208
+
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
209
+
// if as_metadata.client_id_metadata_document_supported != Some(true) {
210
+
// return Err(Error::AuthorizationServerMetadata(format!(
211
+
// "authorization server does not support client_id_metadata_document: {issuer}"
212
+
// )));
213
+
// }
214
+
215
+
Ok(as_metadata)
216
+
}
217
+
}
218
+
219
+
pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
220
+
client: &T,
221
+
server: &Url,
222
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
223
+
let url = server
224
+
.join("/.well-known/oauth-authorization-server")
225
+
.map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?;
226
+
227
+
let req = Request::builder()
228
+
.uri(url.to_string())
229
+
.body(Vec::new())
230
+
.map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?;
231
+
let res = client
232
+
.send_http(req)
233
+
.await
234
+
.map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?;
235
+
if res.status() == StatusCode::OK {
236
+
let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())
237
+
.map_err(ResolverError::SerdeJson)?;
238
+
// https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
239
+
// Accept semantically equivalent issuer (normalize to the requested URL form)
240
+
if issuer_equivalent(&metadata.issuer, server.as_str()) {
241
+
metadata.issuer = server.as_str().into();
242
+
Ok(metadata.into_static())
243
+
} else {
244
+
Err(ResolverError::AuthorizationServerMetadata(format!(
245
+
"invalid issuer: {}",
246
+
metadata.issuer
247
+
)))
248
+
}
249
+
} else {
250
+
Err(ResolverError::HttpStatus(res.status()))
251
+
}
252
+
}
253
+
254
+
pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>(
255
+
client: &T,
256
+
server: &Url,
257
+
) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> {
258
+
let url = server
259
+
.join("/.well-known/oauth-protected-resource")
260
+
.map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?;
261
+
262
+
let req = Request::builder()
263
+
.uri(url.to_string())
264
+
.body(Vec::new())
265
+
.map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?;
266
+
let res = client
267
+
.send_http(req)
268
+
.await
269
+
.map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?;
270
+
if res.status() == StatusCode::OK {
271
+
let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())
272
+
.map_err(ResolverError::SerdeJson)?;
273
+
// https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
274
+
// Accept semantically equivalent resource URL (normalize to the requested URL form)
275
+
if issuer_equivalent(&metadata.resource, server.as_str()) {
276
+
metadata.resource = server.as_str().into();
277
+
Ok(metadata.into_static())
278
+
} else {
279
+
Err(ResolverError::AuthorizationServerMetadata(format!(
280
+
"invalid resource: {}",
281
+
metadata.resource
282
+
)))
283
+
}
284
+
} else {
285
+
Err(ResolverError::HttpStatus(res.status()))
286
+
}
287
+
}
288
+
289
+
#[async_trait::async_trait]
290
+
impl OAuthResolver for jacquard_identity::JacquardResolver {}
291
+
292
+
#[cfg(test)]
293
+
mod tests {
294
+
use super::*;
295
+
use http::{Request as HttpRequest, Response as HttpResponse, StatusCode};
296
+
use jacquard_common::http_client::HttpClient;
297
+
298
+
#[derive(Default, Clone)]
299
+
struct MockHttp {
300
+
next: std::sync::Arc<tokio::sync::Mutex<Option<HttpResponse<Vec<u8>>>>>,
301
+
}
302
+
303
+
impl HttpClient for MockHttp {
304
+
type Error = std::convert::Infallible;
305
+
fn send_http(
306
+
&self,
307
+
_request: HttpRequest<Vec<u8>>,
308
+
) -> impl core::future::Future<
309
+
Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>,
310
+
> + Send {
311
+
let next = self.next.clone();
312
+
async move { Ok(next.lock().await.take().unwrap()) }
313
+
}
314
+
}
315
+
316
+
#[tokio::test]
317
+
async fn authorization_server_http_status() {
318
+
let client = MockHttp::default();
319
+
*client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::NOT_FOUND).body(Vec::new()).unwrap());
320
+
let issuer = url::Url::parse("https://issuer").unwrap();
321
+
let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err();
322
+
matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND));
323
+
}
324
+
325
+
#[tokio::test]
326
+
async fn authorization_server_bad_json() {
327
+
let client = MockHttp::default();
328
+
*client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::OK).body(b"{not json}".to_vec()).unwrap());
329
+
let issuer = url::Url::parse("https://issuer").unwrap();
330
+
let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err();
331
+
matches!(err, ResolverError::SerdeJson(_));
332
+
}
333
+
334
+
#[test]
335
+
fn issuer_equivalence_rules() {
336
+
assert!(super::issuer_equivalent("https://issuer", "https://issuer/"));
337
+
assert!(super::issuer_equivalent("https://issuer:443/", "https://issuer/"));
338
+
assert!(!super::issuer_equivalent("http://issuer/", "https://issuer/"));
339
+
assert!(!super::issuer_equivalent("https://issuer/foo", "https://issuer/"));
340
+
assert!(!super::issuer_equivalent("https://issuer/?q=1", "https://issuer/"));
341
+
}
342
+
}
+2009
crates/jacquard-oauth/src/scopes.rs
+2009
crates/jacquard-oauth/src/scopes.rs
···
1
+
//! AT Protocol OAuth scopes module
2
+
//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
3
+
//!
4
+
//! This module provides comprehensive support for AT Protocol OAuth scopes,
5
+
//! including parsing, serialization, normalization, and permission checking.
6
+
//!
7
+
//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
8
+
//! - `account`: Access to account information (email, repo, status)
9
+
//! - `identity`: Access to identity information (handle)
10
+
//! - `blob`: Access to blob operations with mime type constraints
11
+
//! - `repo`: Repository operations with collection and action constraints
12
+
//! - `rpc`: RPC method access with lexicon and audience constraints
13
+
//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
14
+
//! - `transition`: Migration operations (generic or email)
15
+
//!
16
+
//! Standard OpenID Connect scopes (no suffixes or query parameters):
17
+
//! - `openid`: Required for OpenID Connect authentication
18
+
//! - `profile`: Access to user profile information
19
+
//! - `email`: Access to user email address
20
+
21
+
use std::collections::{BTreeMap, BTreeSet};
22
+
use std::fmt;
23
+
use std::str::FromStr;
24
+
25
+
use jacquard_common::types::did::Did;
26
+
use jacquard_common::types::nsid::Nsid;
27
+
use jacquard_common::types::string::AtStrError;
28
+
use jacquard_common::{CowStr, IntoStatic};
29
+
use serde::de::Visitor;
30
+
use serde::{Deserialize, Serialize};
31
+
use smol_str::{SmolStr, ToSmolStr};
32
+
33
+
/// Represents an AT Protocol OAuth scope
34
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35
+
pub enum Scope<'s> {
36
+
/// Account scope for accessing account information
37
+
Account(AccountScope),
38
+
/// Identity scope for accessing identity information
39
+
Identity(IdentityScope),
40
+
/// Blob scope for blob operations with mime type constraints
41
+
Blob(BlobScope<'s>),
42
+
/// Repository scope for collection operations
43
+
Repo(RepoScope<'s>),
44
+
/// RPC scope for method access
45
+
Rpc(RpcScope<'s>),
46
+
/// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
47
+
Atproto,
48
+
/// Transition scope for migration operations
49
+
Transition(TransitionScope),
50
+
/// OpenID Connect scope - required for OpenID Connect authentication
51
+
OpenId,
52
+
/// Profile scope - access to user profile information
53
+
Profile,
54
+
/// Email scope - access to user email address
55
+
Email,
56
+
}
57
+
58
+
impl Serialize for Scope<'_> {
59
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60
+
where
61
+
S: serde::Serializer,
62
+
{
63
+
serializer.serialize_str(&self.to_string_normalized())
64
+
}
65
+
}
66
+
67
+
impl<'de> Deserialize<'de> for Scope<'_> {
68
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69
+
where
70
+
D: serde::Deserializer<'de>,
71
+
{
72
+
struct ScopeVisitor;
73
+
74
+
impl Visitor<'_> for ScopeVisitor {
75
+
type Value = Scope<'static>;
76
+
77
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
78
+
write!(formatter, "a scope string")
79
+
}
80
+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
81
+
where
82
+
E: serde::de::Error,
83
+
{
84
+
Scope::parse(v)
85
+
.map(|s| s.into_static())
86
+
.map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
87
+
}
88
+
}
89
+
deserializer.deserialize_str(ScopeVisitor)
90
+
}
91
+
}
92
+
93
+
impl IntoStatic for Scope<'_> {
94
+
type Output = Scope<'static>;
95
+
96
+
fn into_static(self) -> Self::Output {
97
+
match self {
98
+
Scope::Account(scope) => Scope::Account(scope),
99
+
Scope::Identity(scope) => Scope::Identity(scope),
100
+
Scope::Blob(scope) => Scope::Blob(scope.into_static()),
101
+
Scope::Repo(scope) => Scope::Repo(scope.into_static()),
102
+
Scope::Rpc(scope) => Scope::Rpc(scope.into_static()),
103
+
Scope::Atproto => Scope::Atproto,
104
+
Scope::Transition(scope) => Scope::Transition(scope),
105
+
Scope::OpenId => Scope::OpenId,
106
+
Scope::Profile => Scope::Profile,
107
+
Scope::Email => Scope::Email,
108
+
}
109
+
}
110
+
}
111
+
112
+
/// Account scope attributes
113
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114
+
pub struct AccountScope {
115
+
/// The account resource type
116
+
pub resource: AccountResource,
117
+
/// The action permission level
118
+
pub action: AccountAction,
119
+
}
120
+
121
+
/// Account resource types
122
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123
+
pub enum AccountResource {
124
+
/// Email access
125
+
Email,
126
+
/// Repository access
127
+
Repo,
128
+
/// Status access
129
+
Status,
130
+
}
131
+
132
+
/// Account action permissions
133
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134
+
pub enum AccountAction {
135
+
/// Read-only access
136
+
Read,
137
+
/// Management access (includes read)
138
+
Manage,
139
+
}
140
+
141
+
/// Identity scope attributes
142
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143
+
pub enum IdentityScope {
144
+
/// Handle access
145
+
Handle,
146
+
/// All identity access (wildcard)
147
+
All,
148
+
}
149
+
150
+
/// Transition scope types
151
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152
+
pub enum TransitionScope {
153
+
/// Generic transition operations
154
+
Generic,
155
+
/// Email transition operations
156
+
Email,
157
+
}
158
+
159
+
/// Blob scope with mime type constraints
160
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161
+
pub struct BlobScope<'s> {
162
+
/// Accepted mime types
163
+
pub accept: BTreeSet<MimePattern<'s>>,
164
+
}
165
+
166
+
impl IntoStatic for BlobScope<'_> {
167
+
type Output = BlobScope<'static>;
168
+
169
+
fn into_static(self) -> Self::Output {
170
+
BlobScope {
171
+
accept: self.accept.into_iter().map(|p| p.into_static()).collect(),
172
+
}
173
+
}
174
+
}
175
+
176
+
/// MIME type pattern for blob scope
177
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
178
+
pub enum MimePattern<'s> {
179
+
/// Match all types
180
+
All,
181
+
/// Match all subtypes of a type (e.g., "image/*")
182
+
TypeWildcard(CowStr<'s>),
183
+
/// Exact mime type match
184
+
Exact(CowStr<'s>),
185
+
}
186
+
187
+
impl IntoStatic for MimePattern<'_> {
188
+
type Output = MimePattern<'static>;
189
+
190
+
fn into_static(self) -> Self::Output {
191
+
match self {
192
+
MimePattern::All => MimePattern::All,
193
+
MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()),
194
+
MimePattern::Exact(s) => MimePattern::Exact(s.into_static()),
195
+
}
196
+
}
197
+
}
198
+
199
+
/// Repository scope with collection and action constraints
200
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
201
+
pub struct RepoScope<'s> {
202
+
/// Collection NSID or wildcard
203
+
pub collection: RepoCollection<'s>,
204
+
/// Allowed actions
205
+
pub actions: BTreeSet<RepoAction>,
206
+
}
207
+
208
+
impl IntoStatic for RepoScope<'_> {
209
+
type Output = RepoScope<'static>;
210
+
211
+
fn into_static(self) -> Self::Output {
212
+
RepoScope {
213
+
collection: self.collection.into_static(),
214
+
actions: self.actions,
215
+
}
216
+
}
217
+
}
218
+
219
+
/// Repository collection identifier
220
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
221
+
pub enum RepoCollection<'s> {
222
+
/// All collections (wildcard)
223
+
All,
224
+
/// Specific collection NSID
225
+
Nsid(Nsid<'s>),
226
+
}
227
+
228
+
impl IntoStatic for RepoCollection<'_> {
229
+
type Output = RepoCollection<'static>;
230
+
231
+
fn into_static(self) -> Self::Output {
232
+
match self {
233
+
RepoCollection::All => RepoCollection::All,
234
+
RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()),
235
+
}
236
+
}
237
+
}
238
+
239
+
/// Repository actions
240
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
241
+
pub enum RepoAction {
242
+
/// Create records
243
+
Create,
244
+
/// Update records
245
+
Update,
246
+
/// Delete records
247
+
Delete,
248
+
}
249
+
250
+
/// RPC scope with lexicon method and audience constraints
251
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
252
+
pub struct RpcScope<'s> {
253
+
/// Lexicon methods (NSIDs or wildcard)
254
+
pub lxm: BTreeSet<RpcLexicon<'s>>,
255
+
/// Audiences (DIDs or wildcard)
256
+
pub aud: BTreeSet<RpcAudience<'s>>,
257
+
}
258
+
259
+
impl IntoStatic for RpcScope<'_> {
260
+
type Output = RpcScope<'static>;
261
+
262
+
fn into_static(self) -> Self::Output {
263
+
RpcScope {
264
+
lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(),
265
+
aud: self.aud.into_iter().map(|s| s.into_static()).collect(),
266
+
}
267
+
}
268
+
}
269
+
270
+
/// RPC lexicon identifier
271
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
272
+
pub enum RpcLexicon<'s> {
273
+
/// All lexicons (wildcard)
274
+
All,
275
+
/// Specific lexicon NSID
276
+
Nsid(Nsid<'s>),
277
+
}
278
+
279
+
impl IntoStatic for RpcLexicon<'_> {
280
+
type Output = RpcLexicon<'static>;
281
+
282
+
fn into_static(self) -> Self::Output {
283
+
match self {
284
+
RpcLexicon::All => RpcLexicon::All,
285
+
RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()),
286
+
}
287
+
}
288
+
}
289
+
290
+
/// RPC audience identifier
291
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
292
+
pub enum RpcAudience<'s> {
293
+
/// All audiences (wildcard)
294
+
All,
295
+
/// Specific DID
296
+
Did(Did<'s>),
297
+
}
298
+
299
+
impl IntoStatic for RpcAudience<'_> {
300
+
type Output = RpcAudience<'static>;
301
+
302
+
fn into_static(self) -> Self::Output {
303
+
match self {
304
+
RpcAudience::All => RpcAudience::All,
305
+
RpcAudience::Did(did) => RpcAudience::Did(did.into_static()),
306
+
}
307
+
}
308
+
}
309
+
310
+
impl<'s> Scope<'s> {
311
+
/// Parse multiple space-separated scopes from a string
312
+
///
313
+
/// # Examples
314
+
/// ```
315
+
/// # use jacquard_oauth::scopes::Scope;
316
+
/// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
317
+
/// assert_eq!(scopes.len(), 2);
318
+
/// ```
319
+
pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> {
320
+
if s.trim().is_empty() {
321
+
return Ok(Vec::new());
322
+
}
323
+
324
+
let mut scopes = Vec::new();
325
+
for scope_str in s.split_whitespace() {
326
+
scopes.push(Self::parse(scope_str)?);
327
+
}
328
+
329
+
Ok(scopes)
330
+
}
331
+
332
+
/// Parse multiple space-separated scopes and return the minimal set needed
333
+
///
334
+
/// This method removes duplicate scopes and scopes that are already granted
335
+
/// by other scopes in the list, returning only the minimal set of scopes needed.
336
+
///
337
+
/// # Examples
338
+
/// ```
339
+
/// # use jacquard_oauth::scopes::Scope;
340
+
/// // repo:* grants repo:foo.bar, so only repo:* is kept
341
+
/// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
342
+
/// assert_eq!(scopes.len(), 2); // atproto and repo:*
343
+
/// ```
344
+
pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> {
345
+
let all_scopes = Self::parse_multiple(s)?;
346
+
347
+
if all_scopes.is_empty() {
348
+
return Ok(Vec::new());
349
+
}
350
+
351
+
let mut result: Vec<Self> = Vec::new();
352
+
353
+
for scope in all_scopes {
354
+
// Check if this scope is already granted by something in the result
355
+
let mut is_granted = false;
356
+
for existing in &result {
357
+
if existing.grants(&scope) && existing != &scope {
358
+
is_granted = true;
359
+
break;
360
+
}
361
+
}
362
+
363
+
if is_granted {
364
+
continue; // Skip this scope, it's already covered
365
+
}
366
+
367
+
// Check if this scope grants any existing scopes in the result
368
+
let mut indices_to_remove = Vec::new();
369
+
for (i, existing) in result.iter().enumerate() {
370
+
if scope.grants(existing) && &scope != existing {
371
+
indices_to_remove.push(i);
372
+
}
373
+
}
374
+
375
+
// Remove scopes that are granted by the new scope (in reverse order to maintain indices)
376
+
for i in indices_to_remove.into_iter().rev() {
377
+
result.remove(i);
378
+
}
379
+
380
+
// Add the new scope if it's not a duplicate
381
+
if !result.contains(&scope) {
382
+
result.push(scope);
383
+
}
384
+
}
385
+
386
+
Ok(result)
387
+
}
388
+
389
+
/// Serialize a list of scopes into a space-separated OAuth scopes string
390
+
///
391
+
/// The scopes are sorted alphabetically by their string representation to ensure
392
+
/// consistent output regardless of input order.
393
+
///
394
+
/// # Examples
395
+
/// ```
396
+
/// # use jacquard_oauth::scopes::Scope;
397
+
/// let scopes = vec![
398
+
/// Scope::parse("repo:*").unwrap(),
399
+
/// Scope::parse("atproto").unwrap(),
400
+
/// Scope::parse("account:email").unwrap(),
401
+
/// ];
402
+
/// let result = Scope::serialize_multiple(&scopes);
403
+
/// assert_eq!(result, "account:email atproto repo:*");
404
+
/// ```
405
+
pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> {
406
+
if scopes.is_empty() {
407
+
return CowStr::default();
408
+
}
409
+
410
+
let mut serialized: Vec<String> = scopes
411
+
.iter()
412
+
.map(|scope| scope.to_string_normalized())
413
+
.collect();
414
+
415
+
serialized.sort();
416
+
serialized.join(" ").into()
417
+
}
418
+
419
+
/// Remove a scope from a list of scopes
420
+
///
421
+
/// Returns a new vector with all instances of the specified scope removed.
422
+
/// If the scope doesn't exist in the list, returns a copy of the original list.
423
+
///
424
+
/// # Examples
425
+
/// ```
426
+
/// # use jacquard_oauth::scopes::Scope;
427
+
/// let scopes = vec![
428
+
/// Scope::parse("repo:*").unwrap(),
429
+
/// Scope::parse("atproto").unwrap(),
430
+
/// Scope::parse("account:email").unwrap(),
431
+
/// ];
432
+
/// let to_remove = Scope::parse("atproto").unwrap();
433
+
/// let result = Scope::remove_scope(&scopes, &to_remove);
434
+
/// assert_eq!(result.len(), 2);
435
+
/// assert!(!result.contains(&to_remove));
436
+
/// ```
437
+
pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
438
+
scopes
439
+
.iter()
440
+
.filter(|s| *s != scope_to_remove)
441
+
.cloned()
442
+
.collect()
443
+
}
444
+
445
+
/// Parse a scope from a string
446
+
pub fn parse(s: &'s str) -> Result<Self, ParseError> {
447
+
// Determine the prefix first by checking for known prefixes
448
+
let prefixes = [
449
+
"account",
450
+
"identity",
451
+
"blob",
452
+
"repo",
453
+
"rpc",
454
+
"atproto",
455
+
"transition",
456
+
"openid",
457
+
"profile",
458
+
"email",
459
+
];
460
+
let mut found_prefix = None;
461
+
let mut suffix = None;
462
+
463
+
for prefix in &prefixes {
464
+
if let Some(remainder) = s.strip_prefix(prefix)
465
+
&& (remainder.is_empty()
466
+
|| remainder.starts_with(':')
467
+
|| remainder.starts_with('?'))
468
+
{
469
+
found_prefix = Some(*prefix);
470
+
if let Some(stripped) = remainder.strip_prefix(':') {
471
+
suffix = Some(stripped);
472
+
} else if remainder.starts_with('?') {
473
+
suffix = Some(remainder);
474
+
} else {
475
+
suffix = None;
476
+
}
477
+
break;
478
+
}
479
+
}
480
+
481
+
let prefix = found_prefix.ok_or_else(|| {
482
+
// If no known prefix found, extract what looks like a prefix for error reporting
483
+
let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
484
+
ParseError::UnknownPrefix(s[..end].to_string())
485
+
})?;
486
+
487
+
match prefix {
488
+
"account" => Self::parse_account(suffix),
489
+
"identity" => Self::parse_identity(suffix),
490
+
"blob" => Self::parse_blob(suffix),
491
+
"repo" => Self::parse_repo(suffix),
492
+
"rpc" => Self::parse_rpc(suffix),
493
+
"atproto" => Self::parse_atproto(suffix),
494
+
"transition" => Self::parse_transition(suffix),
495
+
"openid" => Self::parse_openid(suffix),
496
+
"profile" => Self::parse_profile(suffix),
497
+
"email" => Self::parse_email(suffix),
498
+
_ => Err(ParseError::UnknownPrefix(prefix.to_string())),
499
+
}
500
+
}
501
+
502
+
fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> {
503
+
let (resource_str, params) = match suffix {
504
+
Some(s) => {
505
+
if let Some(pos) = s.find('?') {
506
+
(&s[..pos], Some(&s[pos + 1..]))
507
+
} else {
508
+
(s, None)
509
+
}
510
+
}
511
+
None => return Err(ParseError::MissingResource),
512
+
};
513
+
514
+
let resource = match resource_str {
515
+
"email" => AccountResource::Email,
516
+
"repo" => AccountResource::Repo,
517
+
"status" => AccountResource::Status,
518
+
_ => return Err(ParseError::InvalidResource(resource_str.to_string())),
519
+
};
520
+
521
+
let action = if let Some(params) = params {
522
+
let parsed_params = parse_query_string(params);
523
+
match parsed_params
524
+
.get("action")
525
+
.and_then(|v| v.first())
526
+
.map(|s| s.as_ref())
527
+
{
528
+
Some("read") => AccountAction::Read,
529
+
Some("manage") => AccountAction::Manage,
530
+
Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
531
+
None => AccountAction::Read,
532
+
}
533
+
} else {
534
+
AccountAction::Read
535
+
};
536
+
537
+
Ok(Scope::Account(AccountScope { resource, action }))
538
+
}
539
+
540
+
fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> {
541
+
let scope = match suffix {
542
+
Some("handle") => IdentityScope::Handle,
543
+
Some("*") => IdentityScope::All,
544
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
545
+
None => return Err(ParseError::MissingResource),
546
+
};
547
+
548
+
Ok(Scope::Identity(scope))
549
+
}
550
+
551
+
fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> {
552
+
let mut accept = BTreeSet::new();
553
+
554
+
match suffix {
555
+
Some(s) if s.starts_with('?') => {
556
+
let params = parse_query_string(&s[1..]);
557
+
if let Some(values) = params.get("accept") {
558
+
for value in values {
559
+
accept.insert(MimePattern::from_str(value)?);
560
+
}
561
+
}
562
+
}
563
+
Some(s) => {
564
+
accept.insert(MimePattern::from_str(s)?);
565
+
}
566
+
None => {
567
+
accept.insert(MimePattern::All);
568
+
}
569
+
}
570
+
571
+
if accept.is_empty() {
572
+
accept.insert(MimePattern::All);
573
+
}
574
+
575
+
Ok(Scope::Blob(BlobScope { accept }))
576
+
}
577
+
578
+
fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> {
579
+
let (collection_str, params) = match suffix {
580
+
Some(s) => {
581
+
if let Some(pos) = s.find('?') {
582
+
(Some(&s[..pos]), Some(&s[pos + 1..]))
583
+
} else {
584
+
(Some(s), None)
585
+
}
586
+
}
587
+
None => (None, None),
588
+
};
589
+
590
+
let collection = match collection_str {
591
+
Some("*") | None => RepoCollection::All,
592
+
Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?),
593
+
};
594
+
595
+
let mut actions = BTreeSet::new();
596
+
if let Some(params) = params {
597
+
let parsed_params = parse_query_string(params);
598
+
if let Some(values) = parsed_params.get("action") {
599
+
for value in values {
600
+
match value.as_ref() {
601
+
"create" => {
602
+
actions.insert(RepoAction::Create);
603
+
}
604
+
"update" => {
605
+
actions.insert(RepoAction::Update);
606
+
}
607
+
"delete" => {
608
+
actions.insert(RepoAction::Delete);
609
+
}
610
+
"*" => {
611
+
actions.insert(RepoAction::Create);
612
+
actions.insert(RepoAction::Update);
613
+
actions.insert(RepoAction::Delete);
614
+
}
615
+
other => return Err(ParseError::InvalidAction(other.to_string())),
616
+
}
617
+
}
618
+
}
619
+
}
620
+
621
+
if actions.is_empty() {
622
+
actions.insert(RepoAction::Create);
623
+
actions.insert(RepoAction::Update);
624
+
actions.insert(RepoAction::Delete);
625
+
}
626
+
627
+
Ok(Scope::Repo(RepoScope {
628
+
collection,
629
+
actions,
630
+
}))
631
+
}
632
+
633
+
fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> {
634
+
let mut lxm = BTreeSet::new();
635
+
let mut aud = BTreeSet::new();
636
+
637
+
match suffix {
638
+
Some("*") => {
639
+
lxm.insert(RpcLexicon::All);
640
+
aud.insert(RpcAudience::All);
641
+
}
642
+
Some(s) if s.starts_with('?') => {
643
+
let params = parse_query_string(&s[1..]);
644
+
645
+
if let Some(values) = params.get("lxm") {
646
+
for value in values {
647
+
if value.as_ref() == "*" {
648
+
lxm.insert(RpcLexicon::All);
649
+
} else {
650
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static()));
651
+
}
652
+
}
653
+
}
654
+
655
+
if let Some(values) = params.get("aud") {
656
+
for value in values {
657
+
if value.as_ref() == "*" {
658
+
aud.insert(RpcAudience::All);
659
+
} else {
660
+
aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
661
+
}
662
+
}
663
+
}
664
+
}
665
+
Some(s) => {
666
+
// Check if there's a query string in the suffix
667
+
if let Some(pos) = s.find('?') {
668
+
let nsid = &s[..pos];
669
+
let params = parse_query_string(&s[pos + 1..]);
670
+
671
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static()));
672
+
673
+
if let Some(values) = params.get("aud") {
674
+
for value in values {
675
+
if value.as_ref() == "*" {
676
+
aud.insert(RpcAudience::All);
677
+
} else {
678
+
aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
679
+
}
680
+
}
681
+
}
682
+
} else {
683
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static()));
684
+
}
685
+
}
686
+
None => {}
687
+
}
688
+
689
+
if lxm.is_empty() {
690
+
lxm.insert(RpcLexicon::All);
691
+
}
692
+
if aud.is_empty() {
693
+
aud.insert(RpcAudience::All);
694
+
}
695
+
696
+
Ok(Scope::Rpc(RpcScope { lxm, aud }))
697
+
}
698
+
699
+
fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
700
+
if suffix.is_some() {
701
+
return Err(ParseError::InvalidResource(
702
+
"atproto scope does not accept suffixes".to_string(),
703
+
));
704
+
}
705
+
Ok(Scope::Atproto)
706
+
}
707
+
708
+
fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
709
+
let scope = match suffix {
710
+
Some("generic") => TransitionScope::Generic,
711
+
Some("email") => TransitionScope::Email,
712
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
713
+
None => return Err(ParseError::MissingResource),
714
+
};
715
+
716
+
Ok(Scope::Transition(scope))
717
+
}
718
+
719
+
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
720
+
if suffix.is_some() {
721
+
return Err(ParseError::InvalidResource(
722
+
"openid scope does not accept suffixes".to_string(),
723
+
));
724
+
}
725
+
Ok(Scope::OpenId)
726
+
}
727
+
728
+
fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
729
+
if suffix.is_some() {
730
+
return Err(ParseError::InvalidResource(
731
+
"profile scope does not accept suffixes".to_string(),
732
+
));
733
+
}
734
+
Ok(Scope::Profile)
735
+
}
736
+
737
+
fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
738
+
if suffix.is_some() {
739
+
return Err(ParseError::InvalidResource(
740
+
"email scope does not accept suffixes".to_string(),
741
+
));
742
+
}
743
+
Ok(Scope::Email)
744
+
}
745
+
746
+
/// Convert the scope to its normalized string representation
747
+
pub fn to_string_normalized(&self) -> String {
748
+
match self {
749
+
Scope::Account(scope) => {
750
+
let resource = match scope.resource {
751
+
AccountResource::Email => "email",
752
+
AccountResource::Repo => "repo",
753
+
AccountResource::Status => "status",
754
+
};
755
+
756
+
match scope.action {
757
+
AccountAction::Read => format!("account:{}", resource),
758
+
AccountAction::Manage => format!("account:{}?action=manage", resource),
759
+
}
760
+
}
761
+
Scope::Identity(scope) => match scope {
762
+
IdentityScope::Handle => "identity:handle".to_string(),
763
+
IdentityScope::All => "identity:*".to_string(),
764
+
},
765
+
Scope::Blob(scope) => {
766
+
if scope.accept.len() == 1 {
767
+
if let Some(pattern) = scope.accept.iter().next() {
768
+
match pattern {
769
+
MimePattern::All => "blob:*/*".to_string(),
770
+
MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
771
+
MimePattern::Exact(mime) => format!("blob:{}", mime),
772
+
}
773
+
} else {
774
+
"blob:*/*".to_string()
775
+
}
776
+
} else {
777
+
let mut params = Vec::new();
778
+
for pattern in &scope.accept {
779
+
match pattern {
780
+
MimePattern::All => params.push("accept=*/*".to_string()),
781
+
MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
782
+
MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
783
+
}
784
+
}
785
+
params.sort();
786
+
format!("blob?{}", params.join("&"))
787
+
}
788
+
}
789
+
Scope::Repo(scope) => {
790
+
let collection = match &scope.collection {
791
+
RepoCollection::All => "*",
792
+
RepoCollection::Nsid(nsid) => nsid,
793
+
};
794
+
795
+
if scope.actions.len() == 3 {
796
+
format!("repo:{}", collection)
797
+
} else {
798
+
let mut params = Vec::new();
799
+
for action in &scope.actions {
800
+
match action {
801
+
RepoAction::Create => params.push("action=create"),
802
+
RepoAction::Update => params.push("action=update"),
803
+
RepoAction::Delete => params.push("action=delete"),
804
+
}
805
+
}
806
+
format!("repo:{}?{}", collection, params.join("&"))
807
+
}
808
+
}
809
+
Scope::Rpc(scope) => {
810
+
if scope.lxm.len() == 1
811
+
&& scope.lxm.contains(&RpcLexicon::All)
812
+
&& scope.aud.len() == 1
813
+
&& scope.aud.contains(&RpcAudience::All)
814
+
{
815
+
"rpc:*".to_string()
816
+
} else if scope.lxm.len() == 1
817
+
&& scope.aud.len() == 1
818
+
&& scope.aud.contains(&RpcAudience::All)
819
+
{
820
+
if let Some(lxm) = scope.lxm.iter().next() {
821
+
match lxm {
822
+
RpcLexicon::All => "rpc:*".to_string(),
823
+
RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
824
+
}
825
+
} else {
826
+
"rpc:*".to_string()
827
+
}
828
+
} else {
829
+
let mut params = Vec::new();
830
+
831
+
for lxm in &scope.lxm {
832
+
match lxm {
833
+
RpcLexicon::All => params.push("lxm=*".to_string()),
834
+
RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
835
+
}
836
+
}
837
+
838
+
for aud in &scope.aud {
839
+
match aud {
840
+
RpcAudience::All => params.push("aud=*".to_string()),
841
+
RpcAudience::Did(did) => params.push(format!("aud={}", did)),
842
+
}
843
+
}
844
+
845
+
params.sort();
846
+
847
+
if params.is_empty() {
848
+
"rpc:*".to_string()
849
+
} else {
850
+
format!("rpc?{}", params.join("&"))
851
+
}
852
+
}
853
+
}
854
+
Scope::Atproto => "atproto".to_string(),
855
+
Scope::Transition(scope) => match scope {
856
+
TransitionScope::Generic => "transition:generic".to_string(),
857
+
TransitionScope::Email => "transition:email".to_string(),
858
+
},
859
+
Scope::OpenId => "openid".to_string(),
860
+
Scope::Profile => "profile".to_string(),
861
+
Scope::Email => "email".to_string(),
862
+
}
863
+
}
864
+
865
+
/// Check if this scope grants the permissions of another scope
866
+
pub fn grants(&self, other: &Scope) -> bool {
867
+
match (self, other) {
868
+
// Atproto only grants itself (it's a required scope, not a permission grant)
869
+
(Scope::Atproto, Scope::Atproto) => true,
870
+
(Scope::Atproto, _) => false,
871
+
// Nothing else grants atproto
872
+
(_, Scope::Atproto) => false,
873
+
// Transition scopes only grant themselves
874
+
(Scope::Transition(a), Scope::Transition(b)) => a == b,
875
+
// Other scopes don't grant transition scopes
876
+
(_, Scope::Transition(_)) => false,
877
+
(Scope::Transition(_), _) => false,
878
+
// OpenID Connect scopes only grant themselves
879
+
(Scope::OpenId, Scope::OpenId) => true,
880
+
(Scope::OpenId, _) => false,
881
+
(_, Scope::OpenId) => false,
882
+
(Scope::Profile, Scope::Profile) => true,
883
+
(Scope::Profile, _) => false,
884
+
(_, Scope::Profile) => false,
885
+
(Scope::Email, Scope::Email) => true,
886
+
(Scope::Email, _) => false,
887
+
(_, Scope::Email) => false,
888
+
(Scope::Account(a), Scope::Account(b)) => {
889
+
a.resource == b.resource
890
+
&& matches!(
891
+
(a.action, b.action),
892
+
(AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
893
+
)
894
+
}
895
+
(Scope::Identity(a), Scope::Identity(b)) => matches!(
896
+
(a, b),
897
+
(IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
898
+
),
899
+
(Scope::Blob(a), Scope::Blob(b)) => {
900
+
for b_pattern in &b.accept {
901
+
let mut granted = false;
902
+
for a_pattern in &a.accept {
903
+
if a_pattern.grants(b_pattern) {
904
+
granted = true;
905
+
break;
906
+
}
907
+
}
908
+
if !granted {
909
+
return false;
910
+
}
911
+
}
912
+
true
913
+
}
914
+
(Scope::Repo(a), Scope::Repo(b)) => {
915
+
let collection_match = match (&a.collection, &b.collection) {
916
+
(RepoCollection::All, _) => true,
917
+
(RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
918
+
a_nsid == b_nsid
919
+
}
920
+
_ => false,
921
+
};
922
+
923
+
if !collection_match {
924
+
return false;
925
+
}
926
+
927
+
b.actions.is_subset(&a.actions) || a.actions.len() == 3
928
+
}
929
+
(Scope::Rpc(a), Scope::Rpc(b)) => {
930
+
let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
931
+
true
932
+
} else {
933
+
b.lxm.iter().all(|b_lxm| match b_lxm {
934
+
RpcLexicon::All => false,
935
+
RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
936
+
})
937
+
};
938
+
939
+
let aud_match = if a.aud.contains(&RpcAudience::All) {
940
+
true
941
+
} else {
942
+
b.aud.iter().all(|b_aud| match b_aud {
943
+
RpcAudience::All => false,
944
+
RpcAudience::Did(_) => a.aud.contains(b_aud),
945
+
})
946
+
};
947
+
948
+
lxm_match && aud_match
949
+
}
950
+
_ => false,
951
+
}
952
+
}
953
+
}
954
+
955
+
impl MimePattern<'_> {
956
+
fn grants(&self, other: &MimePattern) -> bool {
957
+
match (self, other) {
958
+
(MimePattern::All, _) => true,
959
+
(MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
960
+
a_type == b_type
961
+
}
962
+
(MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
963
+
b_mime.starts_with(&format!("{}/", a_type))
964
+
}
965
+
(MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
966
+
_ => false,
967
+
}
968
+
}
969
+
}
970
+
971
+
impl FromStr for MimePattern<'_> {
972
+
type Err = ParseError;
973
+
974
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
975
+
if s == "*/*" {
976
+
Ok(MimePattern::All)
977
+
} else if let Some(stripped) = s.strip_suffix("/*") {
978
+
Ok(MimePattern::TypeWildcard(CowStr::Owned(
979
+
stripped.to_smolstr(),
980
+
)))
981
+
} else if s.contains('/') {
982
+
Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr())))
983
+
} else {
984
+
Err(ParseError::InvalidMimeType(s.to_string()))
985
+
}
986
+
}
987
+
}
988
+
989
+
impl FromStr for Scope<'_> {
990
+
type Err = ParseError;
991
+
992
+
fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> {
993
+
match Scope::parse(s) {
994
+
Ok(parsed) => Ok(parsed.into_static()),
995
+
Err(e) => Err(e),
996
+
}
997
+
}
998
+
}
999
+
1000
+
impl fmt::Display for Scope<'_> {
1001
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1002
+
write!(f, "{}", self.to_string_normalized())
1003
+
}
1004
+
}
1005
+
1006
+
/// Parse a query string into a map of keys to lists of values
1007
+
fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> {
1008
+
let mut params = BTreeMap::new();
1009
+
1010
+
for pair in query.split('&') {
1011
+
if let Some(pos) = pair.find('=') {
1012
+
let key = &pair[..pos];
1013
+
let value = &pair[pos + 1..];
1014
+
params
1015
+
.entry(key.to_smolstr())
1016
+
.or_insert_with(Vec::new)
1017
+
.push(CowStr::Owned(value.to_smolstr()));
1018
+
}
1019
+
}
1020
+
1021
+
params
1022
+
}
1023
+
1024
+
/// Error type for scope parsing
1025
+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
1026
+
pub enum ParseError {
1027
+
/// Unknown scope prefix
1028
+
UnknownPrefix(String),
1029
+
/// Missing required resource
1030
+
MissingResource,
1031
+
/// Invalid resource type
1032
+
InvalidResource(String),
1033
+
/// Invalid action type
1034
+
InvalidAction(String),
1035
+
/// Invalid MIME type
1036
+
InvalidMimeType(String),
1037
+
ParseError(#[from] AtStrError),
1038
+
}
1039
+
1040
+
impl fmt::Display for ParseError {
1041
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1042
+
match self {
1043
+
ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
1044
+
ParseError::MissingResource => write!(f, "Missing required resource"),
1045
+
ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
1046
+
ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
1047
+
ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
1048
+
ParseError::ParseError(err) => write!(f, "Parse error: {}", err),
1049
+
}
1050
+
}
1051
+
}
1052
+
1053
+
#[cfg(test)]
1054
+
mod tests {
1055
+
use super::*;
1056
+
1057
+
#[test]
1058
+
fn test_account_scope_parsing() {
1059
+
let scope = Scope::parse("account:email").unwrap();
1060
+
assert_eq!(
1061
+
scope,
1062
+
Scope::Account(AccountScope {
1063
+
resource: AccountResource::Email,
1064
+
action: AccountAction::Read,
1065
+
})
1066
+
);
1067
+
1068
+
let scope = Scope::parse("account:repo?action=manage").unwrap();
1069
+
assert_eq!(
1070
+
scope,
1071
+
Scope::Account(AccountScope {
1072
+
resource: AccountResource::Repo,
1073
+
action: AccountAction::Manage,
1074
+
})
1075
+
);
1076
+
1077
+
let scope = Scope::parse("account:status?action=read").unwrap();
1078
+
assert_eq!(
1079
+
scope,
1080
+
Scope::Account(AccountScope {
1081
+
resource: AccountResource::Status,
1082
+
action: AccountAction::Read,
1083
+
})
1084
+
);
1085
+
}
1086
+
1087
+
#[test]
1088
+
fn test_identity_scope_parsing() {
1089
+
let scope = Scope::parse("identity:handle").unwrap();
1090
+
assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
1091
+
1092
+
let scope = Scope::parse("identity:*").unwrap();
1093
+
assert_eq!(scope, Scope::Identity(IdentityScope::All));
1094
+
}
1095
+
1096
+
#[test]
1097
+
fn test_blob_scope_parsing() {
1098
+
let scope = Scope::parse("blob:*/*").unwrap();
1099
+
let mut accept = BTreeSet::new();
1100
+
accept.insert(MimePattern::All);
1101
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1102
+
1103
+
let scope = Scope::parse("blob:image/png").unwrap();
1104
+
let mut accept = BTreeSet::new();
1105
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1106
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1107
+
1108
+
let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
1109
+
let mut accept = BTreeSet::new();
1110
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1111
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg")));
1112
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1113
+
1114
+
let scope = Scope::parse("blob:image/*").unwrap();
1115
+
let mut accept = BTreeSet::new();
1116
+
accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image")));
1117
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1118
+
}
1119
+
1120
+
#[test]
1121
+
fn test_repo_scope_parsing() {
1122
+
let scope = Scope::parse("repo:*?action=create").unwrap();
1123
+
let mut actions = BTreeSet::new();
1124
+
actions.insert(RepoAction::Create);
1125
+
assert_eq!(
1126
+
scope,
1127
+
Scope::Repo(RepoScope {
1128
+
collection: RepoCollection::All,
1129
+
actions,
1130
+
})
1131
+
);
1132
+
1133
+
let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap();
1134
+
let mut actions = BTreeSet::new();
1135
+
actions.insert(RepoAction::Create);
1136
+
actions.insert(RepoAction::Update);
1137
+
assert_eq!(
1138
+
scope,
1139
+
Scope::Repo(RepoScope {
1140
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1141
+
actions,
1142
+
})
1143
+
);
1144
+
1145
+
let scope = Scope::parse("repo:app.bsky.feed.post").unwrap();
1146
+
let mut actions = BTreeSet::new();
1147
+
actions.insert(RepoAction::Create);
1148
+
actions.insert(RepoAction::Update);
1149
+
actions.insert(RepoAction::Delete);
1150
+
assert_eq!(
1151
+
scope,
1152
+
Scope::Repo(RepoScope {
1153
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1154
+
actions,
1155
+
})
1156
+
);
1157
+
}
1158
+
1159
+
#[test]
1160
+
fn test_rpc_scope_parsing() {
1161
+
let scope = Scope::parse("rpc:*").unwrap();
1162
+
let mut lxm = BTreeSet::new();
1163
+
let mut aud = BTreeSet::new();
1164
+
lxm.insert(RpcLexicon::All);
1165
+
aud.insert(RpcAudience::All);
1166
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1167
+
1168
+
let scope = Scope::parse("rpc:com.example.service").unwrap();
1169
+
let mut lxm = BTreeSet::new();
1170
+
let mut aud = BTreeSet::new();
1171
+
lxm.insert(RpcLexicon::Nsid(
1172
+
Nsid::new_static("com.example.service").unwrap(),
1173
+
));
1174
+
aud.insert(RpcAudience::All);
1175
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1176
+
1177
+
let scope =
1178
+
Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap();
1179
+
let mut lxm = BTreeSet::new();
1180
+
let mut aud = BTreeSet::new();
1181
+
lxm.insert(RpcLexicon::Nsid(
1182
+
Nsid::new_static("com.example.service").unwrap(),
1183
+
));
1184
+
aud.insert(RpcAudience::Did(
1185
+
Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1186
+
));
1187
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1188
+
1189
+
let scope =
1190
+
Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g")
1191
+
.unwrap();
1192
+
let mut lxm = BTreeSet::new();
1193
+
let mut aud = BTreeSet::new();
1194
+
lxm.insert(RpcLexicon::Nsid(
1195
+
Nsid::new_static("com.example.method1").unwrap(),
1196
+
));
1197
+
lxm.insert(RpcLexicon::Nsid(
1198
+
Nsid::new_static("com.example.method2").unwrap(),
1199
+
));
1200
+
aud.insert(RpcAudience::Did(
1201
+
Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1202
+
));
1203
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1204
+
}
1205
+
1206
+
#[test]
1207
+
fn test_scope_normalization() {
1208
+
let tests = vec![
1209
+
("account:email", "account:email"),
1210
+
("account:email?action=read", "account:email"),
1211
+
("account:email?action=manage", "account:email?action=manage"),
1212
+
("blob:image/png", "blob:image/png"),
1213
+
(
1214
+
"blob?accept=image/jpeg&accept=image/png",
1215
+
"blob?accept=image/jpeg&accept=image/png",
1216
+
),
1217
+
("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"),
1218
+
(
1219
+
"repo:app.bsky.feed.post?action=create",
1220
+
"repo:app.bsky.feed.post?action=create",
1221
+
),
1222
+
("rpc:*", "rpc:*"),
1223
+
];
1224
+
1225
+
for (input, expected) in tests {
1226
+
let scope = Scope::parse(input).unwrap();
1227
+
assert_eq!(scope.to_string_normalized(), expected);
1228
+
}
1229
+
}
1230
+
1231
+
#[test]
1232
+
fn test_account_scope_grants() {
1233
+
let manage = Scope::parse("account:email?action=manage").unwrap();
1234
+
let read = Scope::parse("account:email?action=read").unwrap();
1235
+
let other_read = Scope::parse("account:repo?action=read").unwrap();
1236
+
1237
+
assert!(manage.grants(&read));
1238
+
assert!(manage.grants(&manage));
1239
+
assert!(!read.grants(&manage));
1240
+
assert!(read.grants(&read));
1241
+
assert!(!read.grants(&other_read));
1242
+
}
1243
+
1244
+
#[test]
1245
+
fn test_identity_scope_grants() {
1246
+
let all = Scope::parse("identity:*").unwrap();
1247
+
let handle = Scope::parse("identity:handle").unwrap();
1248
+
1249
+
assert!(all.grants(&handle));
1250
+
assert!(all.grants(&all));
1251
+
assert!(!handle.grants(&all));
1252
+
assert!(handle.grants(&handle));
1253
+
}
1254
+
1255
+
#[test]
1256
+
fn test_blob_scope_grants() {
1257
+
let all = Scope::parse("blob:*/*").unwrap();
1258
+
let image_all = Scope::parse("blob:image/*").unwrap();
1259
+
let image_png = Scope::parse("blob:image/png").unwrap();
1260
+
let text_plain = Scope::parse("blob:text/plain").unwrap();
1261
+
1262
+
assert!(all.grants(&image_all));
1263
+
assert!(all.grants(&image_png));
1264
+
assert!(all.grants(&text_plain));
1265
+
assert!(image_all.grants(&image_png));
1266
+
assert!(!image_all.grants(&text_plain));
1267
+
assert!(!image_png.grants(&image_all));
1268
+
}
1269
+
1270
+
#[test]
1271
+
fn test_repo_scope_grants() {
1272
+
let all_all = Scope::parse("repo:*").unwrap();
1273
+
let all_create = Scope::parse("repo:*?action=create").unwrap();
1274
+
let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap();
1275
+
let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap();
1276
+
let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap();
1277
+
1278
+
assert!(all_all.grants(&all_create));
1279
+
assert!(all_all.grants(&specific_all));
1280
+
assert!(all_all.grants(&specific_create));
1281
+
assert!(all_create.grants(&all_create));
1282
+
assert!(!all_create.grants(&specific_all));
1283
+
assert!(specific_all.grants(&specific_create));
1284
+
assert!(!specific_create.grants(&specific_all));
1285
+
assert!(!specific_create.grants(&other_create));
1286
+
}
1287
+
1288
+
#[test]
1289
+
fn test_rpc_scope_grants() {
1290
+
let all = Scope::parse("rpc:*").unwrap();
1291
+
let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
1292
+
let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1293
+
1294
+
assert!(all.grants(&specific_lxm));
1295
+
assert!(all.grants(&specific_both));
1296
+
assert!(specific_lxm.grants(&specific_both));
1297
+
assert!(!specific_both.grants(&specific_lxm));
1298
+
assert!(!specific_both.grants(&all));
1299
+
}
1300
+
1301
+
#[test]
1302
+
fn test_cross_scope_grants() {
1303
+
let account = Scope::parse("account:email").unwrap();
1304
+
let identity = Scope::parse("identity:handle").unwrap();
1305
+
1306
+
assert!(!account.grants(&identity));
1307
+
assert!(!identity.grants(&account));
1308
+
}
1309
+
1310
+
#[test]
1311
+
fn test_parse_errors() {
1312
+
assert!(matches!(
1313
+
Scope::parse("unknown:test"),
1314
+
Err(ParseError::UnknownPrefix(_))
1315
+
));
1316
+
1317
+
assert!(matches!(
1318
+
Scope::parse("account"),
1319
+
Err(ParseError::MissingResource)
1320
+
));
1321
+
1322
+
assert!(matches!(
1323
+
Scope::parse("account:invalid"),
1324
+
Err(ParseError::InvalidResource(_))
1325
+
));
1326
+
1327
+
assert!(matches!(
1328
+
Scope::parse("account:email?action=invalid"),
1329
+
Err(ParseError::InvalidAction(_))
1330
+
));
1331
+
}
1332
+
1333
+
#[test]
1334
+
fn test_query_parameter_sorting() {
1335
+
let scope =
1336
+
Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
1337
+
let normalized = scope.to_string_normalized();
1338
+
assert!(normalized.contains("accept=application/pdf"));
1339
+
assert!(normalized.contains("accept=image/jpeg"));
1340
+
assert!(normalized.contains("accept=image/png"));
1341
+
let pdf_pos = normalized.find("accept=application/pdf").unwrap();
1342
+
let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
1343
+
let png_pos = normalized.find("accept=image/png").unwrap();
1344
+
assert!(pdf_pos < jpeg_pos);
1345
+
assert!(jpeg_pos < png_pos);
1346
+
}
1347
+
1348
+
#[test]
1349
+
fn test_repo_action_wildcard() {
1350
+
let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap();
1351
+
let mut actions = BTreeSet::new();
1352
+
actions.insert(RepoAction::Create);
1353
+
actions.insert(RepoAction::Update);
1354
+
actions.insert(RepoAction::Delete);
1355
+
assert_eq!(
1356
+
scope,
1357
+
Scope::Repo(RepoScope {
1358
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1359
+
actions,
1360
+
})
1361
+
);
1362
+
}
1363
+
1364
+
#[test]
1365
+
fn test_multiple_blob_accepts() {
1366
+
let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
1367
+
assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
1368
+
assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
1369
+
assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
1370
+
}
1371
+
1372
+
#[test]
1373
+
fn test_rpc_default_wildcards() {
1374
+
let scope = Scope::parse("rpc").unwrap();
1375
+
let mut lxm = BTreeSet::new();
1376
+
let mut aud = BTreeSet::new();
1377
+
lxm.insert(RpcLexicon::All);
1378
+
aud.insert(RpcAudience::All);
1379
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1380
+
}
1381
+
1382
+
#[test]
1383
+
fn test_atproto_scope_parsing() {
1384
+
let scope = Scope::parse("atproto").unwrap();
1385
+
assert_eq!(scope, Scope::Atproto);
1386
+
1387
+
// Atproto should not accept suffixes
1388
+
assert!(Scope::parse("atproto:something").is_err());
1389
+
assert!(Scope::parse("atproto?param=value").is_err());
1390
+
}
1391
+
1392
+
#[test]
1393
+
fn test_transition_scope_parsing() {
1394
+
let scope = Scope::parse("transition:generic").unwrap();
1395
+
assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
1396
+
1397
+
let scope = Scope::parse("transition:email").unwrap();
1398
+
assert_eq!(scope, Scope::Transition(TransitionScope::Email));
1399
+
1400
+
// Test invalid transition types
1401
+
assert!(matches!(
1402
+
Scope::parse("transition:invalid"),
1403
+
Err(ParseError::InvalidResource(_))
1404
+
));
1405
+
1406
+
// Test missing suffix
1407
+
assert!(matches!(
1408
+
Scope::parse("transition"),
1409
+
Err(ParseError::MissingResource)
1410
+
));
1411
+
1412
+
// Test transition doesn't accept query parameters
1413
+
assert!(matches!(
1414
+
Scope::parse("transition:generic?param=value"),
1415
+
Err(ParseError::InvalidResource(_))
1416
+
));
1417
+
}
1418
+
1419
+
#[test]
1420
+
fn test_atproto_scope_normalization() {
1421
+
let scope = Scope::parse("atproto").unwrap();
1422
+
assert_eq!(scope.to_string_normalized(), "atproto");
1423
+
}
1424
+
1425
+
#[test]
1426
+
fn test_transition_scope_normalization() {
1427
+
let tests = vec![
1428
+
("transition:generic", "transition:generic"),
1429
+
("transition:email", "transition:email"),
1430
+
];
1431
+
1432
+
for (input, expected) in tests {
1433
+
let scope = Scope::parse(input).unwrap();
1434
+
assert_eq!(scope.to_string_normalized(), expected);
1435
+
}
1436
+
}
1437
+
1438
+
#[test]
1439
+
fn test_atproto_scope_grants() {
1440
+
let atproto = Scope::parse("atproto").unwrap();
1441
+
let account = Scope::parse("account:email").unwrap();
1442
+
let identity = Scope::parse("identity:handle").unwrap();
1443
+
let blob = Scope::parse("blob:image/png").unwrap();
1444
+
let repo = Scope::parse("repo:app.bsky.feed.post").unwrap();
1445
+
let rpc = Scope::parse("rpc:com.example.service").unwrap();
1446
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1447
+
let transition_email = Scope::parse("transition:email").unwrap();
1448
+
1449
+
// Atproto only grants itself (it's a required scope, not a permission grant)
1450
+
assert!(atproto.grants(&atproto));
1451
+
assert!(!atproto.grants(&account));
1452
+
assert!(!atproto.grants(&identity));
1453
+
assert!(!atproto.grants(&blob));
1454
+
assert!(!atproto.grants(&repo));
1455
+
assert!(!atproto.grants(&rpc));
1456
+
assert!(!atproto.grants(&transition_generic));
1457
+
assert!(!atproto.grants(&transition_email));
1458
+
1459
+
// Nothing else grants atproto
1460
+
assert!(!account.grants(&atproto));
1461
+
assert!(!identity.grants(&atproto));
1462
+
assert!(!blob.grants(&atproto));
1463
+
assert!(!repo.grants(&atproto));
1464
+
assert!(!rpc.grants(&atproto));
1465
+
assert!(!transition_generic.grants(&atproto));
1466
+
assert!(!transition_email.grants(&atproto));
1467
+
}
1468
+
1469
+
#[test]
1470
+
fn test_transition_scope_grants() {
1471
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1472
+
let transition_email = Scope::parse("transition:email").unwrap();
1473
+
let account = Scope::parse("account:email").unwrap();
1474
+
1475
+
// Transition scopes only grant themselves
1476
+
assert!(transition_generic.grants(&transition_generic));
1477
+
assert!(transition_email.grants(&transition_email));
1478
+
assert!(!transition_generic.grants(&transition_email));
1479
+
assert!(!transition_email.grants(&transition_generic));
1480
+
1481
+
// Transition scopes don't grant other scope types
1482
+
assert!(!transition_generic.grants(&account));
1483
+
assert!(!transition_email.grants(&account));
1484
+
1485
+
// Other scopes don't grant transition scopes
1486
+
assert!(!account.grants(&transition_generic));
1487
+
assert!(!account.grants(&transition_email));
1488
+
}
1489
+
1490
+
#[test]
1491
+
fn test_parse_multiple() {
1492
+
// Test parsing multiple scopes
1493
+
let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
1494
+
assert_eq!(scopes.len(), 2);
1495
+
assert_eq!(scopes[0], Scope::Atproto);
1496
+
assert_eq!(
1497
+
scopes[1],
1498
+
Scope::Repo(RepoScope {
1499
+
collection: RepoCollection::All,
1500
+
actions: {
1501
+
let mut actions = BTreeSet::new();
1502
+
actions.insert(RepoAction::Create);
1503
+
actions.insert(RepoAction::Update);
1504
+
actions.insert(RepoAction::Delete);
1505
+
actions
1506
+
}
1507
+
})
1508
+
);
1509
+
1510
+
// Test with more scopes
1511
+
let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
1512
+
assert_eq!(scopes.len(), 3);
1513
+
assert!(matches!(scopes[0], Scope::Account(_)));
1514
+
assert!(matches!(scopes[1], Scope::Identity(_)));
1515
+
assert!(matches!(scopes[2], Scope::Blob(_)));
1516
+
1517
+
// Test with complex scopes
1518
+
let scopes = Scope::parse_multiple(
1519
+
"account:email?action=manage repo:app.bsky.feed.post?action=create transition:email",
1520
+
)
1521
+
.unwrap();
1522
+
assert_eq!(scopes.len(), 3);
1523
+
1524
+
// Test empty string
1525
+
let scopes = Scope::parse_multiple("").unwrap();
1526
+
assert_eq!(scopes.len(), 0);
1527
+
1528
+
// Test whitespace only
1529
+
let scopes = Scope::parse_multiple(" ").unwrap();
1530
+
assert_eq!(scopes.len(), 0);
1531
+
1532
+
// Test with extra whitespace
1533
+
let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
1534
+
assert_eq!(scopes.len(), 2);
1535
+
1536
+
// Test single scope
1537
+
let scopes = Scope::parse_multiple("atproto").unwrap();
1538
+
assert_eq!(scopes.len(), 1);
1539
+
assert_eq!(scopes[0], Scope::Atproto);
1540
+
1541
+
// Test error propagation
1542
+
assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
1543
+
assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
1544
+
}
1545
+
1546
+
#[test]
1547
+
fn test_parse_multiple_reduced() {
1548
+
// Test repo scope reduction - wildcard grants specific
1549
+
let scopes =
1550
+
Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
1551
+
assert_eq!(scopes.len(), 2);
1552
+
assert!(scopes.contains(&Scope::Atproto));
1553
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1554
+
collection: RepoCollection::All,
1555
+
actions: {
1556
+
let mut actions = BTreeSet::new();
1557
+
actions.insert(RepoAction::Create);
1558
+
actions.insert(RepoAction::Update);
1559
+
actions.insert(RepoAction::Delete);
1560
+
actions
1561
+
}
1562
+
})));
1563
+
1564
+
// Test reverse order - should get same result
1565
+
let scopes =
1566
+
Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap();
1567
+
assert_eq!(scopes.len(), 2);
1568
+
assert!(scopes.contains(&Scope::Atproto));
1569
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1570
+
collection: RepoCollection::All,
1571
+
actions: {
1572
+
let mut actions = BTreeSet::new();
1573
+
actions.insert(RepoAction::Create);
1574
+
actions.insert(RepoAction::Update);
1575
+
actions.insert(RepoAction::Delete);
1576
+
actions
1577
+
}
1578
+
})));
1579
+
1580
+
// Test account scope reduction - manage grants read
1581
+
let scopes =
1582
+
Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
1583
+
assert_eq!(scopes.len(), 1);
1584
+
assert_eq!(
1585
+
scopes[0],
1586
+
Scope::Account(AccountScope {
1587
+
resource: AccountResource::Email,
1588
+
action: AccountAction::Manage,
1589
+
})
1590
+
);
1591
+
1592
+
// Test identity scope reduction - wildcard grants specific
1593
+
let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
1594
+
assert_eq!(scopes.len(), 1);
1595
+
assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
1596
+
1597
+
// Test blob scope reduction - wildcard grants specific
1598
+
let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
1599
+
assert_eq!(scopes.len(), 1);
1600
+
let mut accept = BTreeSet::new();
1601
+
accept.insert(MimePattern::All);
1602
+
assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
1603
+
1604
+
// Test no reduction needed - different scope types
1605
+
let scopes =
1606
+
Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
1607
+
assert_eq!(scopes.len(), 3);
1608
+
1609
+
// Test repo action reduction
1610
+
let scopes = Scope::parse_multiple_reduced(
1611
+
"repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post",
1612
+
)
1613
+
.unwrap();
1614
+
assert_eq!(scopes.len(), 1);
1615
+
assert_eq!(
1616
+
scopes[0],
1617
+
Scope::Repo(RepoScope {
1618
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1619
+
actions: {
1620
+
let mut actions = BTreeSet::new();
1621
+
actions.insert(RepoAction::Create);
1622
+
actions.insert(RepoAction::Update);
1623
+
actions.insert(RepoAction::Delete);
1624
+
actions
1625
+
}
1626
+
})
1627
+
);
1628
+
1629
+
// Test RPC scope reduction
1630
+
let scopes = Scope::parse_multiple_reduced(
1631
+
"rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
1632
+
)
1633
+
.unwrap();
1634
+
assert_eq!(scopes.len(), 1);
1635
+
assert_eq!(
1636
+
scopes[0],
1637
+
Scope::Rpc(RpcScope {
1638
+
lxm: {
1639
+
let mut lxm = BTreeSet::new();
1640
+
lxm.insert(RpcLexicon::All);
1641
+
lxm
1642
+
},
1643
+
aud: {
1644
+
let mut aud = BTreeSet::new();
1645
+
aud.insert(RpcAudience::All);
1646
+
aud
1647
+
}
1648
+
})
1649
+
);
1650
+
1651
+
// Test duplicate removal
1652
+
let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
1653
+
assert_eq!(scopes.len(), 1);
1654
+
assert_eq!(scopes[0], Scope::Atproto);
1655
+
1656
+
// Test transition scopes - only grant themselves
1657
+
let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
1658
+
assert_eq!(scopes.len(), 2);
1659
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
1660
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
1661
+
1662
+
// Test empty input
1663
+
let scopes = Scope::parse_multiple_reduced("").unwrap();
1664
+
assert_eq!(scopes.len(), 0);
1665
+
1666
+
// Test complex scenario with multiple reductions
1667
+
let scopes = Scope::parse_multiple_reduced(
1668
+
"account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
1669
+
).unwrap();
1670
+
assert_eq!(scopes.len(), 3);
1671
+
// Should have: account:email?action=manage, account:repo, identity:*
1672
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1673
+
resource: AccountResource::Email,
1674
+
action: AccountAction::Manage,
1675
+
})));
1676
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1677
+
resource: AccountResource::Repo,
1678
+
action: AccountAction::Read,
1679
+
})));
1680
+
assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
1681
+
1682
+
// Test that atproto doesn't grant other scopes (per recent change)
1683
+
let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
1684
+
assert_eq!(scopes.len(), 3);
1685
+
assert!(scopes.contains(&Scope::Atproto));
1686
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1687
+
resource: AccountResource::Email,
1688
+
action: AccountAction::Read,
1689
+
})));
1690
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1691
+
collection: RepoCollection::All,
1692
+
actions: {
1693
+
let mut actions = BTreeSet::new();
1694
+
actions.insert(RepoAction::Create);
1695
+
actions.insert(RepoAction::Update);
1696
+
actions.insert(RepoAction::Delete);
1697
+
actions
1698
+
}
1699
+
})));
1700
+
}
1701
+
1702
+
#[test]
1703
+
fn test_openid_connect_scope_parsing() {
1704
+
// Test OpenID scope
1705
+
let scope = Scope::parse("openid").unwrap();
1706
+
assert_eq!(scope, Scope::OpenId);
1707
+
1708
+
// Test Profile scope
1709
+
let scope = Scope::parse("profile").unwrap();
1710
+
assert_eq!(scope, Scope::Profile);
1711
+
1712
+
// Test Email scope
1713
+
let scope = Scope::parse("email").unwrap();
1714
+
assert_eq!(scope, Scope::Email);
1715
+
1716
+
// Test that they don't accept suffixes
1717
+
assert!(Scope::parse("openid:something").is_err());
1718
+
assert!(Scope::parse("profile:something").is_err());
1719
+
assert!(Scope::parse("email:something").is_err());
1720
+
1721
+
// Test that they don't accept query parameters
1722
+
assert!(Scope::parse("openid?param=value").is_err());
1723
+
assert!(Scope::parse("profile?param=value").is_err());
1724
+
assert!(Scope::parse("email?param=value").is_err());
1725
+
}
1726
+
1727
+
#[test]
1728
+
fn test_openid_connect_scope_normalization() {
1729
+
let scope = Scope::parse("openid").unwrap();
1730
+
assert_eq!(scope.to_string_normalized(), "openid");
1731
+
1732
+
let scope = Scope::parse("profile").unwrap();
1733
+
assert_eq!(scope.to_string_normalized(), "profile");
1734
+
1735
+
let scope = Scope::parse("email").unwrap();
1736
+
assert_eq!(scope.to_string_normalized(), "email");
1737
+
}
1738
+
1739
+
#[test]
1740
+
fn test_openid_connect_scope_grants() {
1741
+
let openid = Scope::parse("openid").unwrap();
1742
+
let profile = Scope::parse("profile").unwrap();
1743
+
let email = Scope::parse("email").unwrap();
1744
+
let account = Scope::parse("account:email").unwrap();
1745
+
1746
+
// OpenID Connect scopes only grant themselves
1747
+
assert!(openid.grants(&openid));
1748
+
assert!(!openid.grants(&profile));
1749
+
assert!(!openid.grants(&email));
1750
+
assert!(!openid.grants(&account));
1751
+
1752
+
assert!(profile.grants(&profile));
1753
+
assert!(!profile.grants(&openid));
1754
+
assert!(!profile.grants(&email));
1755
+
assert!(!profile.grants(&account));
1756
+
1757
+
assert!(email.grants(&email));
1758
+
assert!(!email.grants(&openid));
1759
+
assert!(!email.grants(&profile));
1760
+
assert!(!email.grants(&account));
1761
+
1762
+
// Other scopes don't grant OpenID Connect scopes
1763
+
assert!(!account.grants(&openid));
1764
+
assert!(!account.grants(&profile));
1765
+
assert!(!account.grants(&email));
1766
+
}
1767
+
1768
+
#[test]
1769
+
fn test_parse_multiple_with_openid_connect() {
1770
+
let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
1771
+
assert_eq!(scopes.len(), 4);
1772
+
assert_eq!(scopes[0], Scope::OpenId);
1773
+
assert_eq!(scopes[1], Scope::Profile);
1774
+
assert_eq!(scopes[2], Scope::Email);
1775
+
assert_eq!(scopes[3], Scope::Atproto);
1776
+
1777
+
// Test with mixed scopes
1778
+
let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
1779
+
assert_eq!(scopes.len(), 4);
1780
+
assert!(scopes.contains(&Scope::OpenId));
1781
+
assert!(scopes.contains(&Scope::Profile));
1782
+
}
1783
+
1784
+
#[test]
1785
+
fn test_parse_multiple_reduced_with_openid_connect() {
1786
+
// OpenID Connect scopes don't grant each other, so no reduction
1787
+
let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
1788
+
assert_eq!(scopes.len(), 3);
1789
+
assert!(scopes.contains(&Scope::OpenId));
1790
+
assert!(scopes.contains(&Scope::Profile));
1791
+
assert!(scopes.contains(&Scope::Email));
1792
+
1793
+
// Mixed with other scopes
1794
+
let scopes = Scope::parse_multiple_reduced(
1795
+
"openid account:email account:email?action=manage profile",
1796
+
)
1797
+
.unwrap();
1798
+
assert_eq!(scopes.len(), 3);
1799
+
assert!(scopes.contains(&Scope::OpenId));
1800
+
assert!(scopes.contains(&Scope::Profile));
1801
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1802
+
resource: AccountResource::Email,
1803
+
action: AccountAction::Manage,
1804
+
})));
1805
+
}
1806
+
1807
+
#[test]
1808
+
fn test_serialize_multiple() {
1809
+
// Test empty list
1810
+
let scopes: Vec<Scope> = vec![];
1811
+
assert_eq!(Scope::serialize_multiple(&scopes), "");
1812
+
1813
+
// Test single scope
1814
+
let scopes = vec![Scope::Atproto];
1815
+
assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
1816
+
1817
+
// Test multiple scopes - should be sorted alphabetically
1818
+
let scopes = vec![
1819
+
Scope::parse("repo:*").unwrap(),
1820
+
Scope::Atproto,
1821
+
Scope::parse("account:email").unwrap(),
1822
+
];
1823
+
assert_eq!(
1824
+
Scope::serialize_multiple(&scopes),
1825
+
"account:email atproto repo:*"
1826
+
);
1827
+
1828
+
// Test that sorting is consistent regardless of input order
1829
+
let scopes = vec![
1830
+
Scope::parse("identity:handle").unwrap(),
1831
+
Scope::parse("blob:image/png").unwrap(),
1832
+
Scope::parse("account:repo?action=manage").unwrap(),
1833
+
];
1834
+
assert_eq!(
1835
+
Scope::serialize_multiple(&scopes),
1836
+
"account:repo?action=manage blob:image/png identity:handle"
1837
+
);
1838
+
1839
+
// Test with OpenID Connect scopes
1840
+
let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
1841
+
assert_eq!(
1842
+
Scope::serialize_multiple(&scopes),
1843
+
"atproto email openid profile"
1844
+
);
1845
+
1846
+
// Test with complex scopes including query parameters
1847
+
let scopes = vec![
1848
+
Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method")
1849
+
.unwrap(),
1850
+
Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(),
1851
+
Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1852
+
];
1853
+
let result = Scope::serialize_multiple(&scopes);
1854
+
// The result should be sorted alphabetically
1855
+
// Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1856
+
assert!(result.starts_with("blob:"));
1857
+
assert!(result.contains(" repo:"));
1858
+
assert!(
1859
+
result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service")
1860
+
);
1861
+
1862
+
// Test with transition scopes
1863
+
let scopes = vec![
1864
+
Scope::Transition(TransitionScope::Email),
1865
+
Scope::Transition(TransitionScope::Generic),
1866
+
Scope::Atproto,
1867
+
];
1868
+
assert_eq!(
1869
+
Scope::serialize_multiple(&scopes),
1870
+
"atproto transition:email transition:generic"
1871
+
);
1872
+
1873
+
// Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
1874
+
let scopes = vec![
1875
+
Scope::Atproto,
1876
+
Scope::Atproto,
1877
+
Scope::parse("account:email").unwrap(),
1878
+
];
1879
+
assert_eq!(
1880
+
Scope::serialize_multiple(&scopes),
1881
+
"account:email atproto atproto"
1882
+
);
1883
+
1884
+
// Test normalization is preserved in serialization
1885
+
let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
1886
+
// Should normalize query parameters alphabetically
1887
+
assert_eq!(
1888
+
Scope::serialize_multiple(&scopes),
1889
+
"blob?accept=image/jpeg&accept=image/png"
1890
+
);
1891
+
}
1892
+
1893
+
#[test]
1894
+
fn test_serialize_multiple_roundtrip() {
1895
+
// Test that parse_multiple and serialize_multiple are inverses (when sorted)
1896
+
let original = "account:email atproto blob:image/png identity:handle repo:*";
1897
+
let scopes = Scope::parse_multiple(original).unwrap();
1898
+
let serialized = Scope::serialize_multiple(&scopes);
1899
+
assert_eq!(serialized, original);
1900
+
1901
+
// Test with complex scopes
1902
+
let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
1903
+
let scopes = Scope::parse_multiple(original).unwrap();
1904
+
let serialized = Scope::serialize_multiple(&scopes);
1905
+
// Parse again to verify it's valid
1906
+
let reparsed = Scope::parse_multiple(&serialized).unwrap();
1907
+
assert_eq!(scopes, reparsed);
1908
+
1909
+
// Test with OpenID Connect scopes
1910
+
let original = "email openid profile";
1911
+
let scopes = Scope::parse_multiple(original).unwrap();
1912
+
let serialized = Scope::serialize_multiple(&scopes);
1913
+
assert_eq!(serialized, original);
1914
+
}
1915
+
1916
+
#[test]
1917
+
fn test_remove_scope() {
1918
+
// Test removing a scope that exists
1919
+
let scopes = vec![
1920
+
Scope::parse("repo:*").unwrap(),
1921
+
Scope::Atproto,
1922
+
Scope::parse("account:email").unwrap(),
1923
+
];
1924
+
let to_remove = Scope::Atproto;
1925
+
let result = Scope::remove_scope(&scopes, &to_remove);
1926
+
assert_eq!(result.len(), 2);
1927
+
assert!(!result.contains(&to_remove));
1928
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1929
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1930
+
1931
+
// Test removing a scope that doesn't exist
1932
+
let scopes = vec![
1933
+
Scope::parse("repo:*").unwrap(),
1934
+
Scope::parse("account:email").unwrap(),
1935
+
];
1936
+
let to_remove = Scope::parse("identity:handle").unwrap();
1937
+
let result = Scope::remove_scope(&scopes, &to_remove);
1938
+
assert_eq!(result.len(), 2);
1939
+
assert_eq!(result, scopes);
1940
+
1941
+
// Test removing from empty list
1942
+
let scopes: Vec<Scope> = vec![];
1943
+
let to_remove = Scope::Atproto;
1944
+
let result = Scope::remove_scope(&scopes, &to_remove);
1945
+
assert_eq!(result.len(), 0);
1946
+
1947
+
// Test removing all instances of a duplicate scope
1948
+
let scopes = vec![
1949
+
Scope::Atproto,
1950
+
Scope::parse("account:email").unwrap(),
1951
+
Scope::Atproto,
1952
+
Scope::parse("repo:*").unwrap(),
1953
+
Scope::Atproto,
1954
+
];
1955
+
let to_remove = Scope::Atproto;
1956
+
let result = Scope::remove_scope(&scopes, &to_remove);
1957
+
assert_eq!(result.len(), 2);
1958
+
assert!(!result.contains(&to_remove));
1959
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1960
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1961
+
1962
+
// Test removing complex scopes with query parameters
1963
+
let scopes = vec![
1964
+
Scope::parse("account:email?action=manage").unwrap(),
1965
+
Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
1966
+
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1967
+
];
1968
+
let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
1969
+
let result = Scope::remove_scope(&scopes, &to_remove);
1970
+
assert_eq!(result.len(), 2);
1971
+
assert!(!result.contains(&to_remove));
1972
+
1973
+
// Test with OpenID Connect scopes
1974
+
let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
1975
+
let to_remove = Scope::Profile;
1976
+
let result = Scope::remove_scope(&scopes, &to_remove);
1977
+
assert_eq!(result.len(), 3);
1978
+
assert!(!result.contains(&to_remove));
1979
+
assert!(result.contains(&Scope::OpenId));
1980
+
assert!(result.contains(&Scope::Email));
1981
+
assert!(result.contains(&Scope::Atproto));
1982
+
1983
+
// Test with transition scopes
1984
+
let scopes = vec![
1985
+
Scope::Transition(TransitionScope::Generic),
1986
+
Scope::Transition(TransitionScope::Email),
1987
+
Scope::Atproto,
1988
+
];
1989
+
let to_remove = Scope::Transition(TransitionScope::Email);
1990
+
let result = Scope::remove_scope(&scopes, &to_remove);
1991
+
assert_eq!(result.len(), 2);
1992
+
assert!(!result.contains(&to_remove));
1993
+
assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
1994
+
assert!(result.contains(&Scope::Atproto));
1995
+
1996
+
// Test that only exact matches are removed
1997
+
let scopes = vec![
1998
+
Scope::parse("account:email").unwrap(),
1999
+
Scope::parse("account:email?action=manage").unwrap(),
2000
+
Scope::parse("account:repo").unwrap(),
2001
+
];
2002
+
let to_remove = Scope::parse("account:email").unwrap();
2003
+
let result = Scope::remove_scope(&scopes, &to_remove);
2004
+
assert_eq!(result.len(), 2);
2005
+
assert!(!result.contains(&Scope::parse("account:email").unwrap()));
2006
+
assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
2007
+
assert!(result.contains(&Scope::parse("account:repo").unwrap()));
2008
+
}
2009
+
}
+374
crates/jacquard-oauth/src/session.rs
+374
crates/jacquard-oauth/src/session.rs
···
1
+
use std::sync::Arc;
2
+
3
+
use crate::{
4
+
atproto::{AtprotoClientMetadata, atproto_client_metadata},
5
+
authstore::ClientAuthStore,
6
+
dpop::DpopExt,
7
+
keyset::Keyset,
8
+
request::{OAuthMetadata, refresh},
9
+
resolver::OAuthResolver,
10
+
scopes::Scope,
11
+
types::TokenSet,
12
+
};
13
+
14
+
use dashmap::DashMap;
15
+
use jacquard_common::{
16
+
CowStr, IntoStatic,
17
+
http_client::HttpClient,
18
+
session::SessionStoreError,
19
+
types::{did::Did, string::Datetime},
20
+
};
21
+
use jose_jwk::Key;
22
+
use serde::{Deserialize, Serialize};
23
+
use smol_str::{SmolStr, format_smolstr};
24
+
use tokio::sync::Mutex;
25
+
use url::Url;
26
+
27
+
pub trait DpopDataSource {
28
+
fn key(&self) -> &Key;
29
+
fn authserver_nonce(&self) -> Option<CowStr<'_>>;
30
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>);
31
+
fn host_nonce(&self) -> Option<CowStr<'_>>;
32
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>);
33
+
}
34
+
35
+
/// Persisted information about an OAuth session. Used to resume an active session.
36
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37
+
pub struct ClientSessionData<'s> {
38
+
// Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this information.
39
+
#[serde(borrow)]
40
+
pub account_did: Did<'s>,
41
+
42
+
// Identifier to distinguish this particular session for the account. Server backends generally support multiple sessions for the same account. This package will re-use the random 'state' token from the auth flow as the session ID.
43
+
pub session_id: CowStr<'s>,
44
+
45
+
// Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
46
+
pub host_url: Url,
47
+
48
+
// Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
49
+
pub authserver_url: Url,
50
+
51
+
// Full token endpoint
52
+
pub authserver_token_endpoint: CowStr<'s>,
53
+
54
+
// Full revocation endpoint, if it exists
55
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
56
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
57
+
58
+
// The set of scopes approved for this session (returned in the initial token request)
59
+
pub scopes: Vec<Scope<'s>>,
60
+
61
+
#[serde(flatten)]
62
+
pub dpop_data: DpopClientData<'s>,
63
+
64
+
#[serde(flatten)]
65
+
pub token_set: TokenSet<'s>,
66
+
}
67
+
68
+
impl IntoStatic for ClientSessionData<'_> {
69
+
type Output = ClientSessionData<'static>;
70
+
71
+
fn into_static(self) -> Self::Output {
72
+
ClientSessionData {
73
+
authserver_url: self.authserver_url,
74
+
authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
75
+
authserver_revocation_endpoint: self
76
+
.authserver_revocation_endpoint
77
+
.map(IntoStatic::into_static),
78
+
scopes: self.scopes.into_static(),
79
+
dpop_data: self.dpop_data.into_static(),
80
+
token_set: self.token_set.into_static(),
81
+
account_did: self.account_did.into_static(),
82
+
session_id: self.session_id.into_static(),
83
+
host_url: self.host_url,
84
+
}
85
+
}
86
+
}
87
+
88
+
impl ClientSessionData<'_> {
89
+
pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) {
90
+
if let Some(Ok(scopes)) = token_set
91
+
.scope
92
+
.as_ref()
93
+
.map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static))
94
+
{
95
+
self.scopes = scopes;
96
+
}
97
+
self.token_set = token_set.into_static();
98
+
}
99
+
}
100
+
101
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102
+
pub struct DpopClientData<'s> {
103
+
pub dpop_key: Key,
104
+
// Current auth server DPoP nonce
105
+
#[serde(borrow)]
106
+
pub dpop_authserver_nonce: CowStr<'s>,
107
+
// Current host ("resource server", eg PDS) DPoP nonce
108
+
pub dpop_host_nonce: CowStr<'s>,
109
+
}
110
+
111
+
impl IntoStatic for DpopClientData<'_> {
112
+
type Output = DpopClientData<'static>;
113
+
114
+
fn into_static(self) -> Self::Output {
115
+
DpopClientData {
116
+
dpop_key: self.dpop_key,
117
+
dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
118
+
dpop_host_nonce: self.dpop_host_nonce.into_static(),
119
+
}
120
+
}
121
+
}
122
+
123
+
impl DpopDataSource for DpopClientData<'_> {
124
+
fn key(&self) -> &Key {
125
+
&self.dpop_key
126
+
}
127
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
128
+
Some(self.dpop_authserver_nonce.clone())
129
+
}
130
+
131
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
132
+
Some(self.dpop_host_nonce.clone())
133
+
}
134
+
135
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
136
+
self.dpop_authserver_nonce = nonce.into_static();
137
+
}
138
+
139
+
fn set_host_nonce(&mut self, nonce: CowStr<'_>) {
140
+
self.dpop_host_nonce = nonce.into_static();
141
+
}
142
+
}
143
+
144
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145
+
pub struct AuthRequestData<'s> {
146
+
// The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
147
+
#[serde(borrow)]
148
+
pub state: CowStr<'s>,
149
+
150
+
// URL of the auth server (eg, PDS or entryway)
151
+
pub authserver_url: Url,
152
+
153
+
// If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
154
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
155
+
pub account_did: Option<Did<'s>>,
156
+
157
+
// OAuth scope strings
158
+
pub scopes: Vec<Scope<'s>>,
159
+
160
+
// unique token in URI format, which will be used by the client in the auth flow redirect
161
+
pub request_uri: CowStr<'s>,
162
+
163
+
// Full token endpoint URL
164
+
pub authserver_token_endpoint: CowStr<'s>,
165
+
166
+
// Full revocation endpoint, if it exists
167
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
168
+
pub authserver_revocation_endpoint: Option<CowStr<'s>>,
169
+
170
+
// The secret token/nonce which a code challenge was generated from
171
+
pub pkce_verifier: CowStr<'s>,
172
+
173
+
#[serde(flatten)]
174
+
pub dpop_data: DpopReqData<'s>,
175
+
}
176
+
177
+
impl IntoStatic for AuthRequestData<'_> {
178
+
type Output = AuthRequestData<'static>;
179
+
fn into_static(self) -> AuthRequestData<'static> {
180
+
AuthRequestData {
181
+
request_uri: self.request_uri.into_static(),
182
+
authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
183
+
authserver_revocation_endpoint: self
184
+
.authserver_revocation_endpoint
185
+
.map(|s| s.into_static()),
186
+
pkce_verifier: self.pkce_verifier.into_static(),
187
+
dpop_data: self.dpop_data.into_static(),
188
+
state: self.state.into_static(),
189
+
authserver_url: self.authserver_url,
190
+
account_did: self.account_did.into_static(),
191
+
scopes: self.scopes.into_static(),
192
+
}
193
+
}
194
+
}
195
+
196
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
197
+
pub struct DpopReqData<'s> {
198
+
// The secret cryptographic key generated by the client for this specific OAuth session
199
+
pub dpop_key: Key,
200
+
// Server-provided DPoP nonce from auth request (PAR)
201
+
#[serde(borrow)]
202
+
pub dpop_authserver_nonce: Option<CowStr<'s>>,
203
+
}
204
+
205
+
impl IntoStatic for DpopReqData<'_> {
206
+
type Output = DpopReqData<'static>;
207
+
fn into_static(self) -> DpopReqData<'static> {
208
+
DpopReqData {
209
+
dpop_key: self.dpop_key,
210
+
dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
211
+
}
212
+
}
213
+
}
214
+
215
+
impl DpopDataSource for DpopReqData<'_> {
216
+
fn key(&self) -> &Key {
217
+
&self.dpop_key
218
+
}
219
+
fn authserver_nonce(&self) -> Option<CowStr<'_>> {
220
+
self.dpop_authserver_nonce.clone()
221
+
}
222
+
223
+
fn host_nonce(&self) -> Option<CowStr<'_>> {
224
+
None
225
+
}
226
+
227
+
fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) {
228
+
self.dpop_authserver_nonce = Some(nonce.into_static());
229
+
}
230
+
231
+
fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {}
232
+
}
233
+
234
+
#[derive(Clone, Debug)]
235
+
pub struct ClientData<'s> {
236
+
pub keyset: Option<Keyset>,
237
+
pub config: AtprotoClientMetadata<'s>,
238
+
}
239
+
240
+
pub struct ClientSession<'s> {
241
+
pub keyset: Option<Keyset>,
242
+
pub config: AtprotoClientMetadata<'s>,
243
+
pub session_data: ClientSessionData<'s>,
244
+
}
245
+
246
+
impl<'s> ClientSession<'s> {
247
+
pub fn new(
248
+
ClientData { keyset, config }: ClientData<'s>,
249
+
session_data: ClientSessionData<'s>,
250
+
) -> Self {
251
+
Self {
252
+
keyset,
253
+
config,
254
+
session_data,
255
+
}
256
+
}
257
+
258
+
pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
259
+
&self,
260
+
client: &T,
261
+
) -> Result<OAuthMetadata, Error> {
262
+
Ok(OAuthMetadata {
263
+
server_metadata: client
264
+
.get_authorization_server_metadata(&self.session_data.authserver_url)
265
+
.await
266
+
.map_err(|e| Error::ServerAgent(crate::request::RequestError::ResolverError(e)))?,
267
+
client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset)
268
+
.unwrap()
269
+
.into_static(),
270
+
keyset: self.keyset.clone(),
271
+
})
272
+
}
273
+
}
274
+
275
+
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
276
+
pub enum Error {
277
+
#[error(transparent)]
278
+
#[diagnostic(code(jacquard_oauth::session::request))]
279
+
ServerAgent(#[from] crate::request::RequestError),
280
+
#[error(transparent)]
281
+
#[diagnostic(code(jacquard_oauth::session::storage))]
282
+
Store(#[from] SessionStoreError),
283
+
#[error("session does not exist")]
284
+
#[diagnostic(code(jacquard_oauth::session::not_found))]
285
+
SessionNotFound,
286
+
}
287
+
288
+
pub struct SessionRegistry<T, S>
289
+
where
290
+
T: OAuthResolver,
291
+
S: ClientAuthStore,
292
+
{
293
+
pub store: Arc<S>,
294
+
pub client: Arc<T>,
295
+
pub client_data: ClientData<'static>,
296
+
pending: DashMap<SmolStr, Arc<Mutex<()>>>,
297
+
}
298
+
299
+
impl<T, S> SessionRegistry<T, S>
300
+
where
301
+
S: ClientAuthStore,
302
+
T: OAuthResolver,
303
+
{
304
+
pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self {
305
+
let store = Arc::new(store);
306
+
Self {
307
+
store: Arc::clone(&store),
308
+
client,
309
+
client_data,
310
+
pending: DashMap::new(),
311
+
}
312
+
}
313
+
}
314
+
315
+
impl<T, S> SessionRegistry<T, S>
316
+
where
317
+
S: ClientAuthStore + Send + Sync + 'static,
318
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
319
+
{
320
+
async fn get_refreshed(
321
+
&self,
322
+
did: &Did<'_>,
323
+
session_id: &str,
324
+
) -> Result<ClientSessionData<'_>, Error> {
325
+
let key = format_smolstr!("{}_{}", did, session_id);
326
+
let lock = self
327
+
.pending
328
+
.entry(key)
329
+
.or_insert_with(|| Arc::new(Mutex::new(())))
330
+
.clone();
331
+
let _guard = lock.lock().await;
332
+
333
+
let mut session = self
334
+
.store
335
+
.get_session(did, session_id)
336
+
.await?
337
+
.ok_or(Error::SessionNotFound)?;
338
+
if let Some(expires_at) = &session.token_set.expires_at {
339
+
if expires_at > &Datetime::now() {
340
+
return Ok(session);
341
+
}
342
+
}
343
+
let metadata =
344
+
OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?;
345
+
session = refresh(self.client.as_ref(), session, &metadata).await?;
346
+
self.store.upsert_session(session.clone()).await?;
347
+
348
+
Ok(session)
349
+
}
350
+
pub async fn get(
351
+
&self,
352
+
did: &Did<'_>,
353
+
session_id: &str,
354
+
refresh: bool,
355
+
) -> Result<ClientSessionData<'_>, Error> {
356
+
if refresh {
357
+
self.get_refreshed(did, session_id).await
358
+
} else {
359
+
// TODO: cached?
360
+
self.store
361
+
.get_session(did, session_id)
362
+
.await?
363
+
.ok_or(Error::SessionNotFound)
364
+
}
365
+
}
366
+
pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> {
367
+
self.store.upsert_session(value).await?;
368
+
Ok(())
369
+
}
370
+
pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> {
371
+
self.store.delete_session(did, session_id).await?;
372
+
Ok(())
373
+
}
374
+
}
+55
crates/jacquard-oauth/src/types/client_metadata.rs
+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 ParParameters<'a> {
31
+
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
32
+
pub response_type: AuthorizationResponseType,
33
+
#[serde(borrow)]
34
+
pub redirect_uri: CowStr<'a>,
35
+
pub state: CowStr<'a>,
36
+
pub scope: Option<CowStr<'a>>,
37
+
// https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
38
+
pub response_mode: Option<AuthorizationResponseMode>,
39
+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.3
40
+
pub code_challenge: CowStr<'a>,
41
+
pub code_challenge_method: AuthorizationCodeChallengeMethod,
42
+
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
43
+
pub login_hint: Option<CowStr<'a>>,
44
+
pub prompt: Option<CowStr<'a>>,
45
+
}
46
+
47
+
#[derive(Serialize, Deserialize)]
48
+
#[serde(rename_all = "snake_case")]
49
+
pub enum TokenGrantType {
50
+
AuthorizationCode,
51
+
RefreshToken,
52
+
}
53
+
54
+
#[derive(Serialize, Deserialize)]
55
+
pub struct TokenRequestParameters<'a> {
56
+
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
57
+
pub grant_type: TokenGrantType,
58
+
#[serde(borrow)]
59
+
pub code: CowStr<'a>,
60
+
pub redirect_uri: CowStr<'a>,
61
+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
62
+
pub code_verifier: CowStr<'a>,
63
+
}
64
+
65
+
#[derive(Serialize, Deserialize)]
66
+
pub struct RefreshRequestParameters<'a> {
67
+
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
68
+
pub grant_type: TokenGrantType,
69
+
#[serde(borrow)]
70
+
pub refresh_token: CowStr<'a>,
71
+
pub scope: Option<CowStr<'a>>,
72
+
}
73
+
74
+
// https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
75
+
#[derive(Serialize, Deserialize)]
76
+
pub struct RevocationRequestParameters<'a> {
77
+
#[serde(borrow)]
78
+
pub token: CowStr<'a>,
79
+
// ?
80
+
// pub token_type_hint: Option<String>,
81
+
}
82
+
83
+
impl IntoStatic for RevocationRequestParameters<'_> {
84
+
type Output = RevocationRequestParameters<'static>;
85
+
86
+
fn into_static(self) -> Self::Output {
87
+
Self::Output {
88
+
token: self.token.into_static(),
89
+
}
90
+
}
91
+
}
92
+
93
+
impl IntoStatic for TokenRequestParameters<'_> {
94
+
type Output = TokenRequestParameters<'static>;
95
+
96
+
fn into_static(self) -> Self::Output {
97
+
Self::Output {
98
+
grant_type: self.grant_type,
99
+
code: self.code.into_static(),
100
+
redirect_uri: self.redirect_uri.into_static(),
101
+
code_verifier: self.code_verifier.into_static(),
102
+
}
103
+
}
104
+
}
105
+
106
+
impl IntoStatic for RefreshRequestParameters<'_> {
107
+
type Output = RefreshRequestParameters<'static>;
108
+
109
+
fn into_static(self) -> Self::Output {
110
+
Self::Output {
111
+
grant_type: self.grant_type,
112
+
refresh_token: self.refresh_token.into_static(),
113
+
scope: self.scope.map(CowStr::into_static),
114
+
}
115
+
}
116
+
}
117
+
118
+
impl IntoStatic for ParParameters<'_> {
119
+
type Output = ParParameters<'static>;
120
+
121
+
fn into_static(self) -> Self::Output {
122
+
Self::Output {
123
+
redirect_uri: self.redirect_uri.into_static(),
124
+
response_type: self.response_type,
125
+
scope: self.scope.into_static(),
126
+
code_challenge: self.code_challenge.into_static(),
127
+
code_challenge_method: self.code_challenge_method,
128
+
state: self.state.into_static(),
129
+
response_mode: self.response_mode,
130
+
login_hint: self.login_hint.into_static(),
131
+
prompt: self.prompt.into_static(),
132
+
}
133
+
}
134
+
}
+37
crates/jacquard-oauth/src/types/response.rs
+37
crates/jacquard-oauth/src/types/response.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use smol_str::SmolStr;
3
+
4
+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
5
+
pub struct OAuthParResponse {
6
+
pub request_uri: SmolStr,
7
+
pub expires_in: Option<u32>,
8
+
}
9
+
10
+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
11
+
pub enum OAuthTokenType {
12
+
DPoP,
13
+
Bearer,
14
+
}
15
+
16
+
impl OAuthTokenType {
17
+
pub fn as_str(&self) -> &'static str {
18
+
match self {
19
+
OAuthTokenType::DPoP => "DPoP",
20
+
OAuthTokenType::Bearer => "Bearer",
21
+
}
22
+
}
23
+
}
24
+
25
+
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
26
+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
27
+
pub struct OAuthTokenResponse {
28
+
pub access_token: SmolStr,
29
+
pub token_type: OAuthTokenType,
30
+
pub expires_in: Option<i64>,
31
+
pub refresh_token: Option<SmolStr>,
32
+
pub scope: Option<SmolStr>,
33
+
// ATPROTO extension: add the sub claim to the token response to allow
34
+
// clients to resolve the PDS url (audience) using the did resolution
35
+
// mechanism.
36
+
pub sub: Option<SmolStr>,
37
+
}
+36
crates/jacquard-oauth/src/types/token.rs
+36
crates/jacquard-oauth/src/types/token.rs
···
1
+
use super::response::OAuthTokenType;
2
+
use jacquard_common::types::string::{Datetime, Did};
3
+
use jacquard_common::{CowStr, IntoStatic};
4
+
use serde::{Deserialize, Serialize};
5
+
6
+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
7
+
pub struct TokenSet<'s> {
8
+
#[serde(borrow)]
9
+
pub iss: CowStr<'s>,
10
+
pub sub: Did<'s>,
11
+
pub aud: CowStr<'s>,
12
+
pub scope: Option<CowStr<'s>>,
13
+
14
+
pub refresh_token: Option<CowStr<'s>>,
15
+
pub access_token: CowStr<'s>,
16
+
pub token_type: OAuthTokenType,
17
+
18
+
pub expires_at: Option<Datetime>,
19
+
}
20
+
21
+
impl IntoStatic for TokenSet<'_> {
22
+
type Output = TokenSet<'static>;
23
+
24
+
fn into_static(self) -> Self::Output {
25
+
TokenSet {
26
+
iss: self.iss.into_static(),
27
+
sub: self.sub.into_static(),
28
+
aud: self.aud.into_static(),
29
+
scope: self.scope.map(|s| s.into_static()),
30
+
refresh_token: self.refresh_token.map(|s| s.into_static()),
31
+
access_token: self.access_token.into_static(),
32
+
token_type: self.token_type,
33
+
expires_at: self.expires_at.map(|s| s.into_static()),
34
+
}
35
+
}
36
+
}
+62
crates/jacquard-oauth/src/types.rs
+62
crates/jacquard-oauth/src/types.rs
···
1
+
mod client_metadata;
2
+
mod metadata;
3
+
mod request;
4
+
mod response;
5
+
mod token;
6
+
7
+
use crate::scopes::Scope;
8
+
9
+
pub use self::client_metadata::*;
10
+
pub use self::metadata::*;
11
+
pub use self::request::*;
12
+
pub use self::response::*;
13
+
pub use self::token::*;
14
+
use jacquard_common::CowStr;
15
+
use serde::Deserialize;
16
+
use url::Url;
17
+
18
+
#[derive(Debug, Deserialize, Clone, Copy)]
19
+
pub enum AuthorizeOptionPrompt {
20
+
Login,
21
+
None,
22
+
Consent,
23
+
SelectAccount,
24
+
}
25
+
26
+
impl From<AuthorizeOptionPrompt> for CowStr<'static> {
27
+
fn from(value: AuthorizeOptionPrompt) -> Self {
28
+
match value {
29
+
AuthorizeOptionPrompt::Login => CowStr::new_static("login"),
30
+
AuthorizeOptionPrompt::None => CowStr::new_static("none"),
31
+
AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"),
32
+
AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"),
33
+
}
34
+
}
35
+
}
36
+
37
+
#[derive(Debug)]
38
+
pub struct AuthorizeOptions<'s> {
39
+
pub redirect_uri: Option<Url>,
40
+
pub scopes: Vec<Scope<'s>>,
41
+
pub prompt: Option<AuthorizeOptionPrompt>,
42
+
pub state: Option<CowStr<'s>>,
43
+
}
44
+
45
+
impl Default for AuthorizeOptions<'_> {
46
+
fn default() -> Self {
47
+
Self {
48
+
redirect_uri: None,
49
+
scopes: vec![Scope::Atproto],
50
+
prompt: None,
51
+
state: None,
52
+
}
53
+
}
54
+
}
55
+
56
+
#[derive(Debug, Deserialize)]
57
+
pub struct CallbackParams<'s> {
58
+
#[serde(borrow)]
59
+
pub code: CowStr<'s>,
60
+
pub state: Option<CowStr<'s>>,
61
+
pub iss: Option<CowStr<'s>>,
62
+
}
+94
crates/jacquard-oauth/src/utils.rs
+94
crates/jacquard-oauth/src/utils.rs
···
1
+
use base64::Engine;
2
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3
+
use elliptic_curve::SecretKey;
4
+
use jacquard_common::CowStr;
5
+
use jose_jwk::{Key, crypto};
6
+
use rand::{CryptoRng, RngCore, rngs::ThreadRng};
7
+
use sha2::{Digest, Sha256};
8
+
use std::cmp::Ordering;
9
+
10
+
use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata};
11
+
12
+
pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> {
13
+
for alg in allowed_algos {
14
+
#[allow(clippy::single_match)]
15
+
match alg.as_ref() {
16
+
"ES256" => {
17
+
return Some(Key::from(&crypto::Key::from(
18
+
SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()),
19
+
)));
20
+
}
21
+
_ => {
22
+
// TODO: Implement other algorithms?
23
+
}
24
+
}
25
+
}
26
+
None
27
+
}
28
+
29
+
pub fn generate_nonce() -> CowStr<'static> {
30
+
URL_SAFE_NO_PAD
31
+
.encode(get_random_values::<_, 16>(&mut ThreadRng::default()))
32
+
.into()
33
+
}
34
+
35
+
pub fn generate_verifier() -> CowStr<'static> {
36
+
URL_SAFE_NO_PAD
37
+
.encode(get_random_values::<_, 43>(&mut ThreadRng::default()))
38
+
.into()
39
+
}
40
+
41
+
pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN]
42
+
where
43
+
R: RngCore + CryptoRng,
44
+
{
45
+
let mut bytes = [0u8; LEN];
46
+
rng.fill_bytes(&mut bytes);
47
+
bytes
48
+
}
49
+
50
+
// 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order)
51
+
pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering {
52
+
if a.as_ref() == "ES256K" {
53
+
return Ordering::Less;
54
+
}
55
+
if b.as_ref() == "ES256K" {
56
+
return Ordering::Greater;
57
+
}
58
+
for prefix in ["ES", "PS", "RS"] {
59
+
if let Some(stripped_a) = a.strip_prefix(prefix) {
60
+
if let Some(stripped_b) = b.strip_prefix(prefix) {
61
+
if let (Ok(len_a), Ok(len_b)) =
62
+
(stripped_a.parse::<u32>(), stripped_b.parse::<u32>())
63
+
{
64
+
return len_a.cmp(&len_b);
65
+
}
66
+
} else {
67
+
return Ordering::Less;
68
+
}
69
+
} else if b.starts_with(prefix) {
70
+
return Ordering::Greater;
71
+
}
72
+
}
73
+
Ordering::Equal
74
+
}
75
+
76
+
pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) {
77
+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
78
+
let verifier = generate_verifier();
79
+
(
80
+
URL_SAFE_NO_PAD
81
+
.encode(Sha256::digest(&verifier.as_str()))
82
+
.into(),
83
+
verifier,
84
+
)
85
+
}
86
+
87
+
pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> {
88
+
let mut algs = metadata
89
+
.dpop_signing_alg_values_supported
90
+
.clone()
91
+
.unwrap_or(vec![FALLBACK_ALG.into()]);
92
+
algs.sort_by(compare_algos);
93
+
generate_key(&algs)
94
+
}