From a0fe35e314cde738ea59cbf046b171ceac1c45e7 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 5 Oct 2025 10:15:51 -0400 Subject: [PATCH] part-way through oauth stuff :( --- .gitignore | 1 + Cargo.lock | 515 ++++- crates/jacquard-common/Cargo.toml | 8 + crates/jacquard-common/src/cowstr.rs | 7 + .../client => jacquard-common/src}/error.rs | 42 +- crates/jacquard-common/src/http_client.rs | 64 + crates/jacquard-common/src/ident_resolver.rs | 399 ++++ crates/jacquard-common/src/lib.rs | 15 + crates/jacquard-common/src/session.rs | 76 + crates/jacquard-common/src/types/did.rs | 2 +- crates/jacquard-common/src/types/nsid.rs | 2 +- crates/jacquard-common/src/types/string.rs | 4 +- crates/jacquard-common/src/types/xrpc.rs | 447 +++- crates/jacquard-oauth/Cargo.toml | 29 + crates/jacquard-oauth/src/atproto.rs | 360 +++ crates/jacquard-oauth/src/dpop.rs | 260 +++ crates/jacquard-oauth/src/error.rs | 42 + crates/jacquard-oauth/src/jose.rs | 14 + crates/jacquard-oauth/src/jose/jws.rs | 85 + crates/jacquard-oauth/src/jose/jwt.rs | 101 + crates/jacquard-oauth/src/jose/signing.rs | 21 + crates/jacquard-oauth/src/keyset.rs | 128 ++ crates/jacquard-oauth/src/lib.rs | 14 + crates/jacquard-oauth/src/resolver.rs | 214 ++ crates/jacquard-oauth/src/scopes.rs | 1969 +++++++++++++++++ crates/jacquard-oauth/src/session.rs | 23 + crates/jacquard-oauth/src/types.rs | 61 + .../src/types/client_metadata.rs | 55 + crates/jacquard-oauth/src/types/metadata.rs | 144 ++ crates/jacquard-oauth/src/types/request.rs | 134 ++ crates/jacquard-oauth/src/types/response.rs | 56 + crates/jacquard-oauth/src/types/token.rs | 36 + crates/jacquard/Cargo.toml | 12 +- crates/jacquard/src/client.rs | 274 +-- crates/jacquard/src/client/at_client.rs | 152 +- crates/jacquard/src/client/response.rs | 198 -- crates/jacquard/src/client/token.rs | 87 +- crates/jacquard/src/client/xrpc_call.rs | 154 -- .../src/{identity/resolver.rs => identity.rs} | 474 +--- crates/jacquard/src/identity/mod.rs | 3 - crates/jacquard/src/lib.rs | 25 +- crates/jacquard/src/main.rs | 9 +- 42 files changed, 5631 insertions(+), 1085 deletions(-) rename crates/{jacquard/src/client => jacquard-common/src}/error.rs (95%) create mode 100644 crates/jacquard-common/src/http_client.rs create mode 100644 crates/jacquard-common/src/ident_resolver.rs create mode 100644 crates/jacquard-common/src/session.rs create mode 100644 crates/jacquard-oauth/Cargo.toml create mode 100644 crates/jacquard-oauth/src/atproto.rs create mode 100644 crates/jacquard-oauth/src/dpop.rs create mode 100644 crates/jacquard-oauth/src/error.rs create mode 100644 crates/jacquard-oauth/src/jose.rs create mode 100644 crates/jacquard-oauth/src/jose/jws.rs create mode 100644 crates/jacquard-oauth/src/jose/jwt.rs create mode 100644 crates/jacquard-oauth/src/jose/signing.rs create mode 100644 crates/jacquard-oauth/src/keyset.rs create mode 100644 crates/jacquard-oauth/src/lib.rs create mode 100644 crates/jacquard-oauth/src/resolver.rs create mode 100644 crates/jacquard-oauth/src/scopes.rs create mode 100644 crates/jacquard-oauth/src/session.rs create mode 100644 crates/jacquard-oauth/src/types.rs create mode 100644 crates/jacquard-oauth/src/types/client_metadata.rs create mode 100644 crates/jacquard-oauth/src/types/metadata.rs create mode 100644 crates/jacquard-oauth/src/types/request.rs create mode 100644 crates/jacquard-oauth/src/types/response.rs create mode 100644 crates/jacquard-oauth/src/types/token.rs delete mode 100644 crates/jacquard/src/client/response.rs delete mode 100644 crates/jacquard/src/client/xrpc_call.rs rename crates/jacquard/src/{identity/resolver.rs => identity.rs} (62%) delete mode 100644 crates/jacquard/src/identity/mod.rs diff --git a/.gitignore b/.gitignore index b0dfa4bf..d66577f2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ crates/jacquard-lexicon/tests/fixtures/lexicons/atproto crates/jacquard-lexicon/target codegen_plan.md /lex_js +/docs diff --git a/Cargo.lock b/Cargo.lock index ce0d42c4..0923c775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -51,6 +57,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -110,6 +131,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-compression" version = "0.4.30" @@ -182,12 +209,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bitflags" version = "2.9.4" @@ -237,6 +276,27 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "btree-range-map" version = "0.7.2" @@ -261,12 +321,28 @@ dependencies = [ "smallvec", ] +[[package]] +name = "buf_redux" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -330,6 +406,12 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "ciborium" version = "0.2.2" @@ -598,6 +680,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "deflate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" +dependencies = [ + "adler32", + "gzip-header", +] + [[package]] name = "der" version = "0.7.10" @@ -605,6 +697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -625,7 +718,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -645,6 +740,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -681,9 +790,12 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", + "digest", "ff", "generic-array", "group", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -761,6 +873,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.2" @@ -893,6 +1017,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + [[package]] name = "h2" version = "0.4.12" @@ -946,6 +1079,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1003,6 +1142,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.3.1" @@ -1043,6 +1191,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.7.0" @@ -1088,7 +1242,7 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1364,9 +1518,14 @@ dependencies = [ "jacquard-api", "jacquard-common", "jacquard-derive", + "jacquard-oauth", + "jose-jwk", "miette", + "p256", "percent-encoding", + "rand_core 0.6.4", "reqwest", + "rouille", "serde", "serde_html_form", "serde_ipld_dagcbor", @@ -1395,13 +1554,16 @@ dependencies = [ name = "jacquard-common" version = "0.2.0" dependencies = [ - "base64", + "async-trait", + "base64 0.22.1", "bon", "bytes", "chrono", "cid", "ed25519-dalek", "enum_dispatch", + "hickory-resolver", + "http", "ipld-core", "k256", "langtag", @@ -1413,12 +1575,15 @@ dependencies = [ "p256", "rand 0.9.2", "regex", + "reqwest", "serde", "serde_html_form", + "serde_ipld_dagcbor", "serde_json", "serde_with", "smol_str", "thiserror 2.0.17", + "tokio", "url", ] @@ -1460,6 +1625,69 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "jacquard-oauth" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "elliptic-curve", + "http", + "jacquard-common", + "jose-jwa", + "jose-jwk", + "miette", + "p256", + "rand 0.8.5", + "rand_core 0.6.4", + "serde", + "serde_html_form", + "serde_json", + "sha2", + "signature", + "smol_str", + "thiserror 2.0.17", + "url", + "uuid", +] + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -1491,12 +1719,38 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1587,6 +1841,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1635,6 +1899,24 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "multipart" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" +dependencies = [ + "buf_redux", + "httparse", + "log", + "mime", + "mime_guess", + "quick-error", + "rand 0.8.5", + "safemem", + "tempfile", + "twoway", +] + [[package]] name = "nom" version = "7.1.3" @@ -1645,12 +1927,49 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1658,6 +1977,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -1716,6 +2055,18 @@ name = "p256" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ "elliptic-curve", "primeorder", @@ -1744,6 +2095,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1762,6 +2122,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1861,6 +2232,12 @@ dependencies = [ "yansi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -2061,7 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "async-compression", - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2104,6 +2481,16 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2118,6 +2505,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rouille" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" +dependencies = [ + "base64 0.13.1", + "brotli", + "chrono", + "deflate", + "filetime", + "multipart", + "percent-encoding", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha1_smol", + "threadpool", + "time", + "tiny_http", + "url", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -2199,6 +2630,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + [[package]] name = "schemars" version = "0.9.0" @@ -2238,6 +2675,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -2354,7 +2792,7 @@ version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -2380,6 +2818,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2402,6 +2846,10 @@ name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] [[package]] name = "slab" @@ -2445,12 +2893,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ + "base64ct", "der", ] @@ -2654,6 +3109,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.44" @@ -2662,7 +3126,9 @@ checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -2685,6 +3151,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -2844,12 +3322,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twoway" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" +dependencies = [ + "memchr", +] + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -2916,6 +3409,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -3476,6 +3980,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", +] [[package]] name = "zerotrie" diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml index 9f49e435..b68f6de2 100644 --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -35,13 +35,21 @@ serde_with.workspace = true smol_str.workspace = true thiserror.workspace = true url.workspace = true +http.workspace = true +async-trait = "0.1" +tokio = { version = "1", features = ["sync"] } +reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } +hickory-resolver = { version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"], optional = true } +serde_ipld_dagcbor.workspace = true [features] default = [] +dns = ["dep:hickory-resolver"] crypto = [] crypto-ed25519 = ["crypto", "dep:ed25519-dalek"] crypto-k256 = ["crypto", "dep:k256"] crypto-p256 = ["crypto", "dep:p256"] +reqwest-client = ["dep:reqwest"] [dependencies.ed25519-dalek] version = "2" diff --git a/crates/jacquard-common/src/cowstr.rs b/crates/jacquard-common/src/cowstr.rs index 5600fe51..b5e15aa4 100644 --- a/crates/jacquard-common/src/cowstr.rs +++ b/crates/jacquard-common/src/cowstr.rs @@ -105,6 +105,13 @@ impl<'s> From<&'s str> for CowStr<'s> { } } +impl Default for CowStr<'_> { + #[inline] + fn default() -> Self { + CowStr::new_static("") + } +} + impl From for CowStr<'_> { #[inline] fn from(s: String) -> Self { diff --git a/crates/jacquard/src/client/error.rs b/crates/jacquard-common/src/error.rs similarity index 95% rename from crates/jacquard/src/client/error.rs rename to crates/jacquard-common/src/error.rs index 37874174..3737d19e 100644 --- a/crates/jacquard/src/client/error.rs +++ b/crates/jacquard-common/src/error.rs @@ -2,6 +2,8 @@ use bytes::Bytes; +use crate::types::xrpc::EncodeError; + /// Client error type wrapping all possible error conditions #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum ClientError { @@ -66,9 +68,6 @@ pub enum TransportError { Other(Box), } -// Re-export EncodeError from common -pub use jacquard_common::types::xrpc::EncodeError; - /// Response deserialization errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum DecodeError { @@ -91,7 +90,7 @@ pub enum DecodeError { CborRemote( #[from] #[source] - serde_ipld_dagcbor::DecodeError, + serde_ipld_dagcbor::DecodeError, ), } @@ -116,6 +115,24 @@ impl std::fmt::Display for HttpError { } } +/// Result type for client operations +pub type XrpcResult = std::result::Result; + +#[cfg(feature = "reqwest-client")] +impl From for TransportError { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + Self::Timeout + } else if e.is_connect() { + Self::Connect(e.to_string()) + } else if e.is_builder() || e.is_request() { + Self::InvalidRequest(e.to_string()) + } else { + Self::Other(Box::new(e)) + } + } +} + /// Authentication and authorization errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum AuthError { @@ -139,20 +156,3 @@ pub enum AuthError { #[error("Authentication error: {0:?}")] Other(http::HeaderValue), } - -/// Result type for client operations -pub type Result = std::result::Result; - -impl From for TransportError { - fn from(e: reqwest::Error) -> Self { - if e.is_timeout() { - Self::Timeout - } else if e.is_connect() { - Self::Connect(e.to_string()) - } else if e.is_builder() || e.is_request() { - Self::InvalidRequest(e.to_string()) - } else { - Self::Other(Box::new(e)) - } - } -} diff --git a/crates/jacquard-common/src/http_client.rs b/crates/jacquard-common/src/http_client.rs new file mode 100644 index 00000000..010ef2e5 --- /dev/null +++ b/crates/jacquard-common/src/http_client.rs @@ -0,0 +1,64 @@ +//! Minimal HTTP client abstraction shared across crates. + +use std::fmt::Display; +use std::future::Future; +use std::sync::Arc; + +/// HTTP client trait for sending raw HTTP requests. +pub trait HttpClient { + /// Error type returned by the HTTP client + type Error: std::error::Error + Display + Send + Sync + 'static; + /// Send an HTTP request and return the response. + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> + Send; +} + +#[cfg(feature = "reqwest-client")] +impl HttpClient for reqwest::Client { + type Error = reqwest::Error; + + async fn send_http( + &self, + request: http::Request>, + ) -> core::result::Result>, Self::Error> { + // Convert http::Request to reqwest::Request + let (parts, body) = request.into_parts(); + + let mut req = self.request(parts.method, parts.uri.to_string()).body(body); + + // Copy headers + for (name, value) in parts.headers.iter() { + req = req.header(name.as_str(), value.as_bytes()); + } + + // Send request + let resp = req.send().await?; + + // Convert reqwest::Response to http::Response + let mut builder = http::Response::builder().status(resp.status()); + + // Copy headers + for (name, value) in resp.headers().iter() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + // Read body + let body = resp.bytes().await?.to_vec(); + + Ok(builder.body(body).expect("Failed to build response")) + } +} + +impl HttpClient for Arc { + type Error = T::Error; + + fn send_http( + &self, + request: http::Request>, + ) -> impl Future>, Self::Error>> + Send + { + self.as_ref().send_http(request) + } +} diff --git a/crates/jacquard-common/src/ident_resolver.rs b/crates/jacquard-common/src/ident_resolver.rs new file mode 100644 index 00000000..1ffdfc38 --- /dev/null +++ b/crates/jacquard-common/src/ident_resolver.rs @@ -0,0 +1,399 @@ +//! Identity resolution: handle → DID and DID → document, with smart fallbacks. +//! +//! Fallback order (default): +//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC +//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured). +//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured), +//! then Slingshot mini‑doc (partial) if configured. +//! +//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer +//! and optionally validate the document `id` against the requested DID. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use crate::error::TransportError; +use crate::types::did_doc::Service; +use crate::types::ident::AtIdentifier; +use crate::types::string::AtprotoStr; +use crate::types::uri::Uri; +use crate::types::value::Data; +use crate::{CowStr, IntoStatic}; +use bon::Builder; +use bytes::Bytes; +use http::StatusCode; +use miette::Diagnostic; +use thiserror::Error; +use url::Url; + +use crate::types::did_doc::DidDocument; +use crate::types::string::{Did, Handle}; +use crate::types::value::AtDataError; +/// Errors that can occur during identity resolution. +/// +/// Note: when validating a fetched DID document against a requested DID, a +/// `DocIdMismatch` error is returned that includes the owned document so callers +/// can inspect it and decide how to proceed. +#[derive(Debug, Error, Diagnostic)] +#[allow(missing_docs)] +pub enum IdentityError { + #[error("unsupported DID method: {0}")] + UnsupportedDidMethod(String), + #[error("invalid well-known atproto-did content")] + InvalidWellKnown, + #[error("missing PDS endpoint in DID document")] + MissingPdsEndpoint, + #[error("HTTP error: {0}")] + Http(#[from] TransportError), + #[error("HTTP status {0}")] + HttpStatus(StatusCode), + #[error("XRPC error: {0}")] + Xrpc(String), + #[error("URL parse error: {0}")] + Url(#[from] url::ParseError), + #[error("DNS error: {0}")] + #[cfg(feature = "dns")] + Dns(#[from] hickory_resolver::error::ResolveError), + #[error("serialize/deserialize error: {0}")] + Serde(#[from] serde_json::Error), + #[error("invalid DID document: {0}")] + InvalidDoc(String), + #[error(transparent)] + Data(#[from] AtDataError), + /// DID document id did not match requested DID; includes the fetched document + #[error("DID doc id mismatch")] + DocIdMismatch { + expected: Did<'static>, + doc: DidDocument<'static>, + }, +} + +/// Source to fetch PLC (did:plc) documents from. +/// +/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). +/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as +/// `com.atproto.identity.resolveHandle` and a "mini-doc" +/// endpoint (`com.bad-example.identity.resolveMiniDoc`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlcSource { + /// Use the public PLC directory + PlcDirectory { + /// Base URL for the PLC directory + base: Url, + }, + /// Use the slingshot mini-docs service + Slingshot { + /// Base URL for the Slingshot service + base: Url, + }, +} + +impl Default for PlcSource { + fn default() -> Self { + Self::PlcDirectory { + base: Url::parse("https://plc.directory/").expect("valid url"), + } + } +} + +impl PlcSource { + /// Default Slingshot source (`https://slingshot.microcosm.blue`) + pub fn slingshot_default() -> Self { + PlcSource::Slingshot { + base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), + } + } +} + +/// DID Document fetch response for borrowed/owned parsing. +/// +/// Carries the raw response bytes and the HTTP status, plus the requested DID +/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer +/// or `parse_validated()` to also enforce that the doc `id` matches the +/// requested DID (returns a `DocIdMismatch` containing the fetched doc on +/// mismatch). Use `into_owned()` to parse into an owned document. +#[derive(Clone)] +pub struct DidDocResponse { + pub buffer: Bytes, + pub status: StatusCode, + /// Optional DID we intended to resolve; used for validation helpers + pub requested: Option>, +} + +impl DidDocResponse { + /// Parse as borrowed DidDocument<'_> + pub fn parse<'b>(&'b self) -> Result, IdentityError> { + if self.status.is_success() { + if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { + Ok(doc) + } else if let Ok(mini_doc) = serde_json::from_slice::>(&self.buffer) { + Ok(DidDocument { + id: mini_doc.did, + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), + verification_method: None, + service: Some(vec![Service { + id: CowStr::new_static("#atproto_pds"), + r#type: CowStr::new_static("AtprotoPersonalDataServer"), + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( + Url::from_str(&mini_doc.pds).unwrap(), + )))), + extra_data: BTreeMap::new(), + }]), + extra_data: BTreeMap::new(), + }) + } else { + Err(IdentityError::MissingPdsEndpoint) + } + } else { + Err(IdentityError::HttpStatus(self.status)) + } + } + + /// Parse and validate that the DID in the document matches the requested DID if present. + /// + /// On mismatch, returns an error that contains the owned document for inspection. + pub fn parse_validated<'b>(&'b self) -> Result, IdentityError> { + let doc = self.parse()?; + if let Some(expected) = &self.requested { + if doc.id.as_str() != expected.as_str() { + return Err(IdentityError::DocIdMismatch { + expected: expected.clone(), + doc: doc.clone().into_static(), + }); + } + } + Ok(doc) + } + + /// Parse as owned DidDocument<'static> + pub fn into_owned(self) -> Result, IdentityError> { + if self.status.is_success() { + if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { + Ok(doc.into_static()) + } else if let Ok(mini_doc) = serde_json::from_slice::>(&self.buffer) { + Ok(DidDocument { + id: mini_doc.did, + also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), + verification_method: None, + service: Some(vec![Service { + id: CowStr::new_static("#atproto_pds"), + r#type: CowStr::new_static("AtprotoPersonalDataServer"), + service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( + Url::from_str(&mini_doc.pds).unwrap(), + )))), + extra_data: BTreeMap::new(), + }]), + extra_data: BTreeMap::new(), + } + .into_static()) + } else { + Err(IdentityError::MissingPdsEndpoint) + } + } else { + Err(IdentityError::HttpStatus(self.status)) + } + } +} + +/// Slingshot mini-doc data (subset of DID doc info) +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct MiniDoc<'a> { + #[serde(borrow)] + pub did: Did<'a>, + #[serde(borrow)] + pub handle: Handle<'a>, + #[serde(borrow)] + pub pds: crate::CowStr<'a>, + #[serde(borrow, rename = "signingKey", alias = "signing_key")] + pub signing_key: crate::CowStr<'a>, +} + +/// Handle → DID fallback step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandleStep { + /// DNS TXT _atproto.\ + DnsTxt, + /// HTTPS GET https://\/.well-known/atproto-did + HttpsWellKnown, + /// XRPC com.atproto.identity.resolveHandle against a provided PDS base + PdsResolveHandle, +} + +/// DID → Doc fallback step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DidStep { + /// For did:web: fetch from the well-known location + DidWebHttps, + /// For did:plc: fetch from PLC source + PlcHttp, + /// If a PDS base is known, ask it for the DID doc + PdsResolveDid, +} + +/// Configurable resolver options. +/// +/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). +/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless +/// XRPC over reqwest; authentication can be layered as needed). +/// - `handle_order`/`did_order`: ordered strategies for resolution. +/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, +/// returning `DocIdMismatch` with the fetched document on mismatch. +/// - `public_fallback_for_handle`: if true (default), attempt +/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. +/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC +/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. +#[derive(Debug, Clone, Builder)] +#[builder(start_fn = new)] +pub struct ResolverOptions { + /// PLC data source (directory or slingshot) + pub plc_source: PlcSource, + /// Optional PDS base to use for fallbacks + pub pds_fallback: Option, + /// Order of attempts for handle → DID resolution + pub handle_order: Vec, + /// Order of attempts for DID → Doc resolution + pub did_order: Vec, + /// Validate that fetched DID document id matches the requested DID + pub validate_doc_id: bool, + /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app + pub public_fallback_for_handle: bool, +} + +impl Default for ResolverOptions { + fn default() -> Self { + // By default, prefer DNS then HTTPS for handles, then PDS fallback + // For DID documents, prefer method-native sources, then PDS fallback + Self::new() + .plc_source(PlcSource::default()) + .handle_order(vec![ + HandleStep::DnsTxt, + HandleStep::HttpsWellKnown, + HandleStep::PdsResolveHandle, + ]) + .did_order(vec![ + DidStep::DidWebHttps, + DidStep::PlcHttp, + DidStep::PdsResolveDid, + ]) + .validate_doc_id(true) + .public_fallback_for_handle(true) + .build() + } +} + +/// Trait for identity resolution, for pluggable implementations. +/// +/// The provided `DefaultResolver` supports: +/// - DNS TXT (`_atproto.`) when compiled with the `dns` feature +/// - HTTPS well-known for handles and `did:web` +/// - PLC directory or Slingshot for `did:plc` +/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source +/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest +#[async_trait::async_trait] +pub trait IdentityResolver { + /// Access options for validation decisions in default methods + fn options(&self) -> &ResolverOptions; + + /// Resolve handle + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, IdentityError>; + + /// Resolve DID document + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result; + + /// Resolve DID doc from an identifier + async fn resolve_ident( + &self, + actor: &AtIdentifier<'_>, + ) -> Result { + match actor { + AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, + AtIdentifier::Handle(handle) => { + let did = self.resolve_handle(&handle).await?; + self.resolve_did_doc(&did).await + } + } + } + + /// Resolve DID doc from an identifier + async fn resolve_ident_owned( + &self, + actor: &AtIdentifier<'_>, + ) -> Result, IdentityError> { + match actor { + AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, + AtIdentifier::Handle(handle) => { + let did = self.resolve_handle(&handle).await?; + self.resolve_did_doc_owned(&did).await + } + } + } + + /// Resolve the DID document and return an owned version + async fn resolve_did_doc_owned( + &self, + did: &Did<'_>, + ) -> Result, IdentityError> { + self.resolve_did_doc(did).await?.into_owned() + } + /// Return the PDS url for a DID + async fn pds_for_did(&self, did: &Did<'_>) -> Result { + let resp = self.resolve_did_doc(did).await?; + let doc = resp.parse()?; + // Default-on doc id equality check + if self.options().validate_doc_id { + if doc.id.as_str() != did.as_str() { + return Err(IdentityError::DocIdMismatch { + expected: did.clone().into_static(), + doc: doc.clone().into_static(), + }); + } + } + doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) + } + /// Return the DIS and PDS url for a handle + async fn pds_for_handle( + &self, + handle: &Handle<'_>, + ) -> Result<(Did<'static>, Url), IdentityError> { + let did = self.resolve_handle(handle).await?; + let pds = self.pds_for_did(&did).await?; + Ok((did, pds)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_validated_ok() { + let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); + let requested = Did::new_owned("did:plc:alice").unwrap(); + let resp = DidDocResponse { + buffer: buf, + status: StatusCode::OK, + requested: Some(requested), + }; + let _doc = resp.parse_validated().expect("valid"); + } + + #[test] + fn parse_validated_mismatch() { + let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); + let requested = Did::new_owned("did:plc:alice").unwrap(); + let resp = DidDocResponse { + buffer: buf, + status: StatusCode::OK, + requested: Some(requested), + }; + match resp.parse_validated() { + Err(IdentityError::DocIdMismatch { expected, doc }) => { + assert_eq!(expected.as_str(), "did:plc:alice"); + assert_eq!(doc.id.as_str(), "did:plc:bob"); + } + other => panic!("unexpected result: {:?}", other), + } + } +} diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs index 148e85ba..745b5195 100644 --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -13,6 +13,21 @@ pub mod cowstr; #[macro_use] /// Trait for taking ownership of most borrowed types in jacquard. pub mod into_static; +pub mod error; +/// HTTP client abstraction used by jacquard crates. +pub mod http_client; +pub mod ident_resolver; pub mod macros; +/// Generic session storage traits and utilities. +pub mod session; /// Baseline fundamental AT Protocol data types. pub mod types; + +/// Authorization token types for XRPC requests. +#[derive(Debug, Clone)] +pub enum AuthorizationToken<'s> { + /// Bearer token (access JWT, refresh JWT to refresh the session) + Bearer(CowStr<'s>), + /// DPoP token (proof-of-possession) for OAuth + Dpop(CowStr<'s>), +} diff --git a/crates/jacquard-common/src/session.rs b/crates/jacquard-common/src/session.rs new file mode 100644 index 00000000..c05f306c --- /dev/null +++ b/crates/jacquard-common/src/session.rs @@ -0,0 +1,76 @@ +//! Generic session storage traits and utilities. + +use async_trait::async_trait; +use miette::Diagnostic; +use std::collections::HashMap; +use std::error::Error as StdError; +use std::hash::Hash; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Errors emitted by session stores. +#[derive(Debug, thiserror::Error, Diagnostic)] +pub enum SessionStoreError { + /// Filesystem or I/O error + #[error("I/O error: {0}")] + #[diagnostic(code(jacquard::session_store::io))] + Io(#[from] std::io::Error), + /// Serialization error (e.g., JSON) + #[error("serialization error: {0}")] + #[diagnostic(code(jacquard::session_store::serde))] + Serde(#[from] serde_json::Error), + /// Any other error from a backend implementation + #[error(transparent)] + #[diagnostic(code(jacquard::session_store::other))] + Other(#[from] Box), +} + +/// Pluggable storage for arbitrary session records. +#[async_trait] +pub trait SessionStore: Send + Sync +where + K: Eq + Hash, + T: Clone, +{ + /// Get the current session if present. + async fn get(&self, key: &K) -> Option; + /// Persist the given session. + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; + /// Delete the given session. + async fn del(&self, key: &K) -> Result<(), SessionStoreError>; + /// Remove all stored sessions. + async fn clear(&self) -> Result<(), SessionStoreError>; +} + +/// In-memory session store suitable for short-lived sessions and tests. +#[derive(Clone)] +pub struct MemorySessionStore(Arc>>); + +impl Default for MemorySessionStore { + fn default() -> Self { + Self(Arc::new(RwLock::new(HashMap::new()))) + } +} + +#[async_trait] +impl SessionStore for MemorySessionStore +where + K: Eq + Hash + Send + Sync, + T: Clone + Send + Sync + 'static, +{ + async fn get(&self, key: &K) -> Option { + self.0.read().await.get(key).cloned() + } + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { + self.0.write().await.insert(key, session); + Ok(()) + } + async fn del(&self, key: &K) -> Result<(), SessionStoreError> { + self.0.write().await.remove(key); + Ok(()) + } + async fn clear(&self) -> Result<(), SessionStoreError> { + self.0.write().await.clear(); + Ok(()) + } +} diff --git a/crates/jacquard-common/src/types/did.rs b/crates/jacquard-common/src/types/did.rs index cd53ff19..2a8d4423 100644 --- a/crates/jacquard-common/src/types/did.rs +++ b/crates/jacquard-common/src/types/did.rs @@ -21,7 +21,7 @@ use std::{ops::Deref, str::FromStr}; /// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs. /// /// See: -#[derive(Clone, PartialEq, Eq, Serialize, Hash)] +#[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] #[serde(transparent)] #[repr(transparent)] pub struct Did<'d>(CowStr<'d>); diff --git a/crates/jacquard-common/src/types/nsid.rs b/crates/jacquard-common/src/types/nsid.rs index 5c31c772..4415d5b8 100644 --- a/crates/jacquard-common/src/types/nsid.rs +++ b/crates/jacquard-common/src/types/nsid.rs @@ -24,7 +24,7 @@ use std::{ops::Deref, str::FromStr}; /// - Case-sensitive /// /// See: -#[derive(Clone, PartialEq, Eq, Serialize, Hash)] +#[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] #[serde(transparent)] #[repr(transparent)] pub struct Nsid<'n>(CowStr<'n>); diff --git a/crates/jacquard-common/src/types/string.rs b/crates/jacquard-common/src/types/string.rs index ff7dee78..faae3f25 100644 --- a/crates/jacquard-common/src/types/string.rs +++ b/crates/jacquard-common/src/types/string.rs @@ -251,7 +251,7 @@ impl From> for String { /// detailing the specification for the type /// `source` is the source string, or part of it /// `kind` is the type of parsing error: `[StrParseKind]` -#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] #[error("error in `{source}`: {kind}")] #[diagnostic( url("https://atproto.com/specs/{spec}"), @@ -406,7 +406,7 @@ impl AtStrError { } /// Kinds of parsing errors for AT Protocol string types -#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] pub enum StrParseKind { /// Regex pattern validation failed #[error("regex failure - {message}")] diff --git a/crates/jacquard-common/src/types/xrpc.rs b/crates/jacquard-common/src/types/xrpc.rs index 1c0b35b2..56e42e89 100644 --- a/crates/jacquard-common/src/types/xrpc.rs +++ b/crates/jacquard-common/src/types/xrpc.rs @@ -1,9 +1,20 @@ +use bytes::Bytes; +use http::{ + HeaderName, HeaderValue, Request, StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE}, +}; use serde::{Deserialize, Serialize}; -use std::error::Error; +use smol_str::SmolStr; use std::fmt::{self, Debug}; +use std::{error::Error, marker::PhantomData}; +use url::Url; use crate::IntoStatic; +use crate::error::TransportError; +use crate::http_client::HttpClient; use crate::types::value::Data; +use crate::{AuthorizationToken, error::AuthError}; +use crate::{CowStr, error::XrpcResult}; /// Error type for encoding XRPC requests #[derive(Debug, thiserror::Error, miette::Diagnostic)] @@ -103,3 +114,437 @@ impl IntoStatic for GenericError<'_> { GenericError(self.0.into_static()) } } + +/// Per-request options for XRPC calls. +#[derive(Debug, Default, Clone)] +pub struct CallOptions<'a> { + /// Optional Authorization to apply (`Bearer` or `DPoP`). + pub auth: Option>, + /// `atproto-proxy` header value. + pub atproto_proxy: Option>, + /// `atproto-accept-labelers` header values. + pub atproto_accept_labelers: Option>>, + /// Extra headers to attach to this request. + pub extra_headers: Vec<(HeaderName, HeaderValue)>, +} + +/// Extension for stateless XRPC calls on any `HttpClient`. +/// +/// Example +/// ```ignore +/// use jacquard::client::XrpcExt; +/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; +/// use jacquard::types::ident::AtIdentifier; +/// use miette::IntoDiagnostic; +/// +/// #[tokio::main] +/// async fn main() -> miette::Result<()> { +/// let http = reqwest::Client::new(); +/// let base = url::Url::parse("https://public.api.bsky.app")?; +/// let resp = http +/// .xrpc(base) +/// .send( +/// GetAuthorFeed::new() +/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) +/// .limit(5) +/// .build(), +/// ) +/// .await?; +/// let out = resp.into_output()?; +/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); +/// Ok(()) +/// } +/// ``` +pub trait XrpcExt: HttpClient { + /// Start building an XRPC call for the given base URL. + fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> + where + Self: Sized, + { + XrpcCall { + client: self, + base, + opts: CallOptions::default(), + } + } +} + +impl XrpcExt for T {} + +/// Stateless XRPC call builder. +/// +/// Example (per-request overrides) +/// ```ignore +/// use jacquard::client::{XrpcExt, AuthorizationToken}; +/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; +/// use jacquard::types::ident::AtIdentifier; +/// use jacquard::CowStr; +/// use miette::IntoDiagnostic; +/// +/// #[tokio::main] +/// async fn main() -> miette::Result<()> { +/// let http = reqwest::Client::new(); +/// let base = url::Url::parse("https://public.api.bsky.app")?; +/// let resp = http +/// .xrpc(base) +/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) +/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) +/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) +/// .send( +/// GetAuthorFeed::new() +/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) +/// .limit(5) +/// .build(), +/// ) +/// .await?; +/// let out = resp.into_output()?; +/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); +/// Ok(()) +/// } +/// ``` +pub struct XrpcCall<'a, C: HttpClient> { + pub(crate) client: &'a C, + pub(crate) base: Url, + pub(crate) opts: CallOptions<'a>, +} + +impl<'a, C: HttpClient> XrpcCall<'a, C> { + /// Apply Authorization to this call. + pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { + self.opts.auth = Some(token); + self + } + /// Set `atproto-proxy` header for this call. + pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { + self.opts.atproto_proxy = Some(proxy); + self + } + /// Set `atproto-accept-labelers` header(s) for this call. + pub fn accept_labelers(mut self, labelers: Vec>) -> Self { + self.opts.atproto_accept_labelers = Some(labelers); + self + } + /// Add an extra header. + pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { + self.opts.extra_headers.push((name, value)); + self + } + /// Replace the builder's options entirely. + pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { + self.opts = opts; + self + } + + /// Send the given typed XRPC request and return a response wrapper. + pub async fn send(self, request: R) -> XrpcResult> { + let http_request = build_http_request(&self.base, &request, &self.opts) + .map_err(crate::error::TransportError::from)?; + + let http_response = self + .client + .send_http(http_request) + .await + .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; + + let status = http_response.status(); + let buffer = Bytes::from(http_response.into_body()); + + if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { + return Err(crate::error::HttpError { + status, + body: Some(buffer), + } + .into()); + } + + Ok(Response::new(buffer, status)) + } +} + +/// HTTP headers commonly used in XRPC requests +pub enum Header { + /// Content-Type header + ContentType, + /// Authorization header + Authorization, + /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. + /// + /// See: + AtprotoProxy, + /// `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. + AtprotoAcceptLabelers, +} + +impl From
for HeaderName { + fn from(value: Header) -> Self { + match value { + Header::ContentType => CONTENT_TYPE, + Header::Authorization => AUTHORIZATION, + Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), + Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), + } + } +} + +/// Build an HTTP request for an XRPC call given base URL and options +pub fn build_http_request( + base: &Url, + req: &R, + opts: &CallOptions<'_>, +) -> core::result::Result>, crate::error::TransportError> { + let mut url = base.clone(); + let mut path = url.path().trim_end_matches('/').to_owned(); + path.push_str("/xrpc/"); + path.push_str(R::NSID); + url.set_path(&path); + + if let XrpcMethod::Query = R::METHOD { + let qs = serde_html_form::to_string(&req) + .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?; + if !qs.is_empty() { + url.set_query(Some(&qs)); + } else { + url.set_query(None); + } + } + + let method = match R::METHOD { + XrpcMethod::Query => http::Method::GET, + XrpcMethod::Procedure(_) => http::Method::POST, + }; + + let mut builder = Request::builder().method(method).uri(url.as_str()); + + if let XrpcMethod::Procedure(encoding) = R::METHOD { + builder = builder.header(Header::ContentType, encoding); + } + builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING); + + if let Some(token) = &opts.auth { + let hv = match token { + AuthorizationToken::Bearer(t) => { + HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) + } + AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), + } + .map_err(|e| { + TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) + })?; + builder = builder.header(Header::Authorization, hv); + } + + if let Some(proxy) = &opts.atproto_proxy { + builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); + } + if let Some(labelers) = &opts.atproto_accept_labelers { + if !labelers.is_empty() { + let joined = labelers + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join(", "); + builder = builder.header(Header::AtprotoAcceptLabelers, joined); + } + } + for (name, value) in &opts.extra_headers { + builder = builder.header(name, value); + } + + let body = if let XrpcMethod::Procedure(_) = R::METHOD { + req.encode_body() + .map_err(|e| TransportError::InvalidRequest(e.to_string()))? + } else { + vec![] + }; + + builder + .body(body) + .map_err(|e| TransportError::InvalidRequest(e.to_string())) +} + +/// XRPC response wrapper that owns the response buffer +/// +/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. +/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). +pub struct Response { + buffer: Bytes, + status: StatusCode, + _marker: PhantomData, +} + +impl Response { + /// Create a new response from a buffer and status code + pub fn new(buffer: Bytes, status: StatusCode) -> Self { + Self { + buffer, + status, + _marker: PhantomData, + } + } + + /// Get the HTTP status code + pub fn status(&self) -> StatusCode { + self.status + } + + /// Parse the response, borrowing from the internal buffer + pub fn parse(&self) -> Result, XrpcError>> { + // Use a helper to make lifetime inference work + fn parse_output<'b, R: XrpcRequest>( + buffer: &'b [u8], + ) -> Result, serde_json::Error> { + serde_json::from_slice(buffer) + } + + fn parse_error<'b, R: XrpcRequest>( + buffer: &'b [u8], + ) -> Result, serde_json::Error> { + serde_json::from_slice(buffer) + } + + // 200: parse as output + if self.status.is_success() { + match parse_output::(&self.buffer) { + Ok(output) => Ok(output), + Err(e) => Err(XrpcError::Decode(e)), + } + // 400: try typed XRPC error, fallback to generic error + } else if self.status.as_u16() == 400 { + match parse_error::(&self.buffer) { + Ok(error) => Err(XrpcError::Xrpc(error)), + Err(_) => { + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) + match serde_json::from_slice::(&self.buffer) { + Ok(generic) => { + // Map auth-related errors to AuthError + match generic.error.as_str() { + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), + _ => Err(XrpcError::Generic(generic)), + } + } + Err(e) => Err(XrpcError::Decode(e)), + } + } + } + // 401: always auth error + } else { + match serde_json::from_slice::(&self.buffer) { + Ok(generic) => match generic.error.as_str() { + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), + }, + Err(e) => Err(XrpcError::Decode(e)), + } + } + } + + /// Parse the response into an owned output + pub fn into_output(self) -> Result, XrpcError>> + where + for<'a> R::Output<'a>: IntoStatic>, + for<'a> R::Err<'a>: IntoStatic>, + { + // Use a helper to make lifetime inference work + fn parse_output<'b, R: XrpcRequest>( + buffer: &'b [u8], + ) -> Result, serde_json::Error> { + serde_json::from_slice(buffer) + } + + fn parse_error<'b, R: XrpcRequest>( + buffer: &'b [u8], + ) -> Result, serde_json::Error> { + serde_json::from_slice(buffer) + } + + // 200: parse as output + if self.status.is_success() { + match parse_output::(&self.buffer) { + Ok(output) => Ok(output.into_static()), + Err(e) => Err(XrpcError::Decode(e)), + } + // 400: try typed XRPC error, fallback to generic error + } else if self.status.as_u16() == 400 { + match parse_error::(&self.buffer) { + Ok(error) => Err(XrpcError::Xrpc(error.into_static())), + Err(_) => { + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) + match serde_json::from_slice::(&self.buffer) { + Ok(generic) => { + // Map auth-related errors to AuthError + match generic.error.as_ref() { + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), + _ => Err(XrpcError::Generic(generic)), + } + } + Err(e) => Err(XrpcError::Decode(e)), + } + } + } + // 401: always auth error + } else { + match serde_json::from_slice::(&self.buffer) { + Ok(generic) => match generic.error.as_ref() { + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), + }, + Err(e) => Err(XrpcError::Decode(e)), + } + } + } + + /// Get the raw buffer + pub fn buffer(&self) -> &Bytes { + &self.buffer + } +} + +/// Generic XRPC error format for untyped errors like InvalidRequest +/// +/// Used when the error doesn't match the endpoint's specific error enum +#[derive(Debug, Clone, Deserialize)] +pub struct GenericXrpcError { + /// Error code (e.g., "InvalidRequest") + pub error: SmolStr, + /// Optional error message with details + pub message: Option, +} + +impl std::fmt::Display for GenericXrpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(msg) = &self.message { + write!(f, "{}: {}", self.error, msg) + } else { + write!(f, "{}", self.error) + } + } +} + +impl std::error::Error for GenericXrpcError {} + +/// XRPC-specific errors returned from endpoints +/// +/// Represents errors returned in the response body +/// Type parameter `E` is the endpoint's specific error enum type. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum XrpcError { + /// Typed XRPC error from the endpoint's specific error enum + #[error("XRPC error: {0}")] + Xrpc(E), + + /// Authentication error (ExpiredToken, InvalidToken, etc.) + #[error("Authentication error: {0}")] + Auth(#[from] AuthError), + + /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) + #[error("XRPC error: {0}")] + Generic(GenericXrpcError), + + /// Failed to decode the response body + #[error("Failed to decode response: {0}")] + Decode(#[from] serde_json::Error), +} diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml new file mode 100644 index 00000000..3fd171b8 --- /dev/null +++ b/crates/jacquard-oauth/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "jacquard-oauth" +version = "0.1.0" +edition = "2024" +description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" +license = "MPL-2.0" + +[dependencies] +jacquard-common = { version = "0.2.0", path = "../jacquard-common" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +url = { workspace = true } +smol_str = { workspace = true } +base64 = { version = "0.22" } +sha2 = { version = "0.10" } +thiserror = { workspace = true } +serde_html_form = { workspace = true } +miette = { workspace = true } +uuid = { version = "1", features = ["v4","std"] } +p256 = { version = "0.13", features = ["ecdsa"] } +signature = "2" +rand_core = "0.6" +jose-jwa = "0.1" +jose-jwk = { version = "0.1", features = ["p256"] } +chrono = "0.4" +elliptic-curve = "0.13.8" +http.workspace = true +rand = { version = "0.8.5", features = ["small_rng"] } +async-trait = "0.1.89" diff --git a/crates/jacquard-oauth/src/atproto.rs b/crates/jacquard-oauth/src/atproto.rs new file mode 100644 index 00000000..50d62a43 --- /dev/null +++ b/crates/jacquard-oauth/src/atproto.rs @@ -0,0 +1,360 @@ +use std::str::FromStr; + +use crate::types::OAuthClientMetadata; +use crate::{keyset::Keyset, scopes::Scope}; +use jacquard_common::CowStr; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::{Host, Url}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("`client_id` must be a valid URL")] + InvalidClientId, + #[error("`grant_types` must include `authorization_code`")] + InvalidGrantTypes, + #[error("`scope` must not include `atproto`")] + InvalidScope, + #[error("`redirect_uris` must not be empty")] + EmptyRedirectUris, + #[error("`private_key_jwt` auth method requires `jwks` keys")] + EmptyJwks, + #[error( + "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided" + )] + AuthSigningAlg, + #[error(transparent)] + SerdeHtmlForm(#[from] serde_html_form::ser::Error), + #[error(transparent)] + LocalhostClient(#[from] LocalhostClientError), +} + +#[derive(Error, Debug)] +pub enum LocalhostClientError { + #[error("invalid redirect_uri: {0}")] + Invalid(#[from] url::ParseError), + #[error("loopback client_id must use `http:` redirect_uri")] + NotHttpScheme, + #[error("loopback client_id must not use `localhost` as redirect_uri hostname")] + Localhost, + #[error("loopback client_id must not use loopback addresses as redirect_uri")] + NotLoopbackHost, +} + +pub type Result = core::result::Result; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMethod { + None, + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication + PrivateKeyJwt, +} + +impl From for CowStr<'static> { + fn from(value: AuthMethod) -> Self { + match value { + AuthMethod::None => CowStr::new_static("none"), + AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrantType { + AuthorizationCode, + RefreshToken, +} + +impl From for CowStr<'static> { + fn from(value: GrantType) -> Self { + match value { + GrantType::AuthorizationCode => CowStr::new_static("authorization_code"), + GrantType::RefreshToken => CowStr::new_static("refresh_token"), + } + } +} + +pub fn localhost_client_metadata<'s>( + redirect_uris: Option>, + scopes: Option<&'s [Scope<'s>]>, +) -> Result> { + // validate redirect_uris + if let Some(redirect_uris) = &redirect_uris { + for redirect_uri in redirect_uris { + if redirect_uri.scheme() != "http" { + return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme)); + } + if redirect_uri.host().map(|h| h.to_owned()) == Some(Host::parse("localhost").unwrap()) + { + return Err(Error::LocalhostClient(LocalhostClientError::Localhost)); + } + if redirect_uri + .host() + .map(|h| h.to_owned()) + .map_or(true, |host| { + host != Host::parse("127.0.0.1").unwrap() + && host != Host::parse("[::1]").unwrap() + }) + { + return Err(Error::LocalhostClient( + LocalhostClientError::NotLoopbackHost, + )); + } + } + } + // determine client_id + #[derive(serde::Serialize)] + struct Parameters<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + redirect_uri: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option>, + } + let query = serde_html_form::to_string(Parameters { + redirect_uri: redirect_uris.clone(), + scope: scopes.map(|s| Scope::serialize_multiple(s)), + })?; + let mut client_id = String::from("http://localhost"); + if !query.is_empty() { + client_id.push_str(&format!("?{query}")); + } + Ok(OAuthClientMetadata { + client_id: Url::parse(&client_id).unwrap(), + client_uri: None, + redirect_uris: redirect_uris.unwrap_or(vec![ + Url::from_str("http://127.0.0.1/").unwrap(), + Url::from_str("http://[::1]/").unwrap(), + ]), + scope: None, + grant_types: None, // will be set to `authorization_code` and `refresh_token` + token_endpoint_auth_method: Some(CowStr::new_static("none")), + dpop_bound_access_tokens: None, // will be set to `true` + jwks_uri: None, + jwks: None, + token_endpoint_auth_signing_alg: None, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AtprotoClientMetadata<'m> { + pub client_id: Url, + pub client_uri: Option, + pub redirect_uris: Vec, + pub token_endpoint_auth_method: AuthMethod, + pub grant_types: Vec, + pub scopes: Vec>, + pub jwks_uri: Option, + pub token_endpoint_auth_signing_alg: Option>, +} + +pub fn atproto_client_metadata<'m>( + metadata: AtprotoClientMetadata<'m>, + keyset: &Option, +) -> Result> { + if metadata.redirect_uris.is_empty() { + return Err(Error::EmptyRedirectUris); + } + if !metadata.grant_types.contains(&GrantType::AuthorizationCode) { + return Err(Error::InvalidGrantTypes); + } + if !metadata.scopes.contains(&Scope::Atproto) { + return Err(Error::InvalidScope); + } + let (jwks_uri, mut jwks) = (metadata.jwks_uri, None); + match metadata.token_endpoint_auth_method { + AuthMethod::None => { + if metadata.token_endpoint_auth_signing_alg.is_some() { + return Err(Error::AuthSigningAlg); + } + } + AuthMethod::PrivateKeyJwt => { + if let Some(keyset) = keyset { + if metadata.token_endpoint_auth_signing_alg.is_none() { + return Err(Error::AuthSigningAlg); + } + if jwks_uri.is_none() { + jwks = Some(keyset.public_jwks()); + } + } else { + return Err(Error::EmptyJwks); + } + } + } + Ok(OAuthClientMetadata { + client_id: metadata.client_id, + client_uri: metadata.client_uri, + redirect_uris: metadata.redirect_uris, + token_endpoint_auth_method: Some(metadata.token_endpoint_auth_method.into()), + grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()), + scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), + dpop_bound_access_tokens: Some(true), + jwks_uri, + jwks, + token_endpoint_auth_signing_alg: metadata.token_endpoint_auth_signing_alg, + }) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::scopes::TransitionScope; + + use super::*; + use elliptic_curve::SecretKey; + use jose_jwk::{Jwk, Key, Parameters}; + use p256::pkcs8::DecodePrivateKey; + + const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T +4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P +gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 +-----END PRIVATE KEY-----"#; + + #[test] + fn test_localhost_client_metadata_default() { + assert_eq!( + localhost_client_metadata(None, None).expect("failed to convert metadata"), + OAuthClientMetadata { + client_id: Url::from_str("http://localhost").unwrap(), + client_uri: None, + redirect_uris: vec![ + Url::from_str("http://127.0.0.1/").unwrap(), + Url::from_str("http://[::1]/").unwrap(), + ], + scope: None, + grant_types: None, + token_endpoint_auth_method: Some(AuthMethod::None.into()), + dpop_bound_access_tokens: None, + jwks_uri: None, + jwks: None, + token_endpoint_auth_signing_alg: None, + } + ); + } + + #[test] + fn test_localhost_client_metadata_custom() { + assert_eq!( + localhost_client_metadata( + Some(vec![ + Url::from_str("http://127.0.0.1/callback").unwrap(), + Url::from_str("http://[::1]/callback").unwrap(), + ]), + Some( + vec![ + Scope::Atproto, + Scope::Transition(TransitionScope::Generic), + Scope::parse("account:email").unwrap() + ] + .as_slice() + ) + ) + .expect("failed to convert metadata"), + OAuthClientMetadata { + client_id: Url::from_str( + "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" + ).unwrap(), + client_uri: None, + redirect_uris: vec![ + Url::from_str("http://127.0.0.1/callback").unwrap(), + Url::from_str("http://[::1]/callback").unwrap(), + ], + scope: None, + grant_types: None, + token_endpoint_auth_method: Some(AuthMethod::None.into()), + dpop_bound_access_tokens: None, + jwks_uri: None, + jwks: None, + token_endpoint_auth_signing_alg: None, + } + ); + } + + #[test] + fn test_localhost_client_metadata_invalid() { + { + let err = localhost_client_metadata( + Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]), + None, + ) + .expect_err("expected to fail"); + assert!(matches!( + err, + Error::LocalhostClient(LocalhostClientError::NotHttpScheme) + )); + } + { + let err = localhost_client_metadata( + Some(vec![Url::from_str("http://localhost:8000/").unwrap()]), + None, + ) + .expect_err("expected to fail"); + assert!(matches!( + err, + Error::LocalhostClient(LocalhostClientError::Localhost) + )); + } + { + let err = localhost_client_metadata( + Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]), + None, + ) + .expect_err("expected to fail"); + assert!(matches!( + err, + Error::LocalhostClient(LocalhostClientError::NotLoopbackHost) + )); + } + } + + #[test] + fn test_client_metadata() { + let metadata = AtprotoClientMetadata { + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), + client_uri: Some(Url::from_str("https://example.com").unwrap()), + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], + token_endpoint_auth_method: AuthMethod::PrivateKeyJwt, + grant_types: vec![GrantType::AuthorizationCode], + scopes: vec![Scope::Atproto], + jwks_uri: None, + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), + }; + { + let metadata = metadata.clone(); + let err = atproto_client_metadata(metadata, &None).expect_err("expected to fail"); + assert!(matches!(err, Error::EmptyJwks)); + } + { + let metadata = metadata.clone(); + let secret_key = SecretKey::::from_pkcs8_pem(PRIVATE_KEY) + .expect("failed to parse private key"); + let keys = vec![Jwk { + key: Key::from(&secret_key.into()), + prm: Parameters { + kid: Some(String::from("kid00")), + ..Default::default() + }, + }]; + let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset"); + assert_eq!( + atproto_client_metadata(metadata, &Some(keyset.clone())) + .expect("failed to convert metadata"), + OAuthClientMetadata { + client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), + client_uri: Some(Url::from_str("https://example.com").unwrap()), + redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], + scope: Some(CowStr::new_static("atproto")), + grant_types: Some(vec![CowStr::new_static("authorization_code")]), + token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), + dpop_bound_access_tokens: Some(true), + jwks_uri: None, + jwks: Some(keyset.public_jwks()), + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), + } + ); + } + } +} diff --git a/crates/jacquard-oauth/src/dpop.rs b/crates/jacquard-oauth/src/dpop.rs new file mode 100644 index 00000000..756c9914 --- /dev/null +++ b/crates/jacquard-oauth/src/dpop.rs @@ -0,0 +1,260 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use http::{Request, Response, header::InvalidHeaderValue}; +use jacquard_common::{ + CowStr, + http_client::HttpClient, + session::{MemorySessionStore, SessionStore, SessionStoreError}, +}; +use jose_jwa::{Algorithm, Signing}; +use jose_jwk::{Jwk, Key, crypto}; +use p256::ecdsa::SigningKey; +use rand::{RngCore, SeedableRng}; +use sha2::Digest; +use smol_str::{SmolStr, ToSmolStr}; + +use crate::jose::{ + create_signed_jwt, + jws::RegisteredHeader, + jwt::{Claims, PublicClaims, RegisteredClaims}, +}; + +pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; + +#[derive(serde::Deserialize)] +struct ErrorResponse { + error: String, +} + +#[derive(thiserror::Error, Debug, miette::Diagnostic)] +pub enum Error { + #[error(transparent)] + InvalidHeaderValue(#[from] InvalidHeaderValue), + #[error(transparent)] + SessionStore(#[from] SessionStoreError), + #[error("crypto error: {0:?}")] + JwkCrypto(crypto::Error), + #[error("key does not match any alg supported by the server")] + UnsupportedKey, + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + #[error("Inner: {0}")] + Inner(#[source] Box), +} + +type Result = core::result::Result; + +#[inline] +pub(crate) fn generate_jti() -> CowStr<'static> { + let mut rng = rand::rngs::SmallRng::from_entropy(); + let mut bytes = [0u8; 12]; + rng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes).into() +} + +/// Build a compact JWS (ES256) for DPoP with embedded public JWK. +#[inline] +pub fn build_dpop_proof<'s>( + key: &Key, + method: CowStr<'s>, + url: CowStr<'s>, + nonce: Option>, + ath: Option>, +) -> Result> { + let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, + _ => return Err(Error::UnsupportedKey), + }; + build_dpop_proof_with_secret(&secret, method, url, nonce, ath) +} + +/// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips. +#[inline] +pub fn build_dpop_proof_with_secret<'s>( + secret: &p256::SecretKey, + method: CowStr<'s>, + url: CowStr<'s>, + nonce: Option>, + ath: Option>, +) -> Result> { + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); + header.typ = Some(JWT_HEADER_TYP_DPOP.into()); + header.jwk = Some(Jwk { + key: Key::from(&crypto::Key::from(secret.public_key())), + prm: Default::default(), + }); + + let claims = Claims { + registered: RegisteredClaims { + jti: Some(generate_jti()), + iat: Some(Utc::now().timestamp()), + ..Default::default() + }, + public: PublicClaims { + htm: Some(method), + htu: Some(url), + ath: ath, + nonce: nonce, + }, + }; + Ok(create_signed_jwt( + SigningKey::from(secret.clone()), + header.into(), + claims, + )?) +} + +pub struct DpopClient, CowStr<'static>>> +where + S: SessionStore, CowStr<'static>>, +{ + inner: T, + pub(crate) key: Key, + nonces: S, + is_auth_server: bool, +} + +impl DpopClient { + pub fn new( + key: Key, + http_client: T, + is_auth_server: bool, + supported_algs: &Option>>, + ) -> Result { + if let Some(algs) = supported_algs { + let alg = CowStr::from(match &key { + Key::Ec(ec) => match &ec.crv { + jose_jwk::EcCurves::P256 => "ES256", + _ => unimplemented!(), + }, + _ => unimplemented!(), + }); + if !algs.contains(&alg) { + return Err(Error::UnsupportedKey); + } + } + let nonces = MemorySessionStore::, CowStr<'static>>::default(); + Ok(Self { + inner: http_client, + key, + nonces, + is_auth_server, + }) + } +} + +impl DpopClient +where + S: SessionStore, CowStr<'static>>, +{ + fn build_proof<'s>( + &self, + method: CowStr<'s>, + url: CowStr<'s>, + ath: Option>, + nonce: Option>, + ) -> Result> { + build_dpop_proof(&self.key, method, url, nonce, ath) + } + fn is_use_dpop_nonce_error(&self, response: &http::Response>) -> bool { + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid + if self.is_auth_server { + if response.status() == 400 { + if let Ok(res) = serde_json::from_slice::(response.body()) { + return res.error == "use_dpop_nonce"; + }; + } + } + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no + else if response.status() == 401 { + if let Some(www_auth) = response + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + { + return www_auth.starts_with("DPoP") + && www_auth.contains(r#"error="use_dpop_nonce""#); + } + } + false + } +} + +impl HttpClient for DpopClient +where + T: HttpClient + Send + Sync + 'static, + S: SessionStore, CowStr<'static>> + Send + Sync + 'static, +{ + type Error = Error; + + async fn send_http( + &self, + mut request: Request>, + ) -> core::result::Result>, Self::Error> { + let uri = request.uri(); + let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr()); + let method = CowStr::Owned(request.method().to_smolstr()); + let uri = CowStr::Owned(uri.to_smolstr()); + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 + let ath = request + .headers() + .get("Authorization") + .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) + .map(|auth| { + URL_SAFE_NO_PAD + .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) + .into() + }); + + let init_nonce = self.nonces.get(&nonce_key).await; + let init_proof = + self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?; + request.headers_mut().insert("DPoP", init_proof.parse()?); + let response = self + .inner + .send_http(request.clone()) + .await + .map_err(|e| Error::Inner(e.into()))?; + + let next_nonce = response + .headers() + .get("DPoP-Nonce") + .and_then(|v| v.to_str().ok()) + .map(|c| CowStr::Owned(SmolStr::new(c))); + match &next_nonce { + Some(s) if next_nonce != init_nonce => { + // Store the fresh nonce for future requests + self.nonces.set(nonce_key, s.clone()).await?; + } + _ => { + // No nonce was returned or it is the same as the one we sent. No need to + // update the nonce store, or retry the request. + return Ok(response); + } + } + + if !self.is_use_dpop_nonce_error(&response) { + return Ok(response); + } + let next_proof = self.build_proof(method, uri, ath, next_nonce)?; + request.headers_mut().insert("DPoP", next_proof.parse()?); + let response = self + .inner + .send_http(request) + .await + .map_err(|e| Error::Inner(e.into()))?; + Ok(response) + } +} + +impl Clone for DpopClient { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + key: self.key.clone(), + nonces: self.nonces.clone(), + is_auth_server: self.is_auth_server, + } + } +} diff --git a/crates/jacquard-oauth/src/error.rs b/crates/jacquard-oauth/src/error.rs new file mode 100644 index 00000000..bf41f7fa --- /dev/null +++ b/crates/jacquard-oauth/src/error.rs @@ -0,0 +1,42 @@ +use miette::Diagnostic; +use thiserror::Error; + +/// Errors emitted by OAuth helpers. +#[derive(Debug, Error, Diagnostic)] +pub enum OAuthError { + /// Invalid or unsupported JWK + #[error("invalid JWK: {0}")] + #[diagnostic( + code(jacquard_oauth::jwk), + help("Ensure EC P-256 JWK with base64url x,y,d values") + )] + Jwk(String), + /// Signing error + #[error("signing error: {0}")] + #[diagnostic( + code(jacquard_oauth::signing), + help("Check ES256 key material and input payloads") + )] + Signing(String), + /// Serialization error + #[error(transparent)] + #[diagnostic(code(jacquard_oauth::serde))] + Serde(#[from] serde_json::Error), + /// URL error + #[error(transparent)] + #[diagnostic(code(jacquard_oauth::url))] + Url(#[from] url::ParseError), + /// URL error + #[error(transparent)] + #[diagnostic(code(jacquard_oauth::url))] + UrlEncoding(#[from] serde_html_form::ser::Error), + /// PKCE error + #[error("pkce error: {0}")] + #[diagnostic( + code(jacquard_oauth::pkce), + help("PKCE must use S256; ensure verifier/challenge generated") + )] + Pkce(String), +} + +pub type Result = core::result::Result; diff --git a/crates/jacquard-oauth/src/jose.rs b/crates/jacquard-oauth/src/jose.rs new file mode 100644 index 00000000..1291ba3a --- /dev/null +++ b/crates/jacquard-oauth/src/jose.rs @@ -0,0 +1,14 @@ +pub mod jws; +pub mod jwt; +pub mod signing; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Header<'a> { + #[serde(borrow)] + Jws(jws::Header<'a>), +} + +pub use self::signing::create_signed_jwt; diff --git a/crates/jacquard-oauth/src/jose/jws.rs b/crates/jacquard-oauth/src/jose/jws.rs new file mode 100644 index 00000000..7dd81162 --- /dev/null +++ b/crates/jacquard-oauth/src/jose/jws.rs @@ -0,0 +1,85 @@ +use jacquard_common::{CowStr, IntoStatic}; +use jose_jwa::Algorithm; +use jose_jwk::Jwk; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Header<'a> { + #[serde(flatten)] + #[serde(borrow)] + pub registered: RegisteredHeader<'a>, +} + +impl<'a> From> for super::super::jose::Header<'a> { + fn from(header: Header<'a>) -> Self { + super::super::jose::Header::Jws(header) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + +pub struct RegisteredHeader<'a> { + pub alg: Algorithm, + #[serde(borrow)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jku: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5u: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5c: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5t: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "x5t#S256")] + pub x5ts256: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub typ: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cty: Option>, +} + +impl From for RegisteredHeader<'_> { + fn from(alg: Algorithm) -> Self { + Self { + alg, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5ts256: None, + typ: None, + cty: None, + } + } +} + +impl<'a> From> for super::super::jose::Header<'a> { + fn from(registered: RegisteredHeader<'a>) -> Self { + super::super::jose::Header::Jws(Header { registered }) + } +} + +impl IntoStatic for RegisteredHeader<'_> { + type Output = RegisteredHeader<'static>; + fn into_static(self) -> Self::Output { + RegisteredHeader { + alg: self.alg, + jku: self.jku.map(IntoStatic::into_static), + jwk: self.jwk, + kid: self.kid.map(IntoStatic::into_static), + x5u: self.x5u.map(IntoStatic::into_static), + x5c: self.x5c.map(IntoStatic::into_static), + x5t: self.x5t.map(IntoStatic::into_static), + x5ts256: self.x5ts256.map(IntoStatic::into_static), + typ: self.typ.map(IntoStatic::into_static), + cty: self.cty.map(IntoStatic::into_static), + } + } +} diff --git a/crates/jacquard-oauth/src/jose/jwt.rs b/crates/jacquard-oauth/src/jose/jwt.rs new file mode 100644 index 00000000..4652e47b --- /dev/null +++ b/crates/jacquard-oauth/src/jose/jwt.rs @@ -0,0 +1,101 @@ +use jacquard_common::{CowStr, IntoStatic}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Claims<'a> { + #[serde(flatten)] + pub registered: RegisteredClaims<'a>, + #[serde(flatten)] + #[serde(borrow)] + pub public: PublicClaims<'a>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] + +pub struct RegisteredClaims<'a> { + #[serde(borrow)] + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] + +pub struct PublicClaims<'a> { + #[serde(borrow)] + #[serde(skip_serializing_if = "Option::is_none")] + pub htm: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub htu: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ath: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option>, +} + +impl<'a> From> for Claims<'a> { + fn from(registered: RegisteredClaims<'a>) -> Self { + Self { + registered, + public: PublicClaims::default(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RegisteredClaimsAud<'a> { + #[serde(borrow)] + Single(CowStr<'a>), + Multiple(Vec>), +} + +impl IntoStatic for RegisteredClaims<'_> { + type Output = RegisteredClaims<'static>; + fn into_static(self) -> Self::Output { + RegisteredClaims { + iss: self.iss.map(IntoStatic::into_static), + sub: self.sub.map(IntoStatic::into_static), + aud: self.aud.map(IntoStatic::into_static), + exp: self.exp, + nbf: self.nbf, + iat: self.iat, + jti: self.jti.map(IntoStatic::into_static), + } + } +} + +impl IntoStatic for PublicClaims<'_> { + type Output = PublicClaims<'static>; + fn into_static(self) -> Self::Output { + PublicClaims { + htm: self.htm.map(IntoStatic::into_static), + htu: self.htu.map(IntoStatic::into_static), + ath: self.ath.map(IntoStatic::into_static), + nonce: self.nonce.map(IntoStatic::into_static), + } + } +} + +impl IntoStatic for RegisteredClaimsAud<'_> { + type Output = RegisteredClaimsAud<'static>; + fn into_static(self) -> Self::Output { + match self { + RegisteredClaimsAud::Single(s) => RegisteredClaimsAud::Single(s.into_static()), + RegisteredClaimsAud::Multiple(v) => { + RegisteredClaimsAud::Multiple(v.into_iter().map(IntoStatic::into_static).collect()) + } + } + } +} diff --git a/crates/jacquard-oauth/src/jose/signing.rs b/crates/jacquard-oauth/src/jose/signing.rs new file mode 100644 index 00000000..64be56eb --- /dev/null +++ b/crates/jacquard-oauth/src/jose/signing.rs @@ -0,0 +1,21 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jacquard_common::CowStr; +use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + +use super::{Header, jwt::Claims}; + +pub fn create_signed_jwt( + key: SigningKey, + header: Header, + claims: Claims, +) -> serde_json::Result> { + let header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims)?); + let signature: Signature = key.sign(format!("{header}.{payload}").as_bytes()); + Ok(format!( + "{header}.{payload}.{}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ) + .into()) +} diff --git a/crates/jacquard-oauth/src/keyset.rs b/crates/jacquard-oauth/src/keyset.rs new file mode 100644 index 00000000..8d2917db --- /dev/null +++ b/crates/jacquard-oauth/src/keyset.rs @@ -0,0 +1,128 @@ +use crate::jose::create_signed_jwt; +use crate::jose::jws::RegisteredHeader; +use crate::jose::jwt::Claims; +use jacquard_common::CowStr; +use jose_jwa::{Algorithm, Signing}; +use jose_jwk::{Class, EcCurves, crypto}; +use jose_jwk::{Jwk, JwkSet, Key}; +use smol_str::{SmolStr, ToSmolStr}; +use std::collections::HashSet; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("duplicate kid: {0}")] + DuplicateKid(String), + #[error("keys must not be empty")] + EmptyKeys, + #[error("key must have a `kid`")] + EmptyKid, + #[error("no signing key found for algorithms: {0:?}")] + NotFound(Vec), + #[error("key for signing must be a secret key")] + PublicKey, + #[error("crypto error: {0:?}")] + JwkCrypto(crypto::Error), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), +} + +pub type Result = core::result::Result; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Keyset(Vec); + +impl Keyset { + const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [ + "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", + ]; + pub fn public_jwks(&self) -> JwkSet { + let mut keys = Vec::with_capacity(self.0.len()); + for mut key in self.0.clone() { + match key.key { + Key::Ec(ref mut ec) => { + ec.d = None; + } + _ => unimplemented!(), + } + keys.push(key); + } + JwkSet { keys } + } + pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result> { + let Some(jwk) = self.find_key(algs, Class::Signing) else { + return Err(Error::NotFound(algs.to_vec())); + }; + self.create_jwt_with_key(jwk, claims) + } + fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> { + let candidates = self + .0 + .iter() + .filter_map(|key| { + if key.prm.cls.is_some_and(|c| c != cls) { + return None; + } + let alg = match &key.key { + Key::Ec(ec) => match ec.crv { + EcCurves::P256 => "ES256", + _ => unimplemented!(), + }, + _ => unimplemented!(), + }; + Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr())) + }) + .collect::>(); + for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { + for (alg, key) in &candidates { + if alg == &pref_alg { + return Some(key); + } + } + } + None + } + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result> { + let kid = key.prm.kid.clone().unwrap(); + match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); + header.kid = Some(kid.into()); + Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) + } + _ => unimplemented!(), + } + } +} + +impl TryFrom> for Keyset { + type Error = Error; + + fn try_from(keys: Vec) -> Result { + if keys.is_empty() { + return Err(Error::EmptyKeys); + } + let mut v = Vec::with_capacity(keys.len()); + let mut hs = HashSet::with_capacity(keys.len()); + for key in keys { + if let Some(kid) = key.prm.kid.clone() { + if hs.contains(&kid) { + return Err(Error::DuplicateKid(kid)); + } + hs.insert(kid); + // ensure that the key is a secret key + if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Public(_)) => true, + crypto::Key::P256(crypto::Kind::Secret(_)) => false, + _ => unimplemented!(), + } { + return Err(Error::PublicKey); + } + v.push(key); + } else { + return Err(Error::EmptyKid); + } + } + Ok(Self(v)) + } +} diff --git a/crates/jacquard-oauth/src/lib.rs b/crates/jacquard-oauth/src/lib.rs new file mode 100644 index 00000000..15334032 --- /dev/null +++ b/crates/jacquard-oauth/src/lib.rs @@ -0,0 +1,14 @@ +//! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard. +//! Transport, discovery, and orchestration live in `jacquard`. + +pub mod atproto; +pub mod dpop; +pub mod error; +pub mod jose; +pub mod keyset; +pub mod resolver; +pub mod scopes; +pub mod session; +pub mod types; + +pub const FALLBACK_ALG: &str = "ES256"; diff --git a/crates/jacquard-oauth/src/resolver.rs b/crates/jacquard-oauth/src/resolver.rs new file mode 100644 index 00000000..e6639a19 --- /dev/null +++ b/crates/jacquard-oauth/src/resolver.rs @@ -0,0 +1,214 @@ +use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; +use http::{Request, StatusCode}; +use jacquard_common::IntoStatic; +use jacquard_common::ident_resolver::{IdentityError, IdentityResolver}; +use jacquard_common::types::did_doc::DidDocument; +use jacquard_common::types::ident::AtIdentifier; +use jacquard_common::{http_client::HttpClient, types::did::Did}; +use url::Url; + +#[derive(thiserror::Error, Debug, miette::Diagnostic)] +pub enum ResolverError { + #[error("resource not found")] + NotFound, + #[error("invalid at identifier: {0}")] + AtIdentifier(String), + #[error("invalid did: {0}")] + Did(String), + #[error("invalid did document: {0}")] + DidDocument(String), + #[error("protected resource metadata is invalid: {0}")] + ProtectedResourceMetadata(String), + #[error("authorization server metadata is invalid: {0}")] + AuthorizationServerMetadata(String), + #[error("error resolving identity: {0}")] + IdentityResolverError(#[from] IdentityError), + #[error("unsupported did method: {0:?}")] + UnsupportedDidMethod(Did<'static>), + #[error(transparent)] + Http(#[from] http::Error), + #[error("http client error: {0}")] + HttpClient(Box), + #[error("http status: {0:?}")] + HttpStatus(StatusCode), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + #[error(transparent)] + SerdeHtmlForm(#[from] serde_html_form::ser::Error), + #[error(transparent)] + Uri(#[from] url::ParseError), +} + +#[async_trait::async_trait] +pub trait OAuthResolver: IdentityResolver + HttpClient { + async fn resolve_oauth( + &self, + input: &str, + ) -> Result< + ( + OAuthAuthorizationServerMetadata<'static>, + Option>, + ), + ResolverError, + > { + // Allow using an entryway, or PDS url, directly as login input (e.g. + // when the user forgot their handle, or when the handle does not + // resolve to a DID) + Ok(if input.starts_with("https://") { + let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; + (self.resolve_from_service(&url).await?, None) + } else { + let (metadata, identity) = self.resolve_from_identity(input).await?; + (metadata, Some(identity)) + }) + } + async fn resolve_from_service( + &self, + input: &Url, + ) -> Result, ResolverError> { + // Assume first that input is a PDS URL (as required by ATPROTO) + if let Ok(metadata) = self.get_resource_server_metadata(input).await { + return Ok(metadata); + } + // Fallback to trying to fetch as an issuer (Entryway) + self.get_authorization_server_metadata(input).await + } + async fn resolve_from_identity( + &self, + input: &str, + ) -> Result< + ( + OAuthAuthorizationServerMetadata<'static>, + DidDocument<'static>, + ), + ResolverError, + > { + let actor = AtIdentifier::new(input) + .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; + let identity = self.resolve_ident_owned(&actor).await?; + if let Some(pds) = &identity.pds_endpoint() { + let metadata = self.get_resource_server_metadata(pds).await?; + Ok((metadata, identity)) + } else { + Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) + } + } + async fn get_authorization_server_metadata( + &self, + issuer: &Url, + ) -> Result, ResolverError> { + Ok(resolve_authorization_server(self, issuer).await?) + } + async fn get_resource_server_metadata( + &self, + pds: &Url, + ) -> Result, ResolverError> { + let rs_metadata = resolve_protected_resource_info(self, pds).await?; + // ATPROTO requires one, and only one, authorization server entry + // > That document MUST contain a single item in the authorization_servers array. + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata + let issuer = match &rs_metadata.authorization_servers { + Some(servers) if !servers.is_empty() => { + if servers.len() > 1 { + return Err(ResolverError::ProtectedResourceMetadata(format!( + "unable to determine authorization server for PDS: {pds}" + ))); + } + &servers[0] + } + _ => { + return Err(ResolverError::ProtectedResourceMetadata(format!( + "no authorization server found for PDS: {pds}" + ))); + } + }; + let as_metadata = self.get_authorization_server_metadata(issuer).await?; + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada + if let Some(protected_resources) = &as_metadata.protected_resources { + if !protected_resources.contains(&rs_metadata.resource) { + return Err(ResolverError::AuthorizationServerMetadata(format!( + "pds {pds} does not protected by issuer: {issuer}", + ))); + } + } + + // TODO: atproot specific validation? + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata + // + // eg. + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html + // if as_metadata.client_id_metadata_document_supported != Some(true) { + // return Err(Error::AuthorizationServerMetadata(format!( + // "authorization server does not support client_id_metadata_document: {issuer}" + // ))); + // } + + Ok(as_metadata) + } +} + +pub async fn resolve_authorization_server( + client: &T, + server: &Url, +) -> Result, ResolverError> { + let url = server + .join("/.well-known/oauth-authorization-server") + .map_err(|e| ResolverError::HttpClient(e.into()))?; + + let req = Request::builder() + .uri(url.to_string()) + .body(Vec::new()) + .map_err(|e| ResolverError::HttpClient(e.into()))?; + let res = client + .send_http(req) + .await + .map_err(|e| ResolverError::HttpClient(e.into()))?; + if res.status() == StatusCode::OK { + let metadata = serde_json::from_slice::(res.body()) + .map_err(ResolverError::SerdeJson)?; + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 + if metadata.issuer == server.as_str() { + Ok(metadata.into_static()) + } else { + Err(ResolverError::AuthorizationServerMetadata(format!( + "invalid issuer: {}", + metadata.issuer + ))) + } + } else { + Err(ResolverError::HttpStatus(res.status())) + } +} + +pub async fn resolve_protected_resource_info( + client: &T, + server: &Url, +) -> Result, ResolverError> { + let url = server + .join("/.well-known/oauth-protected-resource") + .map_err(|e| ResolverError::HttpClient(e.into()))?; + + let req = Request::builder() + .uri(url.to_string()) + .body(Vec::new()) + .map_err(|e| ResolverError::HttpClient(e.into()))?; + let res = client + .send_http(req) + .await + .map_err(|e| ResolverError::HttpClient(e.into()))?; + if res.status() == StatusCode::OK { + let metadata = serde_json::from_slice::(res.body()) + .map_err(ResolverError::SerdeJson)?; + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 + if metadata.resource == server.as_str() { + Ok(metadata.into_static()) + } else { + Err(ResolverError::AuthorizationServerMetadata(format!( + "invalid resource: {}", + metadata.resource + ))) + } + } else { + Err(ResolverError::HttpStatus(res.status())) + } +} diff --git a/crates/jacquard-oauth/src/scopes.rs b/crates/jacquard-oauth/src/scopes.rs new file mode 100644 index 00000000..9f76d482 --- /dev/null +++ b/crates/jacquard-oauth/src/scopes.rs @@ -0,0 +1,1969 @@ +//! AT Protocol OAuth scopes module +//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs +//! +//! This module provides comprehensive support for AT Protocol OAuth scopes, +//! including parsing, serialization, normalization, and permission checking. +//! +//! Scopes in AT Protocol follow a prefix-based format with optional query parameters: +//! - `account`: Access to account information (email, repo, status) +//! - `identity`: Access to identity information (handle) +//! - `blob`: Access to blob operations with mime type constraints +//! - `repo`: Repository operations with collection and action constraints +//! - `rpc`: RPC method access with lexicon and audience constraints +//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used +//! - `transition`: Migration operations (generic or email) +//! +//! Standard OpenID Connect scopes (no suffixes or query parameters): +//! - `openid`: Required for OpenID Connect authentication +//! - `profile`: Access to user profile information +//! - `email`: Access to user email address + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::str::FromStr; + +use jacquard_common::types::did::Did; +use jacquard_common::types::nsid::Nsid; +use jacquard_common::types::string::AtStrError; +use jacquard_common::{CowStr, IntoStatic}; +use smol_str::{SmolStr, ToSmolStr}; + +/// Represents an AT Protocol OAuth scope +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Scope<'s> { + /// Account scope for accessing account information + Account(AccountScope), + /// Identity scope for accessing identity information + Identity(IdentityScope), + /// Blob scope for blob operations with mime type constraints + Blob(BlobScope<'s>), + /// Repository scope for collection operations + Repo(RepoScope<'s>), + /// RPC scope for method access + Rpc(RpcScope<'s>), + /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used + Atproto, + /// Transition scope for migration operations + Transition(TransitionScope), + /// OpenID Connect scope - required for OpenID Connect authentication + OpenId, + /// Profile scope - access to user profile information + Profile, + /// Email scope - access to user email address + Email, +} + +impl IntoStatic for Scope<'_> { + type Output = Scope<'static>; + + fn into_static(self) -> Self::Output { + match self { + Scope::Account(scope) => Scope::Account(scope), + Scope::Identity(scope) => Scope::Identity(scope), + Scope::Blob(scope) => Scope::Blob(scope.into_static()), + Scope::Repo(scope) => Scope::Repo(scope.into_static()), + Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), + Scope::Atproto => Scope::Atproto, + Scope::Transition(scope) => Scope::Transition(scope), + Scope::OpenId => Scope::OpenId, + Scope::Profile => Scope::Profile, + Scope::Email => Scope::Email, + } + } +} + +/// Account scope attributes +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AccountScope { + /// The account resource type + pub resource: AccountResource, + /// The action permission level + pub action: AccountAction, +} + +/// Account resource types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AccountResource { + /// Email access + Email, + /// Repository access + Repo, + /// Status access + Status, +} + +/// Account action permissions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AccountAction { + /// Read-only access + Read, + /// Management access (includes read) + Manage, +} + +/// Identity scope attributes +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum IdentityScope { + /// Handle access + Handle, + /// All identity access (wildcard) + All, +} + +/// Transition scope types +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TransitionScope { + /// Generic transition operations + Generic, + /// Email transition operations + Email, +} + +/// Blob scope with mime type constraints +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BlobScope<'s> { + /// Accepted mime types + pub accept: BTreeSet>, +} + +impl IntoStatic for BlobScope<'_> { + type Output = BlobScope<'static>; + + fn into_static(self) -> Self::Output { + BlobScope { + accept: self.accept.into_iter().map(|p| p.into_static()).collect(), + } + } +} + +/// MIME type pattern for blob scope +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum MimePattern<'s> { + /// Match all types + All, + /// Match all subtypes of a type (e.g., "image/*") + TypeWildcard(CowStr<'s>), + /// Exact mime type match + Exact(CowStr<'s>), +} + +impl IntoStatic for MimePattern<'_> { + type Output = MimePattern<'static>; + + fn into_static(self) -> Self::Output { + match self { + MimePattern::All => MimePattern::All, + MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()), + MimePattern::Exact(s) => MimePattern::Exact(s.into_static()), + } + } +} + +/// Repository scope with collection and action constraints +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RepoScope<'s> { + /// Collection NSID or wildcard + pub collection: RepoCollection<'s>, + /// Allowed actions + pub actions: BTreeSet, +} + +impl IntoStatic for RepoScope<'_> { + type Output = RepoScope<'static>; + + fn into_static(self) -> Self::Output { + RepoScope { + collection: self.collection.into_static(), + actions: self.actions, + } + } +} + +/// Repository collection identifier +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RepoCollection<'s> { + /// All collections (wildcard) + All, + /// Specific collection NSID + Nsid(Nsid<'s>), +} + +impl IntoStatic for RepoCollection<'_> { + type Output = RepoCollection<'static>; + + fn into_static(self) -> Self::Output { + match self { + RepoCollection::All => RepoCollection::All, + RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), + } + } +} + +/// Repository actions +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RepoAction { + /// Create records + Create, + /// Update records + Update, + /// Delete records + Delete, +} + +/// RPC scope with lexicon method and audience constraints +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RpcScope<'s> { + /// Lexicon methods (NSIDs or wildcard) + pub lxm: BTreeSet>, + /// Audiences (DIDs or wildcard) + pub aud: BTreeSet>, +} + +impl IntoStatic for RpcScope<'_> { + type Output = RpcScope<'static>; + + fn into_static(self) -> Self::Output { + RpcScope { + lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(), + aud: self.aud.into_iter().map(|s| s.into_static()).collect(), + } + } +} + +/// RPC lexicon identifier +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RpcLexicon<'s> { + /// All lexicons (wildcard) + All, + /// Specific lexicon NSID + Nsid(Nsid<'s>), +} + +impl IntoStatic for RpcLexicon<'_> { + type Output = RpcLexicon<'static>; + + fn into_static(self) -> Self::Output { + match self { + RpcLexicon::All => RpcLexicon::All, + RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()), + } + } +} + +/// RPC audience identifier +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RpcAudience<'s> { + /// All audiences (wildcard) + All, + /// Specific DID + Did(Did<'s>), +} + +impl IntoStatic for RpcAudience<'_> { + type Output = RpcAudience<'static>; + + fn into_static(self) -> Self::Output { + match self { + RpcAudience::All => RpcAudience::All, + RpcAudience::Did(did) => RpcAudience::Did(did.into_static()), + } + } +} + +impl<'s> Scope<'s> { + /// Parse multiple space-separated scopes from a string + /// + /// # Examples + /// ``` + /// # use jacquard_oauth::scopes::Scope; + /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); + /// assert_eq!(scopes.len(), 2); + /// ``` + pub fn parse_multiple(s: &'s str) -> Result, ParseError> { + if s.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut scopes = Vec::new(); + for scope_str in s.split_whitespace() { + scopes.push(Self::parse(scope_str)?); + } + + Ok(scopes) + } + + /// Parse multiple space-separated scopes and return the minimal set needed + /// + /// This method removes duplicate scopes and scopes that are already granted + /// by other scopes in the list, returning only the minimal set of scopes needed. + /// + /// # Examples + /// ``` + /// # use jacquard_oauth::scopes::Scope; + /// // repo:* grants repo:foo.bar, so only repo:* is kept + /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); + /// assert_eq!(scopes.len(), 2); // atproto and repo:* + /// ``` + pub fn parse_multiple_reduced(s: &'s str) -> Result, ParseError> { + let all_scopes = Self::parse_multiple(s)?; + + if all_scopes.is_empty() { + return Ok(Vec::new()); + } + + let mut result: Vec = Vec::new(); + + for scope in all_scopes { + // Check if this scope is already granted by something in the result + let mut is_granted = false; + for existing in &result { + if existing.grants(&scope) && existing != &scope { + is_granted = true; + break; + } + } + + if is_granted { + continue; // Skip this scope, it's already covered + } + + // Check if this scope grants any existing scopes in the result + let mut indices_to_remove = Vec::new(); + for (i, existing) in result.iter().enumerate() { + if scope.grants(existing) && &scope != existing { + indices_to_remove.push(i); + } + } + + // Remove scopes that are granted by the new scope (in reverse order to maintain indices) + for i in indices_to_remove.into_iter().rev() { + result.remove(i); + } + + // Add the new scope if it's not a duplicate + if !result.contains(&scope) { + result.push(scope); + } + } + + Ok(result) + } + + /// Serialize a list of scopes into a space-separated OAuth scopes string + /// + /// The scopes are sorted alphabetically by their string representation to ensure + /// consistent output regardless of input order. + /// + /// # Examples + /// ``` + /// # use jacquard_oauth::scopes::Scope; + /// let scopes = vec![ + /// Scope::parse("repo:*").unwrap(), + /// Scope::parse("atproto").unwrap(), + /// Scope::parse("account:email").unwrap(), + /// ]; + /// let result = Scope::serialize_multiple(&scopes); + /// assert_eq!(result, "account:email atproto repo:*"); + /// ``` + pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> { + if scopes.is_empty() { + return CowStr::default(); + } + + let mut serialized: Vec = scopes.iter().map(|scope| scope.to_string()).collect(); + + serialized.sort(); + serialized.join(" ").into() + } + + /// Remove a scope from a list of scopes + /// + /// Returns a new vector with all instances of the specified scope removed. + /// If the scope doesn't exist in the list, returns a copy of the original list. + /// + /// # Examples + /// ``` + /// # use jacquard_oauth::scopes::Scope; + /// let scopes = vec![ + /// Scope::parse("repo:*").unwrap(), + /// Scope::parse("atproto").unwrap(), + /// Scope::parse("account:email").unwrap(), + /// ]; + /// let to_remove = Scope::parse("atproto").unwrap(); + /// let result = Scope::remove_scope(&scopes, &to_remove); + /// assert_eq!(result.len(), 2); + /// assert!(!result.contains(&to_remove)); + /// ``` + pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec { + scopes + .iter() + .filter(|s| *s != scope_to_remove) + .cloned() + .collect() + } + + /// Parse a scope from a string + pub fn parse(s: &'s str) -> Result { + // Determine the prefix first by checking for known prefixes + let prefixes = [ + "account", + "identity", + "blob", + "repo", + "rpc", + "atproto", + "transition", + "openid", + "profile", + "email", + ]; + let mut found_prefix = None; + let mut suffix = None; + + for prefix in &prefixes { + if let Some(remainder) = s.strip_prefix(prefix) + && (remainder.is_empty() + || remainder.starts_with(':') + || remainder.starts_with('?')) + { + found_prefix = Some(*prefix); + if let Some(stripped) = remainder.strip_prefix(':') { + suffix = Some(stripped); + } else if remainder.starts_with('?') { + suffix = Some(remainder); + } else { + suffix = None; + } + break; + } + } + + let prefix = found_prefix.ok_or_else(|| { + // If no known prefix found, extract what looks like a prefix for error reporting + let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); + ParseError::UnknownPrefix(s[..end].to_string()) + })?; + + match prefix { + "account" => Self::parse_account(suffix), + "identity" => Self::parse_identity(suffix), + "blob" => Self::parse_blob(suffix), + "repo" => Self::parse_repo(suffix), + "rpc" => Self::parse_rpc(suffix), + "atproto" => Self::parse_atproto(suffix), + "transition" => Self::parse_transition(suffix), + "openid" => Self::parse_openid(suffix), + "profile" => Self::parse_profile(suffix), + "email" => Self::parse_email(suffix), + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), + } + } + + fn parse_account(suffix: Option<&'s str>) -> Result { + let (resource_str, params) = match suffix { + Some(s) => { + if let Some(pos) = s.find('?') { + (&s[..pos], Some(&s[pos + 1..])) + } else { + (s, None) + } + } + None => return Err(ParseError::MissingResource), + }; + + let resource = match resource_str { + "email" => AccountResource::Email, + "repo" => AccountResource::Repo, + "status" => AccountResource::Status, + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), + }; + + let action = if let Some(params) = params { + let parsed_params = parse_query_string(params); + match parsed_params + .get("action") + .and_then(|v| v.first()) + .map(|s| s.as_ref()) + { + Some("read") => AccountAction::Read, + Some("manage") => AccountAction::Manage, + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), + None => AccountAction::Read, + } + } else { + AccountAction::Read + }; + + Ok(Scope::Account(AccountScope { resource, action })) + } + + fn parse_identity(suffix: Option<&'s str>) -> Result { + let scope = match suffix { + Some("handle") => IdentityScope::Handle, + Some("*") => IdentityScope::All, + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), + None => return Err(ParseError::MissingResource), + }; + + Ok(Scope::Identity(scope)) + } + + fn parse_blob(suffix: Option<&'s str>) -> Result { + let mut accept = BTreeSet::new(); + + match suffix { + Some(s) if s.starts_with('?') => { + let params = parse_query_string(&s[1..]); + if let Some(values) = params.get("accept") { + for value in values { + accept.insert(MimePattern::from_str(value)?); + } + } + } + Some(s) => { + accept.insert(MimePattern::from_str(s)?); + } + None => { + accept.insert(MimePattern::All); + } + } + + if accept.is_empty() { + accept.insert(MimePattern::All); + } + + Ok(Scope::Blob(BlobScope { accept })) + } + + fn parse_repo(suffix: Option<&'s str>) -> Result { + let (collection_str, params) = match suffix { + Some(s) => { + if let Some(pos) = s.find('?') { + (Some(&s[..pos]), Some(&s[pos + 1..])) + } else { + (Some(s), None) + } + } + None => (None, None), + }; + + let collection = match collection_str { + Some("*") | None => RepoCollection::All, + Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?), + }; + + let mut actions = BTreeSet::new(); + if let Some(params) = params { + let parsed_params = parse_query_string(params); + if let Some(values) = parsed_params.get("action") { + for value in values { + match value.as_ref() { + "create" => { + actions.insert(RepoAction::Create); + } + "update" => { + actions.insert(RepoAction::Update); + } + "delete" => { + actions.insert(RepoAction::Delete); + } + "*" => { + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + } + other => return Err(ParseError::InvalidAction(other.to_string())), + } + } + } + } + + if actions.is_empty() { + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + } + + Ok(Scope::Repo(RepoScope { + collection, + actions, + })) + } + + fn parse_rpc(suffix: Option<&'s str>) -> Result { + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + + match suffix { + Some("*") => { + lxm.insert(RpcLexicon::All); + aud.insert(RpcAudience::All); + } + Some(s) if s.starts_with('?') => { + let params = parse_query_string(&s[1..]); + + if let Some(values) = params.get("lxm") { + for value in values { + if value.as_ref() == "*" { + lxm.insert(RpcLexicon::All); + } else { + lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static())); + } + } + } + + if let Some(values) = params.get("aud") { + for value in values { + if value.as_ref() == "*" { + aud.insert(RpcAudience::All); + } else { + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); + } + } + } + } + Some(s) => { + // Check if there's a query string in the suffix + if let Some(pos) = s.find('?') { + let nsid = &s[..pos]; + let params = parse_query_string(&s[pos + 1..]); + + lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static())); + + if let Some(values) = params.get("aud") { + for value in values { + if value.as_ref() == "*" { + aud.insert(RpcAudience::All); + } else { + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); + } + } + } + } else { + lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static())); + } + } + None => {} + } + + if lxm.is_empty() { + lxm.insert(RpcLexicon::All); + } + if aud.is_empty() { + aud.insert(RpcAudience::All); + } + + Ok(Scope::Rpc(RpcScope { lxm, aud })) + } + + fn parse_atproto(suffix: Option<&str>) -> Result { + if suffix.is_some() { + return Err(ParseError::InvalidResource( + "atproto scope does not accept suffixes".to_string(), + )); + } + Ok(Scope::Atproto) + } + + fn parse_transition(suffix: Option<&str>) -> Result { + let scope = match suffix { + Some("generic") => TransitionScope::Generic, + Some("email") => TransitionScope::Email, + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), + None => return Err(ParseError::MissingResource), + }; + + Ok(Scope::Transition(scope)) + } + + fn parse_openid(suffix: Option<&str>) -> Result { + if suffix.is_some() { + return Err(ParseError::InvalidResource( + "openid scope does not accept suffixes".to_string(), + )); + } + Ok(Scope::OpenId) + } + + fn parse_profile(suffix: Option<&str>) -> Result { + if suffix.is_some() { + return Err(ParseError::InvalidResource( + "profile scope does not accept suffixes".to_string(), + )); + } + Ok(Scope::Profile) + } + + fn parse_email(suffix: Option<&str>) -> Result { + if suffix.is_some() { + return Err(ParseError::InvalidResource( + "email scope does not accept suffixes".to_string(), + )); + } + Ok(Scope::Email) + } + + /// Convert the scope to its normalized string representation + pub fn to_string_normalized(&self) -> String { + match self { + Scope::Account(scope) => { + let resource = match scope.resource { + AccountResource::Email => "email", + AccountResource::Repo => "repo", + AccountResource::Status => "status", + }; + + match scope.action { + AccountAction::Read => format!("account:{}", resource), + AccountAction::Manage => format!("account:{}?action=manage", resource), + } + } + Scope::Identity(scope) => match scope { + IdentityScope::Handle => "identity:handle".to_string(), + IdentityScope::All => "identity:*".to_string(), + }, + Scope::Blob(scope) => { + if scope.accept.len() == 1 { + if let Some(pattern) = scope.accept.iter().next() { + match pattern { + MimePattern::All => "blob:*/*".to_string(), + MimePattern::TypeWildcard(t) => format!("blob:{}/*", t), + MimePattern::Exact(mime) => format!("blob:{}", mime), + } + } else { + "blob:*/*".to_string() + } + } else { + let mut params = Vec::new(); + for pattern in &scope.accept { + match pattern { + MimePattern::All => params.push("accept=*/*".to_string()), + MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)), + MimePattern::Exact(mime) => params.push(format!("accept={}", mime)), + } + } + params.sort(); + format!("blob?{}", params.join("&")) + } + } + Scope::Repo(scope) => { + let collection = match &scope.collection { + RepoCollection::All => "*", + RepoCollection::Nsid(nsid) => nsid, + }; + + if scope.actions.len() == 3 { + format!("repo:{}", collection) + } else { + let mut params = Vec::new(); + for action in &scope.actions { + match action { + RepoAction::Create => params.push("action=create"), + RepoAction::Update => params.push("action=update"), + RepoAction::Delete => params.push("action=delete"), + } + } + format!("repo:{}?{}", collection, params.join("&")) + } + } + Scope::Rpc(scope) => { + if scope.lxm.len() == 1 + && scope.lxm.contains(&RpcLexicon::All) + && scope.aud.len() == 1 + && scope.aud.contains(&RpcAudience::All) + { + "rpc:*".to_string() + } else if scope.lxm.len() == 1 + && scope.aud.len() == 1 + && scope.aud.contains(&RpcAudience::All) + { + if let Some(lxm) = scope.lxm.iter().next() { + match lxm { + RpcLexicon::All => "rpc:*".to_string(), + RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), + } + } else { + "rpc:*".to_string() + } + } else { + let mut params = Vec::new(); + + for lxm in &scope.lxm { + match lxm { + RpcLexicon::All => params.push("lxm=*".to_string()), + RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)), + } + } + + for aud in &scope.aud { + match aud { + RpcAudience::All => params.push("aud=*".to_string()), + RpcAudience::Did(did) => params.push(format!("aud={}", did)), + } + } + + params.sort(); + + if params.is_empty() { + "rpc:*".to_string() + } else { + format!("rpc?{}", params.join("&")) + } + } + } + Scope::Atproto => "atproto".to_string(), + Scope::Transition(scope) => match scope { + TransitionScope::Generic => "transition:generic".to_string(), + TransitionScope::Email => "transition:email".to_string(), + }, + Scope::OpenId => "openid".to_string(), + Scope::Profile => "profile".to_string(), + Scope::Email => "email".to_string(), + } + } + + /// Check if this scope grants the permissions of another scope + pub fn grants(&self, other: &Scope) -> bool { + match (self, other) { + // Atproto only grants itself (it's a required scope, not a permission grant) + (Scope::Atproto, Scope::Atproto) => true, + (Scope::Atproto, _) => false, + // Nothing else grants atproto + (_, Scope::Atproto) => false, + // Transition scopes only grant themselves + (Scope::Transition(a), Scope::Transition(b)) => a == b, + // Other scopes don't grant transition scopes + (_, Scope::Transition(_)) => false, + (Scope::Transition(_), _) => false, + // OpenID Connect scopes only grant themselves + (Scope::OpenId, Scope::OpenId) => true, + (Scope::OpenId, _) => false, + (_, Scope::OpenId) => false, + (Scope::Profile, Scope::Profile) => true, + (Scope::Profile, _) => false, + (_, Scope::Profile) => false, + (Scope::Email, Scope::Email) => true, + (Scope::Email, _) => false, + (_, Scope::Email) => false, + (Scope::Account(a), Scope::Account(b)) => { + a.resource == b.resource + && matches!( + (a.action, b.action), + (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read) + ) + } + (Scope::Identity(a), Scope::Identity(b)) => matches!( + (a, b), + (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle) + ), + (Scope::Blob(a), Scope::Blob(b)) => { + for b_pattern in &b.accept { + let mut granted = false; + for a_pattern in &a.accept { + if a_pattern.grants(b_pattern) { + granted = true; + break; + } + } + if !granted { + return false; + } + } + true + } + (Scope::Repo(a), Scope::Repo(b)) => { + let collection_match = match (&a.collection, &b.collection) { + (RepoCollection::All, _) => true, + (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { + a_nsid == b_nsid + } + _ => false, + }; + + if !collection_match { + return false; + } + + b.actions.is_subset(&a.actions) || a.actions.len() == 3 + } + (Scope::Rpc(a), Scope::Rpc(b)) => { + let lxm_match = if a.lxm.contains(&RpcLexicon::All) { + true + } else { + b.lxm.iter().all(|b_lxm| match b_lxm { + RpcLexicon::All => false, + RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm), + }) + }; + + let aud_match = if a.aud.contains(&RpcAudience::All) { + true + } else { + b.aud.iter().all(|b_aud| match b_aud { + RpcAudience::All => false, + RpcAudience::Did(_) => a.aud.contains(b_aud), + }) + }; + + lxm_match && aud_match + } + _ => false, + } + } +} + +impl MimePattern<'_> { + fn grants(&self, other: &MimePattern) -> bool { + match (self, other) { + (MimePattern::All, _) => true, + (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { + a_type == b_type + } + (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => { + b_mime.starts_with(&format!("{}/", a_type)) + } + (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b, + _ => false, + } + } +} + +impl FromStr for MimePattern<'_> { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + if s == "*/*" { + Ok(MimePattern::All) + } else if let Some(stripped) = s.strip_suffix("/*") { + Ok(MimePattern::TypeWildcard(CowStr::Owned( + stripped.to_smolstr(), + ))) + } else if s.contains('/') { + Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr()))) + } else { + Err(ParseError::InvalidMimeType(s.to_string())) + } + } +} + +impl FromStr for Scope<'_> { + type Err = ParseError; + + fn from_str(s: &str) -> Result, Self::Err> { + match Scope::parse(s) { + Ok(parsed) => Ok(parsed.into_static()), + Err(e) => Err(e), + } + } +} + +impl fmt::Display for Scope<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_string_normalized()) + } +} + +/// Parse a query string into a map of keys to lists of values +fn parse_query_string(query: &str) -> BTreeMap>> { + let mut params = BTreeMap::new(); + + for pair in query.split('&') { + if let Some(pos) = pair.find('=') { + let key = &pair[..pos]; + let value = &pair[pos + 1..]; + params + .entry(key.to_smolstr()) + .or_insert_with(Vec::new) + .push(CowStr::Owned(value.to_smolstr())); + } + } + + params +} + +/// Error type for scope parsing +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] +pub enum ParseError { + /// Unknown scope prefix + UnknownPrefix(String), + /// Missing required resource + MissingResource, + /// Invalid resource type + InvalidResource(String), + /// Invalid action type + InvalidAction(String), + /// Invalid MIME type + InvalidMimeType(String), + ParseError(#[from] AtStrError), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), + ParseError::MissingResource => write!(f, "Missing required resource"), + ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), + ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), + ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), + ParseError::ParseError(err) => write!(f, "Parse error: {}", err), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_account_scope_parsing() { + let scope = Scope::parse("account:email").unwrap(); + assert_eq!( + scope, + Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Read, + }) + ); + + let scope = Scope::parse("account:repo?action=manage").unwrap(); + assert_eq!( + scope, + Scope::Account(AccountScope { + resource: AccountResource::Repo, + action: AccountAction::Manage, + }) + ); + + let scope = Scope::parse("account:status?action=read").unwrap(); + assert_eq!( + scope, + Scope::Account(AccountScope { + resource: AccountResource::Status, + action: AccountAction::Read, + }) + ); + } + + #[test] + fn test_identity_scope_parsing() { + let scope = Scope::parse("identity:handle").unwrap(); + assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); + + let scope = Scope::parse("identity:*").unwrap(); + assert_eq!(scope, Scope::Identity(IdentityScope::All)); + } + + #[test] + fn test_blob_scope_parsing() { + let scope = Scope::parse("blob:*/*").unwrap(); + let mut accept = BTreeSet::new(); + accept.insert(MimePattern::All); + assert_eq!(scope, Scope::Blob(BlobScope { accept })); + + let scope = Scope::parse("blob:image/png").unwrap(); + let mut accept = BTreeSet::new(); + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); + assert_eq!(scope, Scope::Blob(BlobScope { accept })); + + let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); + let mut accept = BTreeSet::new(); + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); + accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg"))); + assert_eq!(scope, Scope::Blob(BlobScope { accept })); + + let scope = Scope::parse("blob:image/*").unwrap(); + let mut accept = BTreeSet::new(); + accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image"))); + assert_eq!(scope, Scope::Blob(BlobScope { accept })); + } + + #[test] + fn test_repo_scope_parsing() { + let scope = Scope::parse("repo:*?action=create").unwrap(); + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + assert_eq!( + scope, + Scope::Repo(RepoScope { + collection: RepoCollection::All, + actions, + }) + ); + + let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(); + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + assert_eq!( + scope, + Scope::Repo(RepoScope { + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), + actions, + }) + ); + + let scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + assert_eq!( + scope, + Scope::Repo(RepoScope { + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), + actions, + }) + ); + } + + #[test] + fn test_rpc_scope_parsing() { + let scope = Scope::parse("rpc:*").unwrap(); + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + lxm.insert(RpcLexicon::All); + aud.insert(RpcAudience::All); + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); + + let scope = Scope::parse("rpc:com.example.service").unwrap(); + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + lxm.insert(RpcLexicon::Nsid( + Nsid::new_static("com.example.service").unwrap(), + )); + aud.insert(RpcAudience::All); + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); + + let scope = + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(); + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + lxm.insert(RpcLexicon::Nsid( + Nsid::new_static("com.example.service").unwrap(), + )); + aud.insert(RpcAudience::Did( + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), + )); + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); + + let scope = + Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g") + .unwrap(); + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + lxm.insert(RpcLexicon::Nsid( + Nsid::new_static("com.example.method1").unwrap(), + )); + lxm.insert(RpcLexicon::Nsid( + Nsid::new_static("com.example.method2").unwrap(), + )); + aud.insert(RpcAudience::Did( + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), + )); + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); + } + + #[test] + fn test_scope_normalization() { + let tests = vec![ + ("account:email", "account:email"), + ("account:email?action=read", "account:email"), + ("account:email?action=manage", "account:email?action=manage"), + ("blob:image/png", "blob:image/png"), + ( + "blob?accept=image/jpeg&accept=image/png", + "blob?accept=image/jpeg&accept=image/png", + ), + ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"), + ( + "repo:app.bsky.feed.post?action=create", + "repo:app.bsky.feed.post?action=create", + ), + ("rpc:*", "rpc:*"), + ]; + + for (input, expected) in tests { + let scope = Scope::parse(input).unwrap(); + assert_eq!(scope.to_string_normalized(), expected); + } + } + + #[test] + fn test_account_scope_grants() { + let manage = Scope::parse("account:email?action=manage").unwrap(); + let read = Scope::parse("account:email?action=read").unwrap(); + let other_read = Scope::parse("account:repo?action=read").unwrap(); + + assert!(manage.grants(&read)); + assert!(manage.grants(&manage)); + assert!(!read.grants(&manage)); + assert!(read.grants(&read)); + assert!(!read.grants(&other_read)); + } + + #[test] + fn test_identity_scope_grants() { + let all = Scope::parse("identity:*").unwrap(); + let handle = Scope::parse("identity:handle").unwrap(); + + assert!(all.grants(&handle)); + assert!(all.grants(&all)); + assert!(!handle.grants(&all)); + assert!(handle.grants(&handle)); + } + + #[test] + fn test_blob_scope_grants() { + let all = Scope::parse("blob:*/*").unwrap(); + let image_all = Scope::parse("blob:image/*").unwrap(); + let image_png = Scope::parse("blob:image/png").unwrap(); + let text_plain = Scope::parse("blob:text/plain").unwrap(); + + assert!(all.grants(&image_all)); + assert!(all.grants(&image_png)); + assert!(all.grants(&text_plain)); + assert!(image_all.grants(&image_png)); + assert!(!image_all.grants(&text_plain)); + assert!(!image_png.grants(&image_all)); + } + + #[test] + fn test_repo_scope_grants() { + let all_all = Scope::parse("repo:*").unwrap(); + let all_create = Scope::parse("repo:*?action=create").unwrap(); + let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap(); + let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap(); + let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap(); + + assert!(all_all.grants(&all_create)); + assert!(all_all.grants(&specific_all)); + assert!(all_all.grants(&specific_create)); + assert!(all_create.grants(&all_create)); + assert!(!all_create.grants(&specific_all)); + assert!(specific_all.grants(&specific_create)); + assert!(!specific_create.grants(&specific_all)); + assert!(!specific_create.grants(&other_create)); + } + + #[test] + fn test_rpc_scope_grants() { + let all = Scope::parse("rpc:*").unwrap(); + let specific_lxm = Scope::parse("rpc:com.example.service").unwrap(); + let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); + + assert!(all.grants(&specific_lxm)); + assert!(all.grants(&specific_both)); + assert!(specific_lxm.grants(&specific_both)); + assert!(!specific_both.grants(&specific_lxm)); + assert!(!specific_both.grants(&all)); + } + + #[test] + fn test_cross_scope_grants() { + let account = Scope::parse("account:email").unwrap(); + let identity = Scope::parse("identity:handle").unwrap(); + + assert!(!account.grants(&identity)); + assert!(!identity.grants(&account)); + } + + #[test] + fn test_parse_errors() { + assert!(matches!( + Scope::parse("unknown:test"), + Err(ParseError::UnknownPrefix(_)) + )); + + assert!(matches!( + Scope::parse("account"), + Err(ParseError::MissingResource) + )); + + assert!(matches!( + Scope::parse("account:invalid"), + Err(ParseError::InvalidResource(_)) + )); + + assert!(matches!( + Scope::parse("account:email?action=invalid"), + Err(ParseError::InvalidAction(_)) + )); + } + + #[test] + fn test_query_parameter_sorting() { + let scope = + Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap(); + let normalized = scope.to_string_normalized(); + assert!(normalized.contains("accept=application/pdf")); + assert!(normalized.contains("accept=image/jpeg")); + assert!(normalized.contains("accept=image/png")); + let pdf_pos = normalized.find("accept=application/pdf").unwrap(); + let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); + let png_pos = normalized.find("accept=image/png").unwrap(); + assert!(pdf_pos < jpeg_pos); + assert!(jpeg_pos < png_pos); + } + + #[test] + fn test_repo_action_wildcard() { + let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap(); + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + assert_eq!( + scope, + Scope::Repo(RepoScope { + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), + actions, + }) + ); + } + + #[test] + fn test_multiple_blob_accepts() { + let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap(); + assert!(scope.grants(&Scope::parse("blob:image/png").unwrap())); + assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap())); + assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap())); + } + + #[test] + fn test_rpc_default_wildcards() { + let scope = Scope::parse("rpc").unwrap(); + let mut lxm = BTreeSet::new(); + let mut aud = BTreeSet::new(); + lxm.insert(RpcLexicon::All); + aud.insert(RpcAudience::All); + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); + } + + #[test] + fn test_atproto_scope_parsing() { + let scope = Scope::parse("atproto").unwrap(); + assert_eq!(scope, Scope::Atproto); + + // Atproto should not accept suffixes + assert!(Scope::parse("atproto:something").is_err()); + assert!(Scope::parse("atproto?param=value").is_err()); + } + + #[test] + fn test_transition_scope_parsing() { + let scope = Scope::parse("transition:generic").unwrap(); + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); + + let scope = Scope::parse("transition:email").unwrap(); + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); + + // Test invalid transition types + assert!(matches!( + Scope::parse("transition:invalid"), + Err(ParseError::InvalidResource(_)) + )); + + // Test missing suffix + assert!(matches!( + Scope::parse("transition"), + Err(ParseError::MissingResource) + )); + + // Test transition doesn't accept query parameters + assert!(matches!( + Scope::parse("transition:generic?param=value"), + Err(ParseError::InvalidResource(_)) + )); + } + + #[test] + fn test_atproto_scope_normalization() { + let scope = Scope::parse("atproto").unwrap(); + assert_eq!(scope.to_string_normalized(), "atproto"); + } + + #[test] + fn test_transition_scope_normalization() { + let tests = vec![ + ("transition:generic", "transition:generic"), + ("transition:email", "transition:email"), + ]; + + for (input, expected) in tests { + let scope = Scope::parse(input).unwrap(); + assert_eq!(scope.to_string_normalized(), expected); + } + } + + #[test] + fn test_atproto_scope_grants() { + let atproto = Scope::parse("atproto").unwrap(); + let account = Scope::parse("account:email").unwrap(); + let identity = Scope::parse("identity:handle").unwrap(); + let blob = Scope::parse("blob:image/png").unwrap(); + let repo = Scope::parse("repo:app.bsky.feed.post").unwrap(); + let rpc = Scope::parse("rpc:com.example.service").unwrap(); + let transition_generic = Scope::parse("transition:generic").unwrap(); + let transition_email = Scope::parse("transition:email").unwrap(); + + // Atproto only grants itself (it's a required scope, not a permission grant) + assert!(atproto.grants(&atproto)); + assert!(!atproto.grants(&account)); + assert!(!atproto.grants(&identity)); + assert!(!atproto.grants(&blob)); + assert!(!atproto.grants(&repo)); + assert!(!atproto.grants(&rpc)); + assert!(!atproto.grants(&transition_generic)); + assert!(!atproto.grants(&transition_email)); + + // Nothing else grants atproto + assert!(!account.grants(&atproto)); + assert!(!identity.grants(&atproto)); + assert!(!blob.grants(&atproto)); + assert!(!repo.grants(&atproto)); + assert!(!rpc.grants(&atproto)); + assert!(!transition_generic.grants(&atproto)); + assert!(!transition_email.grants(&atproto)); + } + + #[test] + fn test_transition_scope_grants() { + let transition_generic = Scope::parse("transition:generic").unwrap(); + let transition_email = Scope::parse("transition:email").unwrap(); + let account = Scope::parse("account:email").unwrap(); + + // Transition scopes only grant themselves + assert!(transition_generic.grants(&transition_generic)); + assert!(transition_email.grants(&transition_email)); + assert!(!transition_generic.grants(&transition_email)); + assert!(!transition_email.grants(&transition_generic)); + + // Transition scopes don't grant other scope types + assert!(!transition_generic.grants(&account)); + assert!(!transition_email.grants(&account)); + + // Other scopes don't grant transition scopes + assert!(!account.grants(&transition_generic)); + assert!(!account.grants(&transition_email)); + } + + #[test] + fn test_parse_multiple() { + // Test parsing multiple scopes + let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); + assert_eq!(scopes.len(), 2); + assert_eq!(scopes[0], Scope::Atproto); + assert_eq!( + scopes[1], + Scope::Repo(RepoScope { + collection: RepoCollection::All, + actions: { + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + actions + } + }) + ); + + // Test with more scopes + let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap(); + assert_eq!(scopes.len(), 3); + assert!(matches!(scopes[0], Scope::Account(_))); + assert!(matches!(scopes[1], Scope::Identity(_))); + assert!(matches!(scopes[2], Scope::Blob(_))); + + // Test with complex scopes + let scopes = Scope::parse_multiple( + "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email", + ) + .unwrap(); + assert_eq!(scopes.len(), 3); + + // Test empty string + let scopes = Scope::parse_multiple("").unwrap(); + assert_eq!(scopes.len(), 0); + + // Test whitespace only + let scopes = Scope::parse_multiple(" ").unwrap(); + assert_eq!(scopes.len(), 0); + + // Test with extra whitespace + let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap(); + assert_eq!(scopes.len(), 2); + + // Test single scope + let scopes = Scope::parse_multiple("atproto").unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!(scopes[0], Scope::Atproto); + + // Test error propagation + assert!(Scope::parse_multiple("atproto invalid:scope").is_err()); + assert!(Scope::parse_multiple("account:invalid repo:*").is_err()); + } + + #[test] + fn test_parse_multiple_reduced() { + // Test repo scope reduction - wildcard grants specific + let scopes = + Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); + assert_eq!(scopes.len(), 2); + assert!(scopes.contains(&Scope::Atproto)); + assert!(scopes.contains(&Scope::Repo(RepoScope { + collection: RepoCollection::All, + actions: { + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + actions + } + }))); + + // Test reverse order - should get same result + let scopes = + Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap(); + assert_eq!(scopes.len(), 2); + assert!(scopes.contains(&Scope::Atproto)); + assert!(scopes.contains(&Scope::Repo(RepoScope { + collection: RepoCollection::All, + actions: { + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + actions + } + }))); + + // Test account scope reduction - manage grants read + let scopes = + Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!( + scopes[0], + Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Manage, + }) + ); + + // Test identity scope reduction - wildcard grants specific + let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); + + // Test blob scope reduction - wildcard grants specific + let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap(); + assert_eq!(scopes.len(), 1); + let mut accept = BTreeSet::new(); + accept.insert(MimePattern::All); + assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); + + // Test no reduction needed - different scope types + let scopes = + Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap(); + assert_eq!(scopes.len(), 3); + + // Test repo action reduction + let scopes = Scope::parse_multiple_reduced( + "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post", + ) + .unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!( + scopes[0], + Scope::Repo(RepoScope { + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), + actions: { + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + actions + } + }) + ); + + // Test RPC scope reduction + let scopes = Scope::parse_multiple_reduced( + "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", + ) + .unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!( + scopes[0], + Scope::Rpc(RpcScope { + lxm: { + let mut lxm = BTreeSet::new(); + lxm.insert(RpcLexicon::All); + lxm + }, + aud: { + let mut aud = BTreeSet::new(); + aud.insert(RpcAudience::All); + aud + } + }) + ); + + // Test duplicate removal + let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap(); + assert_eq!(scopes.len(), 1); + assert_eq!(scopes[0], Scope::Atproto); + + // Test transition scopes - only grant themselves + let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap(); + assert_eq!(scopes.len(), 2); + assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); + assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); + + // Test empty input + let scopes = Scope::parse_multiple_reduced("").unwrap(); + assert_eq!(scopes.len(), 0); + + // Test complex scenario with multiple reductions + let scopes = Scope::parse_multiple_reduced( + "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" + ).unwrap(); + assert_eq!(scopes.len(), 3); + // Should have: account:email?action=manage, account:repo, identity:* + assert!(scopes.contains(&Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Manage, + }))); + assert!(scopes.contains(&Scope::Account(AccountScope { + resource: AccountResource::Repo, + action: AccountAction::Read, + }))); + assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); + + // Test that atproto doesn't grant other scopes (per recent change) + let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap(); + assert_eq!(scopes.len(), 3); + assert!(scopes.contains(&Scope::Atproto)); + assert!(scopes.contains(&Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Read, + }))); + assert!(scopes.contains(&Scope::Repo(RepoScope { + collection: RepoCollection::All, + actions: { + let mut actions = BTreeSet::new(); + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + actions + } + }))); + } + + #[test] + fn test_openid_connect_scope_parsing() { + // Test OpenID scope + let scope = Scope::parse("openid").unwrap(); + assert_eq!(scope, Scope::OpenId); + + // Test Profile scope + let scope = Scope::parse("profile").unwrap(); + assert_eq!(scope, Scope::Profile); + + // Test Email scope + let scope = Scope::parse("email").unwrap(); + assert_eq!(scope, Scope::Email); + + // Test that they don't accept suffixes + assert!(Scope::parse("openid:something").is_err()); + assert!(Scope::parse("profile:something").is_err()); + assert!(Scope::parse("email:something").is_err()); + + // Test that they don't accept query parameters + assert!(Scope::parse("openid?param=value").is_err()); + assert!(Scope::parse("profile?param=value").is_err()); + assert!(Scope::parse("email?param=value").is_err()); + } + + #[test] + fn test_openid_connect_scope_normalization() { + let scope = Scope::parse("openid").unwrap(); + assert_eq!(scope.to_string_normalized(), "openid"); + + let scope = Scope::parse("profile").unwrap(); + assert_eq!(scope.to_string_normalized(), "profile"); + + let scope = Scope::parse("email").unwrap(); + assert_eq!(scope.to_string_normalized(), "email"); + } + + #[test] + fn test_openid_connect_scope_grants() { + let openid = Scope::parse("openid").unwrap(); + let profile = Scope::parse("profile").unwrap(); + let email = Scope::parse("email").unwrap(); + let account = Scope::parse("account:email").unwrap(); + + // OpenID Connect scopes only grant themselves + assert!(openid.grants(&openid)); + assert!(!openid.grants(&profile)); + assert!(!openid.grants(&email)); + assert!(!openid.grants(&account)); + + assert!(profile.grants(&profile)); + assert!(!profile.grants(&openid)); + assert!(!profile.grants(&email)); + assert!(!profile.grants(&account)); + + assert!(email.grants(&email)); + assert!(!email.grants(&openid)); + assert!(!email.grants(&profile)); + assert!(!email.grants(&account)); + + // Other scopes don't grant OpenID Connect scopes + assert!(!account.grants(&openid)); + assert!(!account.grants(&profile)); + assert!(!account.grants(&email)); + } + + #[test] + fn test_parse_multiple_with_openid_connect() { + let scopes = Scope::parse_multiple("openid profile email atproto").unwrap(); + assert_eq!(scopes.len(), 4); + assert_eq!(scopes[0], Scope::OpenId); + assert_eq!(scopes[1], Scope::Profile); + assert_eq!(scopes[2], Scope::Email); + assert_eq!(scopes[3], Scope::Atproto); + + // Test with mixed scopes + let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap(); + assert_eq!(scopes.len(), 4); + assert!(scopes.contains(&Scope::OpenId)); + assert!(scopes.contains(&Scope::Profile)); + } + + #[test] + fn test_parse_multiple_reduced_with_openid_connect() { + // OpenID Connect scopes don't grant each other, so no reduction + let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap(); + assert_eq!(scopes.len(), 3); + assert!(scopes.contains(&Scope::OpenId)); + assert!(scopes.contains(&Scope::Profile)); + assert!(scopes.contains(&Scope::Email)); + + // Mixed with other scopes + let scopes = Scope::parse_multiple_reduced( + "openid account:email account:email?action=manage profile", + ) + .unwrap(); + assert_eq!(scopes.len(), 3); + assert!(scopes.contains(&Scope::OpenId)); + assert!(scopes.contains(&Scope::Profile)); + assert!(scopes.contains(&Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Manage, + }))); + } + + #[test] + fn test_serialize_multiple() { + // Test empty list + let scopes: Vec = vec![]; + assert_eq!(Scope::serialize_multiple(&scopes), ""); + + // Test single scope + let scopes = vec![Scope::Atproto]; + assert_eq!(Scope::serialize_multiple(&scopes), "atproto"); + + // Test multiple scopes - should be sorted alphabetically + let scopes = vec![ + Scope::parse("repo:*").unwrap(), + Scope::Atproto, + Scope::parse("account:email").unwrap(), + ]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "account:email atproto repo:*" + ); + + // Test that sorting is consistent regardless of input order + let scopes = vec![ + Scope::parse("identity:handle").unwrap(), + Scope::parse("blob:image/png").unwrap(), + Scope::parse("account:repo?action=manage").unwrap(), + ]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "account:repo?action=manage blob:image/png identity:handle" + ); + + // Test with OpenID Connect scopes + let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "atproto email openid profile" + ); + + // Test with complex scopes including query parameters + let scopes = vec![ + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method") + .unwrap(), + Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(), + Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), + ]; + let result = Scope::serialize_multiple(&scopes); + // The result should be sorted alphabetically + // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." + assert!(result.starts_with("blob:")); + assert!(result.contains(" repo:")); + assert!( + result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service") + ); + + // Test with transition scopes + let scopes = vec![ + Scope::Transition(TransitionScope::Email), + Scope::Transition(TransitionScope::Generic), + Scope::Atproto, + ]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "atproto transition:email transition:generic" + ); + + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) + let scopes = vec![ + Scope::Atproto, + Scope::Atproto, + Scope::parse("account:email").unwrap(), + ]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "account:email atproto atproto" + ); + + // Test normalization is preserved in serialization + let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; + // Should normalize query parameters alphabetically + assert_eq!( + Scope::serialize_multiple(&scopes), + "blob?accept=image/jpeg&accept=image/png" + ); + } + + #[test] + fn test_serialize_multiple_roundtrip() { + // Test that parse_multiple and serialize_multiple are inverses (when sorted) + let original = "account:email atproto blob:image/png identity:handle repo:*"; + let scopes = Scope::parse_multiple(original).unwrap(); + let serialized = Scope::serialize_multiple(&scopes); + assert_eq!(serialized, original); + + // Test with complex scopes + let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; + let scopes = Scope::parse_multiple(original).unwrap(); + let serialized = Scope::serialize_multiple(&scopes); + // Parse again to verify it's valid + let reparsed = Scope::parse_multiple(&serialized).unwrap(); + assert_eq!(scopes, reparsed); + + // Test with OpenID Connect scopes + let original = "email openid profile"; + let scopes = Scope::parse_multiple(original).unwrap(); + let serialized = Scope::serialize_multiple(&scopes); + assert_eq!(serialized, original); + } + + #[test] + fn test_remove_scope() { + // Test removing a scope that exists + let scopes = vec![ + Scope::parse("repo:*").unwrap(), + Scope::Atproto, + Scope::parse("account:email").unwrap(), + ]; + let to_remove = Scope::Atproto; + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert!(!result.contains(&to_remove)); + assert!(result.contains(&Scope::parse("repo:*").unwrap())); + assert!(result.contains(&Scope::parse("account:email").unwrap())); + + // Test removing a scope that doesn't exist + let scopes = vec![ + Scope::parse("repo:*").unwrap(), + Scope::parse("account:email").unwrap(), + ]; + let to_remove = Scope::parse("identity:handle").unwrap(); + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert_eq!(result, scopes); + + // Test removing from empty list + let scopes: Vec = vec![]; + let to_remove = Scope::Atproto; + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 0); + + // Test removing all instances of a duplicate scope + let scopes = vec![ + Scope::Atproto, + Scope::parse("account:email").unwrap(), + Scope::Atproto, + Scope::parse("repo:*").unwrap(), + Scope::Atproto, + ]; + let to_remove = Scope::Atproto; + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert!(!result.contains(&to_remove)); + assert!(result.contains(&Scope::parse("account:email").unwrap())); + assert!(result.contains(&Scope::parse("repo:*").unwrap())); + + // Test removing complex scopes with query parameters + let scopes = vec![ + Scope::parse("account:email?action=manage").unwrap(), + Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), + ]; + let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert!(!result.contains(&to_remove)); + + // Test with OpenID Connect scopes + let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; + let to_remove = Scope::Profile; + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 3); + assert!(!result.contains(&to_remove)); + assert!(result.contains(&Scope::OpenId)); + assert!(result.contains(&Scope::Email)); + assert!(result.contains(&Scope::Atproto)); + + // Test with transition scopes + let scopes = vec![ + Scope::Transition(TransitionScope::Generic), + Scope::Transition(TransitionScope::Email), + Scope::Atproto, + ]; + let to_remove = Scope::Transition(TransitionScope::Email); + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert!(!result.contains(&to_remove)); + assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); + assert!(result.contains(&Scope::Atproto)); + + // Test that only exact matches are removed + let scopes = vec![ + Scope::parse("account:email").unwrap(), + Scope::parse("account:email?action=manage").unwrap(), + Scope::parse("account:repo").unwrap(), + ]; + let to_remove = Scope::parse("account:email").unwrap(); + let result = Scope::remove_scope(&scopes, &to_remove); + assert_eq!(result.len(), 2); + assert!(!result.contains(&Scope::parse("account:email").unwrap())); + assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); + assert!(result.contains(&Scope::parse("account:repo").unwrap())); + } +} diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs new file mode 100644 index 00000000..7508367a --- /dev/null +++ b/crates/jacquard-oauth/src/session.rs @@ -0,0 +1,23 @@ +use crate::types::TokenSet; + +use jacquard_common::IntoStatic; +use jose_jwk::Key; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OauthSession<'s> { + pub dpop_key: Key, + #[serde(borrow)] + pub token_set: TokenSet<'s>, +} + +impl IntoStatic for OauthSession<'_> { + type Output = OauthSession<'static>; + + fn into_static(self) -> Self::Output { + OauthSession { + dpop_key: self.dpop_key, + token_set: self.token_set.into_static(), + } + } +} diff --git a/crates/jacquard-oauth/src/types.rs b/crates/jacquard-oauth/src/types.rs new file mode 100644 index 00000000..14b6705e --- /dev/null +++ b/crates/jacquard-oauth/src/types.rs @@ -0,0 +1,61 @@ +mod client_metadata; +mod metadata; +mod request; +mod response; +mod token; + +use crate::scopes::Scope; + +pub use self::client_metadata::*; +pub use self::metadata::*; +pub use self::request::*; +pub use self::response::*; +pub use self::token::*; +use jacquard_common::CowStr; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub enum AuthorizeOptionPrompt { + Login, + None, + Consent, + SelectAccount, +} + +impl From for CowStr<'static> { + fn from(value: AuthorizeOptionPrompt) -> Self { + match value { + AuthorizeOptionPrompt::Login => CowStr::new_static("login"), + AuthorizeOptionPrompt::None => CowStr::new_static("none"), + AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"), + AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"), + } + } +} + +#[derive(Debug)] +pub struct AuthorizeOptions<'s> { + pub redirect_uri: Option>, + pub scopes: Vec>, + pub prompt: Option, + pub state: Option>, +} + +impl Default for AuthorizeOptions<'_> { + fn default() -> Self { + Self { + redirect_uri: None, + scopes: vec![Scope::Atproto], + prompt: None, + state: None, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CallbackParams<'s> { + #[serde(borrow)] + pub code: CowStr<'s>, + pub state: Option>, + pub iss: Option>, +} diff --git a/crates/jacquard-oauth/src/types/client_metadata.rs b/crates/jacquard-oauth/src/types/client_metadata.rs new file mode 100644 index 00000000..c0cad7e8 --- /dev/null +++ b/crates/jacquard-oauth/src/types/client_metadata.rs @@ -0,0 +1,55 @@ +use jacquard_common::{CowStr, IntoStatic}; +use jose_jwk::JwkSet; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthClientMetadata<'c> { + pub client_id: Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + pub redirect_uris: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(borrow)] + pub scope: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_types: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option>, + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 + #[serde(skip_serializing_if = "Option::is_none")] + pub dpop_bound_access_tokens: Option, + // https://datatracker.ietf.org/doc/html/rfc7591#section-2 + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks: Option, + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_signing_alg: Option>, +} + +impl OAuthClientMetadata<'_> {} + +impl IntoStatic for OAuthClientMetadata<'_> { + type Output = OAuthClientMetadata<'static>; + + fn into_static(self) -> Self::Output { + OAuthClientMetadata { + client_id: self.client_id, + client_uri: self.client_uri, + redirect_uris: self.redirect_uris, + scope: self.scope.map(|scope| scope.into_static()), + grant_types: self.grant_types.map(|types| types.into_static()), + token_endpoint_auth_method: self + .token_endpoint_auth_method + .map(|method| method.into_static()), + dpop_bound_access_tokens: self.dpop_bound_access_tokens, + jwks_uri: self.jwks_uri, + jwks: self.jwks, + token_endpoint_auth_signing_alg: self + .token_endpoint_auth_signing_alg + .map(|alg| alg.into_static()), + } + } +} diff --git a/crates/jacquard-oauth/src/types/metadata.rs b/crates/jacquard-oauth/src/types/metadata.rs new file mode 100644 index 00000000..d8306f6f --- /dev/null +++ b/crates/jacquard-oauth/src/types/metadata.rs @@ -0,0 +1,144 @@ +use jacquard_common::{CowStr, IntoStatic, types::string::Language}; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +pub struct OAuthAuthorizationServerMetadata<'s> { + // https://datatracker.ietf.org/doc/html/rfc8414#section-2 + #[serde(borrow)] + pub issuer: CowStr<'s>, + pub authorization_endpoint: CowStr<'s>, // optional? + pub token_endpoint: CowStr<'s>, // optional? + pub jwks_uri: Option>, + pub registration_endpoint: Option>, + pub scopes_supported: Vec>, + pub response_types_supported: Vec>, + pub response_modes_supported: Option>>, + pub grant_types_supported: Option>>, + pub token_endpoint_auth_methods_supported: Option>>, + pub token_endpoint_auth_signing_alg_values_supported: Option>>, + pub service_documentation: Option>, + pub ui_locales_supported: Option>, + pub op_policy_uri: Option>, + pub op_tos_uri: Option>, + pub revocation_endpoint: Option>, + pub revocation_endpoint_auth_methods_supported: Option>>, + pub revocation_endpoint_auth_signing_alg_values_supported: Option>>, + pub introspection_endpoint: Option>, + pub introspection_endpoint_auth_methods_supported: Option>>, + pub introspection_endpoint_auth_signing_alg_values_supported: Option>>, + pub code_challenge_methods_supported: Option>>, + + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + pub subject_types_supported: Option>>, + pub require_request_uri_registration: Option, + + // https://datatracker.ietf.org/doc/html/rfc9126#section-5 + pub pushed_authorization_request_endpoint: Option>, + pub require_pushed_authorization_requests: Option, + + // https://datatracker.ietf.org/doc/html/rfc9207#section-3 + pub authorization_response_iss_parameter_supported: Option, + + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 + pub dpop_signing_alg_values_supported: Option>>, + + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5 + pub client_id_metadata_document_supported: Option, + + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada + pub protected_resources: Option>>, +} + +// https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/ +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-2 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +pub struct OAuthProtectedResourceMetadata<'s> { + #[serde(borrow)] + pub resource: CowStr<'s>, + pub authorization_servers: Option>, + pub jwks_uri: Option>, + pub scopes_supported: Vec>, + pub bearer_methods_supported: Option>>, + pub resource_signing_alg_values_supported: Option>>, + pub resource_documentation: Option>, + pub resource_policy_uri: Option>, + pub resource_tos_uri: Option>, +} + +impl IntoStatic for OAuthProtectedResourceMetadata<'_> { + type Output = OAuthProtectedResourceMetadata<'static>; + fn into_static(self) -> Self::Output { + OAuthProtectedResourceMetadata { + resource: self.resource.into_static(), + authorization_servers: self.authorization_servers, + jwks_uri: self.jwks_uri.map(|v| v.into_static()), + scopes_supported: self.scopes_supported.into_static(), + bearer_methods_supported: self.bearer_methods_supported.map(|v| v.into_static()), + resource_signing_alg_values_supported: self + .resource_signing_alg_values_supported + .map(|v| v.into_static()), + resource_documentation: self.resource_documentation.map(|v| v.into_static()), + resource_policy_uri: self.resource_policy_uri.map(|v| v.into_static()), + resource_tos_uri: self.resource_tos_uri.map(|v| v.into_static()), + } + } +} + +impl IntoStatic for OAuthAuthorizationServerMetadata<'_> { + type Output = OAuthAuthorizationServerMetadata<'static>; + fn into_static(self) -> Self::Output { + OAuthAuthorizationServerMetadata { + issuer: self.issuer.into_static(), + authorization_endpoint: self.authorization_endpoint.into_static(), + token_endpoint: self.token_endpoint.into_static(), + jwks_uri: self.jwks_uri.into_static(), + registration_endpoint: self.registration_endpoint.into_static(), + scopes_supported: self.scopes_supported.into_static(), + response_types_supported: self.response_types_supported.into_static(), + response_modes_supported: self.response_modes_supported.into_static(), + grant_types_supported: self.grant_types_supported.into_static(), + token_endpoint_auth_methods_supported: self + .token_endpoint_auth_methods_supported + .into_static(), + token_endpoint_auth_signing_alg_values_supported: self + .token_endpoint_auth_signing_alg_values_supported + .into_static(), + service_documentation: self.service_documentation.into_static(), + ui_locales_supported: self.ui_locales_supported.into_static(), + op_policy_uri: self.op_policy_uri.into_static(), + op_tos_uri: self.op_tos_uri.into_static(), + revocation_endpoint: self.revocation_endpoint.into_static(), + revocation_endpoint_auth_methods_supported: self + .revocation_endpoint_auth_methods_supported + .into_static(), + revocation_endpoint_auth_signing_alg_values_supported: self + .revocation_endpoint_auth_signing_alg_values_supported + .into_static(), + introspection_endpoint: self.introspection_endpoint.into_static(), + introspection_endpoint_auth_methods_supported: self + .introspection_endpoint_auth_methods_supported + .into_static(), + introspection_endpoint_auth_signing_alg_values_supported: self + .introspection_endpoint_auth_signing_alg_values_supported + .into_static(), + code_challenge_methods_supported: self.code_challenge_methods_supported.into_static(), + subject_types_supported: self.subject_types_supported.into_static(), + require_request_uri_registration: self.require_request_uri_registration.into_static(), + pushed_authorization_request_endpoint: self + .pushed_authorization_request_endpoint + .into_static(), + require_pushed_authorization_requests: self + .require_pushed_authorization_requests + .into_static(), + authorization_response_iss_parameter_supported: self + .authorization_response_iss_parameter_supported + .into_static(), + dpop_signing_alg_values_supported: self.dpop_signing_alg_values_supported.into_static(), + client_id_metadata_document_supported: self + .client_id_metadata_document_supported + .into_static(), + protected_resources: self.protected_resources.into_static(), + } + } +} diff --git a/crates/jacquard-oauth/src/types/request.rs b/crates/jacquard-oauth/src/types/request.rs new file mode 100644 index 00000000..eefbc649 --- /dev/null +++ b/crates/jacquard-oauth/src/types/request.rs @@ -0,0 +1,134 @@ +use jacquard_common::{CowStr, IntoStatic}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorizationResponseType { + Code, + Token, + // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) + IdToken, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorizationResponseMode { + Query, + Fragment, + // https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode + FormPost, +} + +#[derive(Serialize, Deserialize)] +pub enum AuthorizationCodeChallengeMethod { + S256, + #[serde(rename = "plain")] + Plain, +} + +#[derive(Serialize, Deserialize)] +pub struct PushedAuthorizationRequestParameters<'a> { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + pub response_type: AuthorizationResponseType, + #[serde(borrow)] + pub redirect_uri: CowStr<'a>, + pub state: CowStr<'a>, + pub scope: Option>, + // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes + pub response_mode: Option, + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 + pub code_challenge: CowStr<'a>, + pub code_challenge_method: AuthorizationCodeChallengeMethod, + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + pub login_hint: Option>, + pub prompt: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenGrantType { + AuthorizationCode, + RefreshToken, +} + +#[derive(Serialize, Deserialize)] +pub struct TokenRequestParameters<'a> { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + pub grant_type: TokenGrantType, + #[serde(borrow)] + pub code: CowStr<'a>, + pub redirect_uri: CowStr<'a>, + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 + pub code_verifier: CowStr<'a>, +} + +#[derive(Serialize, Deserialize)] +pub struct RefreshRequestParameters<'a> { + // https://datatracker.ietf.org/doc/html/rfc6749#section-6 + pub grant_type: TokenGrantType, + #[serde(borrow)] + pub refresh_token: CowStr<'a>, + pub scope: Option>, +} + +// https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 +#[derive(Serialize, Deserialize)] +pub struct RevocationRequestParameters<'a> { + #[serde(borrow)] + pub token: CowStr<'a>, + // ? + // pub token_type_hint: Option, +} + +impl IntoStatic for RevocationRequestParameters<'_> { + type Output = RevocationRequestParameters<'static>; + + fn into_static(self) -> Self::Output { + Self::Output { + token: self.token.into_static(), + } + } +} + +impl IntoStatic for TokenRequestParameters<'_> { + type Output = TokenRequestParameters<'static>; + + fn into_static(self) -> Self::Output { + Self::Output { + grant_type: self.grant_type, + code: self.code.into_static(), + redirect_uri: self.redirect_uri.into_static(), + code_verifier: self.code_verifier.into_static(), + } + } +} + +impl IntoStatic for RefreshRequestParameters<'_> { + type Output = RefreshRequestParameters<'static>; + + fn into_static(self) -> Self::Output { + Self::Output { + grant_type: self.grant_type, + refresh_token: self.refresh_token.into_static(), + scope: self.scope.map(CowStr::into_static), + } + } +} + +impl IntoStatic for PushedAuthorizationRequestParameters<'_> { + type Output = PushedAuthorizationRequestParameters<'static>; + + fn into_static(self) -> Self::Output { + Self::Output { + redirect_uri: self.redirect_uri.into_static(), + response_type: self.response_type, + scope: self.scope.into_static(), + code_challenge: self.code_challenge.into_static(), + code_challenge_method: self.code_challenge_method, + state: self.state.into_static(), + response_mode: self.response_mode, + login_hint: self.login_hint.into_static(), + prompt: self.prompt.into_static(), + } + } +} diff --git a/crates/jacquard-oauth/src/types/response.rs b/crates/jacquard-oauth/src/types/response.rs new file mode 100644 index 00000000..27a8b865 --- /dev/null +++ b/crates/jacquard-oauth/src/types/response.rs @@ -0,0 +1,56 @@ +use jacquard_common::{CowStr, IntoStatic}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthParResponse<'r> { + #[serde(borrow)] + pub request_uri: CowStr<'r>, + pub expires_in: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum OAuthTokenType { + DPoP, + Bearer, +} + +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthTokenResponse<'r> { + #[serde(borrow)] + pub access_token: CowStr<'r>, + pub token_type: OAuthTokenType, + pub expires_in: Option, + pub refresh_token: Option>, + pub scope: Option>, + // ATPROTO extension: add the sub claim to the token response to allow + // clients to resolve the PDS url (audience) using the did resolution + // mechanism. + pub sub: Option>, +} + +impl IntoStatic for OAuthTokenResponse<'_> { + type Output = OAuthTokenResponse<'static>; + + fn into_static(self) -> Self::Output { + OAuthTokenResponse { + access_token: self.access_token.into_static(), + token_type: self.token_type, + expires_in: self.expires_in, + refresh_token: self.refresh_token.map(|s| s.into_static()), + scope: self.scope.map(|s| s.into_static()), + sub: self.sub.map(|s| s.into_static()), + } + } +} + +impl IntoStatic for OAuthParResponse<'_> { + type Output = OAuthParResponse<'static>; + + fn into_static(self) -> Self::Output { + OAuthParResponse { + request_uri: self.request_uri.into_static(), + expires_in: self.expires_in, + } + } +} diff --git a/crates/jacquard-oauth/src/types/token.rs b/crates/jacquard-oauth/src/types/token.rs new file mode 100644 index 00000000..8a7146b2 --- /dev/null +++ b/crates/jacquard-oauth/src/types/token.rs @@ -0,0 +1,36 @@ +use super::response::OAuthTokenType; +use jacquard_common::types::string::{Datetime, Did}; +use jacquard_common::{CowStr, IntoStatic}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct TokenSet<'s> { + #[serde(borrow)] + pub iss: CowStr<'s>, + pub sub: Did<'s>, + pub aud: CowStr<'s>, + pub scope: Option>, + + pub refresh_token: Option>, + pub access_token: CowStr<'s>, + pub token_type: OAuthTokenType, + + pub expires_at: Option, +} + +impl IntoStatic for TokenSet<'_> { + type Output = TokenSet<'static>; + + fn into_static(self) -> Self::Output { + TokenSet { + iss: self.iss.into_static(), + sub: self.sub.into_static(), + aud: self.aud.into_static(), + scope: self.scope.map(|s| s.into_static()), + refresh_token: self.refresh_token.map(|s| s.into_static()), + access_token: self.access_token.into_static(), + token_type: self.token_type, + expires_at: self.expires_at.map(|s| s.into_static()), + } + } +} diff --git a/crates/jacquard/Cargo.toml b/crates/jacquard/Cargo.toml index 62929eba..be9358e6 100644 --- a/crates/jacquard/Cargo.toml +++ b/crates/jacquard/Cargo.toml @@ -12,12 +12,13 @@ exclude.workspace = true license.workspace = true [features] -default = ["api_all", "dns", "fancy"] +default = ["api_all", "dns", "fancy", "loopback"] derive = ["dep:jacquard-derive"] api = ["jacquard-api/com_atproto"] api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"] -dns = ["dep:hickory-resolver"] +dns = ["dep:hickory-resolver", "jacquard-common/dns"] fancy = ["miette/fancy"] +loopback = ["dep:rouille"] [lib] name = "jacquard" @@ -35,7 +36,8 @@ bytes.workspace = true clap.workspace = true http.workspace = true jacquard-api = { version = "0.2.0", path = "../jacquard-api" } -jacquard-common = { version = "0.2.0", path = "../jacquard-common" } +jacquard-common = { version = "0.2.0", path = "../jacquard-common", features = ["reqwest-client"] } +jacquard-oauth = { version = "0.1.0", path = "../jacquard-oauth" } jacquard-derive = { version = "0.2.0", path = "../jacquard-derive", optional = true } miette = { workspace = true } reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } @@ -50,3 +52,7 @@ url.workspace = true smol_str.workspace = true percent-encoding = "2" urlencoding = "2" +jose-jwk = { version = "0.1", features = ["p256"] } +p256 = { version = "0.13", features = ["ecdsa"] } +rand_core = "0.6" +rouille = { version = "3.6.2", optional = true } diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index aef99d5e..ce1d6af7 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -4,94 +4,31 @@ //! client implementation that manages session tokens. mod at_client; -mod error; -mod response; -mod token; -mod xrpc_call; -use std::fmt::Display; -use std::future::Future; +mod token; pub use at_client::{AtClient, SendOverrides}; -pub use error::{ClientError, Result}; -use http::{ - HeaderName, HeaderValue, Request, - header::{AUTHORIZATION, CONTENT_TYPE}, -}; -pub use response::Response; -pub use token::{FileTokenStore, MemoryTokenStore, TokenStore, TokenStoreError}; -pub use xrpc_call::{CallOptions, XrpcCall, XrpcExt}; +pub use jacquard_common::error::{ClientError, XrpcResult}; +pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; use jacquard_common::{ CowStr, IntoStatic, types::{ string::{Did, Handle}, - xrpc::{XrpcMethod, XrpcRequest}, + xrpc::{Response, XrpcRequest}, }, }; +pub use token::FileTokenStore; use url::Url; -/// Implement HttpClient for reqwest::Client -impl HttpClient for reqwest::Client { - type Error = reqwest::Error; - - async fn send_http( - &self, - request: Request>, - ) -> core::result::Result>, Self::Error> { - // Convert http::Request to reqwest::Request - let (parts, body) = request.into_parts(); - - let mut req = self.request(parts.method, parts.uri.to_string()).body(body); - - // Copy headers - for (name, value) in parts.headers.iter() { - req = req.header(name.as_str(), value.as_bytes()); - } - - // Send request - let resp = req.send().await?; - - // Convert reqwest::Response to http::Response - let mut builder = http::Response::builder().status(resp.status()); +use p256::SecretKey; - // Copy headers - for (name, value) in resp.headers().iter() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - // Read body - let body = resp.bytes().await?.to_vec(); - - Ok(builder.body(body).expect("Failed to build response")) - } -} - -/// HTTP client trait for sending raw HTTP requests. -pub trait HttpClient { - /// Error type returned by the HTTP client - type Error: std::error::Error + Display + Send + Sync + 'static; - /// Send an HTTP request and return the response. - fn send_http( - &self, - request: Request>, - ) -> impl Future>, Self::Error>> + Send; -} // Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; -/// Authorization token types for XRPC requests. -#[derive(Debug, Clone)] -pub enum AuthorizationToken<'s> { - /// Bearer token (access JWT, refresh JWT to refresh the session) - Bearer(CowStr<'s>), - /// DPoP token (proof-of-possession) for OAuth - Dpop(CowStr<'s>), -} - -/// Basic client wrapper: reqwest transport + in-memory token store. -pub struct BasicClient(AtClient); +/// Basic client wrapper: reqwest transport + in-memory session store. +pub struct BasicClient(AtClient, AuthSession>>); impl BasicClient { /// Construct a basic client with minimal inputs. @@ -99,17 +36,19 @@ impl BasicClient { Self(AtClient::new( reqwest::Client::new(), base, - MemoryTokenStore::default(), + MemorySessionStore::default(), )) } /// Access the inner stateful client. - pub fn inner(&self) -> &AtClient { + pub fn inner( + &self, + ) -> &AtClient, AuthSession>> { &self.0 } /// Send an XRPC request. - pub async fn send(&self, req: R) -> Result> { + pub async fn send(&self, req: R) -> XrpcResult> { self.0.send(req).await } @@ -118,22 +57,25 @@ impl BasicClient { &self, req: R, overrides: SendOverrides<'_>, - ) -> Result> { + ) -> XrpcResult> { self.0.send_with(req, overrides).await } /// Get current session. - pub async fn session(&self) -> Option { - self.0.session().await + pub async fn session(&self, did: &Did<'static>) -> Option { + self.0.session(did).await } /// Set the session. - pub async fn set_session(&self, session: Session) -> core::result::Result<(), TokenStoreError> { + pub async fn set_session( + &self, + session: AuthSession, + ) -> core::result::Result<(), SessionStoreError> { self.0.set_session(session).await } /// Clear session. - pub async fn clear_session(&self) -> core::result::Result<(), TokenStoreError> { + pub async fn clear_session(&self) -> core::result::Result<(), SessionStoreError> { self.0.clear_session().await } @@ -143,112 +85,11 @@ impl BasicClient { } } -/// HTTP headers commonly used in XRPC requests -pub enum Header { - /// Content-Type header - ContentType, - /// Authorization header - Authorization, - /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate. - /// - /// See: - AtprotoProxy, - /// `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. - AtprotoAcceptLabelers, -} - -impl From
for HeaderName { - fn from(value: Header) -> Self { - match value { - Header::ContentType => CONTENT_TYPE, - Header::Authorization => AUTHORIZATION, - Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"), - Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"), - } - } -} - -/// Build an HTTP request for an XRPC call given base URL and options -pub(crate) fn build_http_request( - base: &Url, - req: &R, - opts: &xrpc_call::CallOptions<'_>, -) -> core::result::Result>, error::TransportError> { - let mut url = base.clone(); - let mut path = url.path().trim_end_matches('/').to_owned(); - path.push_str("/xrpc/"); - path.push_str(R::NSID); - url.set_path(&path); - - if let XrpcMethod::Query = R::METHOD { - let qs = serde_html_form::to_string(&req) - .map_err(|e| error::TransportError::InvalidRequest(e.to_string()))?; - if !qs.is_empty() { - url.set_query(Some(&qs)); - } else { - url.set_query(None); - } - } - - let method = match R::METHOD { - XrpcMethod::Query => http::Method::GET, - XrpcMethod::Procedure(_) => http::Method::POST, - }; - - let mut builder = Request::builder().method(method).uri(url.as_str()); - - if let XrpcMethod::Procedure(encoding) = R::METHOD { - builder = builder.header(Header::ContentType, encoding); - } - builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING); - - if let Some(token) = &opts.auth { - let hv = match token { - AuthorizationToken::Bearer(t) => { - HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) - } - AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())), - } - .map_err(|e| { - error::TransportError::InvalidRequest(format!("Invalid authorization token: {}", e)) - })?; - builder = builder.header(Header::Authorization, hv); - } - - if let Some(proxy) = &opts.atproto_proxy { - builder = builder.header(Header::AtprotoProxy, proxy.as_ref()); - } - if let Some(labelers) = &opts.atproto_accept_labelers { - if !labelers.is_empty() { - let joined = labelers - .iter() - .map(|s| s.as_ref()) - .collect::>() - .join(", "); - builder = builder.header(Header::AtprotoAcceptLabelers, joined); - } - } - for (name, value) in &opts.extra_headers { - builder = builder.header(name, value); - } - - let body = if let XrpcMethod::Procedure(_) = R::METHOD { - req.encode_body() - .map_err(|e| error::TransportError::InvalidRequest(e.to_string()))? - } else { - vec![] - }; - - builder - .body(body) - .map_err(|e| error::TransportError::InvalidRequest(e.to_string())) -} - -/// Session information from `com.atproto.server.createSession` +/// App password session information from `com.atproto.server.createSession` /// /// Contains the access and refresh tokens along with user identity information. #[derive(Debug, Clone)] -pub struct Session { +pub struct AtpSession { /// Access token (JWT) used for authenticated requests pub access_jwt: CowStr<'static>, /// Refresh token (JWT) used to obtain new access tokens @@ -259,7 +100,9 @@ pub struct Session { pub handle: Handle<'static>, } -impl From> for Session { +impl From> + for AtpSession +{ fn from( output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>, ) -> Self { @@ -273,7 +116,7 @@ impl From> - for Session + for AtpSession { fn from( output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>, @@ -286,3 +129,66 @@ impl From), +} + +impl AuthSession { + pub fn did(&self) -> &Did<'static> { + match self { + AuthSession::AppPassword(session) => &session.did, + AuthSession::OAuth(session) => &session.token_set.sub, + } + } + + pub fn refresh_token(&self) -> Option<&CowStr<'static>> { + match self { + AuthSession::AppPassword(session) => Some(&session.refresh_jwt), + AuthSession::OAuth(session) => session.token_set.refresh_token.as_ref(), + } + } + + pub fn access_token(&self) -> &CowStr<'static> { + match self { + AuthSession::AppPassword(session) => &session.access_jwt, + AuthSession::OAuth(session) => &session.token_set.access_token, + } + } + + pub fn set_refresh_token(&mut self, token: CowStr<'_>) { + match self { + AuthSession::AppPassword(session) => { + session.refresh_jwt = token.into_static(); + } + AuthSession::OAuth(session) => { + session.token_set.refresh_token = Some(token.into_static()); + } + } + } + + pub fn set_access_token(&mut self, token: CowStr<'_>) { + match self { + AuthSession::AppPassword(session) => { + session.access_jwt = token.into_static(); + } + AuthSession::OAuth(session) => { + session.token_set.access_token = token.into_static(); + } + } + } +} + +impl From for AuthSession { + fn from(session: AtpSession) -> Self { + AuthSession::AppPassword(session) + } +} + +impl From> for AuthSession { + fn from(session: jacquard_oauth::session::OauthSession<'static>) -> Self { + AuthSession::OAuth(session) + } +} diff --git a/crates/jacquard/src/client/at_client.rs b/crates/jacquard/src/client/at_client.rs index f56f7995..21b44a49 100644 --- a/crates/jacquard/src/client/at_client.rs +++ b/crates/jacquard/src/client/at_client.rs @@ -1,15 +1,24 @@ use bytes::Bytes; +use jacquard_common::{ + AuthorizationToken, IntoStatic, + error::{AuthError, ClientError, HttpError, TransportError, XrpcResult}, + http_client::HttpClient, + session::{SessionStore, SessionStoreError}, + types::{ + did::Did, + xrpc::{CallOptions, Response, XrpcExt}, + }, +}; use url::Url; -use crate::client::xrpc_call::{CallOptions, XrpcExt}; -use crate::client::{self as super_mod, AuthorizationToken, HttpClient, Response, Session, error}; -use jacquard_common::types::xrpc::XrpcRequest; +use jacquard_common::types::xrpc::{XrpcRequest, build_http_request}; -use super::token::TokenStore; +use crate::client::{AtpSession, AuthSession, FileTokenStore, NSID_REFRESH_SESSION}; /// Per-call overrides when sending via `AtClient`. #[derive(Debug, Default, Clone)] pub struct SendOverrides<'a> { + pub did: Option>, /// Optional base URI override for this call. pub base_uri: Option, /// Per-request options such as auth, proxy, labelers, extra headers. @@ -22,6 +31,7 @@ impl<'a> SendOverrides<'a> { /// Construct default overrides (no base override, auto-refresh enabled). pub fn new() -> Self { Self { + did: None, base_uri: None, options: CallOptions::default(), auto_refresh: true, @@ -37,6 +47,12 @@ impl<'a> SendOverrides<'a> { self.options = opts; self } + + /// Provide a full set of call options (auth/headers/etc.). + pub fn did(mut self, did: Did<'a>) -> Self { + self.did = Some(did); + self + } /// Enable or disable one-shot auto-refresh + retry behavior. pub fn auto_refresh(mut self, enable: bool) -> Self { self.auto_refresh = enable; @@ -71,21 +87,21 @@ impl<'a> SendOverrides<'a> { /// Ok(()) /// } /// ``` -pub struct AtClient { +pub struct AtClient { transport: C, base: Url, tokens: S, - refresh_lock: tokio::sync::Mutex<()>, + refresh_lock: tokio::sync::Mutex>>, } -impl AtClient { +impl, AuthSession>> AtClient { /// Create a new client with a transport, base URL, and token store. pub fn new(transport: C, base: Url, tokens: S) -> Self { Self { transport, base, tokens, - refresh_lock: tokio::sync::Mutex::new(()), + refresh_lock: tokio::sync::Mutex::new(None), } } @@ -100,22 +116,24 @@ impl AtClient { } /// Get the current session, if any. - pub async fn session(&self) -> Option { - self.tokens.get().await + pub async fn session(&self, did: &Did<'static>) -> Option { + self.tokens.get(did).await } /// Set the current session in the token store. - pub async fn set_session(&self, session: Session) -> Result<(), super_mod::TokenStoreError> { - self.tokens.set(session).await + pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> { + let s = session.clone(); + let did = s.did().clone().into_static(); + self.tokens.set(did, session).await } /// Clear the current session from the token store. - pub async fn clear_session(&self) -> Result<(), super_mod::TokenStoreError> { + pub async fn clear_session(&self) -> Result<(), SessionStoreError> { self.tokens.clear().await } /// Send an XRPC request using the client's base URL and default behavior. - pub async fn send(&self, req: R) -> super_mod::Result> { + pub async fn send(&self, req: R) -> XrpcResult> { self.send_with(req, SendOverrides::new()).await } @@ -124,35 +142,45 @@ impl AtClient { &self, req: R, mut overrides: SendOverrides<'_>, - ) -> super_mod::Result> { + ) -> XrpcResult> { let base = overrides .base_uri .clone() .unwrap_or_else(|| self.base.clone()); - let is_refresh = R::NSID == super_mod::NSID_REFRESH_SESSION; + let is_refresh = R::NSID == NSID_REFRESH_SESSION; + let mut current_did = None; if overrides.options.auth.is_none() { - if let Some(s) = self.tokens.get().await { - overrides.options.auth = Some(if is_refresh { - AuthorizationToken::Bearer(s.refresh_jwt) - } else { - AuthorizationToken::Bearer(s.access_jwt) - }); + if let Ok(guard) = self.refresh_lock.try_lock() { + if let Some(ref did) = *guard { + current_did = Some(did.clone()); + if let Some(s) = self.tokens.get(&did).await { + overrides.options.auth = Some( + if let Some(refresh_tok) = s.refresh_token() + && is_refresh + { + AuthorizationToken::Bearer(refresh_tok.clone().into_static()) + } else { + AuthorizationToken::Bearer(s.access_token().clone().into_static()) + }, + ); + } + } } } - let http_request = super_mod::build_http_request(&base, &req, &overrides.options) - .map_err(error::TransportError::from)?; + let http_request = + build_http_request(&base, &req, &overrides.options).map_err(TransportError::from)?; let http_response = self .transport .send_http(http_request) .await - .map_err(|e| error::TransportError::Other(Box::new(e)))?; + .map_err(|e| TransportError::Other(Box::new(e)))?; let status = http_response.status(); let buffer = Bytes::from(http_response.into_body()); if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { - return Err(error::HttpError { + return Err(HttpError { status, body: Some(buffer), } @@ -167,21 +195,25 @@ impl AtClient { self.refresh_once().await?; let mut retry_opts = overrides.options.clone(); - if let Some(s) = self.tokens.get().await { - retry_opts.auth = Some(AuthorizationToken::Bearer(s.access_jwt)); + if let Some(curr_did) = current_did { + if let Some(s) = self.tokens.get(&curr_did).await { + retry_opts.auth = Some(AuthorizationToken::Bearer( + s.access_token().clone().into_static(), + )); + } } - let http_request = super_mod::build_http_request(&base, &req, &retry_opts) - .map_err(error::TransportError::from)?; + let http_request = + build_http_request(&base, &req, &retry_opts).map_err(TransportError::from)?; let http_response = self .transport .send_http(http_request) .await - .map_err(|e| error::TransportError::Other(Box::new(e)))?; + .map_err(|e| TransportError::Other(Box::new(e)))?; let status = http_response.status(); let buffer = Bytes::from(http_response.into_body()); if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { - return Err(error::HttpError { + return Err(HttpError { status, body: Some(buffer), } @@ -193,27 +225,41 @@ impl AtClient { Ok(Response::new(buffer, status)) } - async fn refresh_once(&self) -> super_mod::Result<()> { - let _guard = self.refresh_lock.lock().await; - let Some(s) = self.tokens.get().await else { - return Err(error::ClientError::Auth(error::AuthError::NotAuthenticated)); - }; - let refresh_token = s.refresh_jwt.clone(); - let refresh_resp = self - .transport - .xrpc(self.base.clone()) - .auth(AuthorizationToken::Bearer(refresh_token)) - .send(jacquard_api::com_atproto::server::refresh_session::RefreshSession) - .await?; - let refreshed = match refresh_resp.into_output() { - Ok(o) => Session::from(o), - Err(_) => return Err(error::ClientError::Auth(error::AuthError::RefreshFailed)), - }; - self.tokens - .set(refreshed) - .await - .map_err(|_| error::ClientError::Auth(error::AuthError::RefreshFailed))?; - Ok(()) + async fn refresh_once(&self) -> XrpcResult<()> { + let guard = self.refresh_lock.lock().await; + if let Some(ref did) = *guard { + if let Some(s) = self.tokens.get(did).await { + if let Some(refresh_tok) = s.refresh_token() { + let refresh_resp = self + .transport + .xrpc(self.base.clone()) + .auth(AuthorizationToken::Bearer( + refresh_tok.clone().into_static(), + )) + .send(jacquard_api::com_atproto::server::refresh_session::RefreshSession) + .await?; + let refreshed = match refresh_resp.into_output() { + Ok(o) => AtpSession::from(o), + Err(_) => return Err(ClientError::Auth(AuthError::RefreshFailed)), + }; + + let mut session = s.clone(); + session.set_access_token(refreshed.access_jwt); + session.set_refresh_token(refreshed.refresh_jwt); + + self.set_session(session) + .await + .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?; + Ok(()) + } else { + Err(ClientError::Auth(AuthError::RefreshFailed)) + } + } else { + Err(ClientError::Auth(AuthError::NotAuthenticated)) + } + } else { + Err(ClientError::Auth(AuthError::NotAuthenticated)) + } } fn is_auth_expired(status: http::StatusCode, buffer: &Bytes) -> bool { diff --git a/crates/jacquard/src/client/response.rs b/crates/jacquard/src/client/response.rs deleted file mode 100644 index 1da8c649..00000000 --- a/crates/jacquard/src/client/response.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! XRPC response parsing and error handling - -use bytes::Bytes; -use http::StatusCode; -use jacquard_common::IntoStatic; -use jacquard_common::smol_str::SmolStr; -use jacquard_common::types::xrpc::XrpcRequest; -use serde::Deserialize; -use std::marker::PhantomData; - -use super::error::AuthError; - -/// XRPC response wrapper that owns the response buffer -/// -/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations. -/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`). -pub struct Response { - buffer: Bytes, - status: StatusCode, - _marker: PhantomData, -} - -impl Response { - /// Create a new response from a buffer and status code - pub fn new(buffer: Bytes, status: StatusCode) -> Self { - Self { - buffer, - status, - _marker: PhantomData, - } - } - - /// Get the HTTP status code - pub fn status(&self) -> StatusCode { - self.status - } - - /// Parse the response, borrowing from the internal buffer - pub fn parse(&self) -> Result, XrpcError>> { - // Use a helper to make lifetime inference work - fn parse_output<'b, R: XrpcRequest>( - buffer: &'b [u8], - ) -> Result, serde_json::Error> { - serde_json::from_slice(buffer) - } - - fn parse_error<'b, R: XrpcRequest>( - buffer: &'b [u8], - ) -> Result, serde_json::Error> { - serde_json::from_slice(buffer) - } - - // 200: parse as output - if self.status.is_success() { - match parse_output::(&self.buffer) { - Ok(output) => Ok(output), - Err(e) => Err(XrpcError::Decode(e)), - } - // 400: try typed XRPC error, fallback to generic error - } else if self.status.as_u16() == 400 { - match parse_error::(&self.buffer) { - Ok(error) => Err(XrpcError::Xrpc(error)), - Err(_) => { - // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) - match serde_json::from_slice::(&self.buffer) { - Ok(generic) => { - // Map auth-related errors to AuthError - match generic.error.as_str() { - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), - _ => Err(XrpcError::Generic(generic)), - } - } - Err(e) => Err(XrpcError::Decode(e)), - } - } - } - // 401: always auth error - } else { - match serde_json::from_slice::(&self.buffer) { - Ok(generic) => match generic.error.as_str() { - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), - }, - Err(e) => Err(XrpcError::Decode(e)), - } - } - } - - /// Parse the response into an owned output - pub fn into_output(self) -> Result, XrpcError>> - where - for<'a> R::Output<'a>: IntoStatic>, - for<'a> R::Err<'a>: IntoStatic>, - { - // Use a helper to make lifetime inference work - fn parse_output<'b, R: XrpcRequest>( - buffer: &'b [u8], - ) -> Result, serde_json::Error> { - serde_json::from_slice(buffer) - } - - fn parse_error<'b, R: XrpcRequest>( - buffer: &'b [u8], - ) -> Result, serde_json::Error> { - serde_json::from_slice(buffer) - } - - // 200: parse as output - if self.status.is_success() { - match parse_output::(&self.buffer) { - Ok(output) => Ok(output.into_static()), - Err(e) => Err(XrpcError::Decode(e)), - } - // 400: try typed XRPC error, fallback to generic error - } else if self.status.as_u16() == 400 { - match parse_error::(&self.buffer) { - Ok(error) => Err(XrpcError::Xrpc(error.into_static())), - Err(_) => { - // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) - match serde_json::from_slice::(&self.buffer) { - Ok(generic) => { - // Map auth-related errors to AuthError - match generic.error.as_ref() { - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), - _ => Err(XrpcError::Generic(generic)), - } - } - Err(e) => Err(XrpcError::Decode(e)), - } - } - } - // 401: always auth error - } else { - match serde_json::from_slice::(&self.buffer) { - Ok(generic) => match generic.error.as_ref() { - "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), - "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), - _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), - }, - Err(e) => Err(XrpcError::Decode(e)), - } - } - } - - /// Get the raw buffer - pub fn buffer(&self) -> &Bytes { - &self.buffer - } -} - -/// Generic XRPC error format for untyped errors like InvalidRequest -/// -/// Used when the error doesn't match the endpoint's specific error enum -#[derive(Debug, Clone, Deserialize)] -pub struct GenericXrpcError { - /// Error code (e.g., "InvalidRequest") - pub error: SmolStr, - /// Optional error message with details - pub message: Option, -} - -impl std::fmt::Display for GenericXrpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(msg) = &self.message { - write!(f, "{}: {}", self.error, msg) - } else { - write!(f, "{}", self.error) - } - } -} - -impl std::error::Error for GenericXrpcError {} - -/// XRPC-specific errors returned from endpoints -/// -/// Represents errors returned in the response body -/// Type parameter `E` is the endpoint's specific error enum type. -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum XrpcError { - /// Typed XRPC error from the endpoint's specific error enum - #[error("XRPC error: {0}")] - Xrpc(E), - - /// Authentication error (ExpiredToken, InvalidToken, etc.) - #[error("Authentication error: {0}")] - Auth(#[from] AuthError), - - /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest) - #[error("XRPC error: {0}")] - Generic(GenericXrpcError), - - /// Failed to decode the response body - #[error("Failed to decode response: {0}")] - Decode(#[from] serde_json::Error), -} diff --git a/crates/jacquard/src/client/token.rs b/crates/jacquard/src/client/token.rs index 05b2fcdb..8cd02f2b 100644 --- a/crates/jacquard/src/client/token.rs +++ b/crates/jacquard/src/client/token.rs @@ -1,49 +1,9 @@ +use crate::client::AtpSession; use async_trait::async_trait; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use thiserror::Error; - -use super::Session; use jacquard_common::IntoStatic; +use jacquard_common::session::{SessionStore, SessionStoreError}; use jacquard_common::types::string::{Did, Handle}; - -/// Errors emitted by token stores. -#[derive(Debug, Error)] -pub enum TokenStoreError { - /// An underlying I/O or serialization error with context. - #[error("token store error: {0}")] - Other(String), -} - -/// Pluggable session token storage (memory, disk, browser, etc.). -#[async_trait] -pub trait TokenStore: Send + Sync { - /// Get the current session if present. - async fn get(&self) -> Option; - /// Persist the given session. - async fn set(&self, session: Session) -> Result<(), TokenStoreError>; - /// Remove any stored session. - async fn clear(&self) -> Result<(), TokenStoreError>; -} - -/// In-memory token store suitable for short-lived sessions and tests. -#[derive(Default, Clone)] -pub struct MemoryTokenStore(Arc>>); - -#[async_trait] -impl TokenStore for MemoryTokenStore { - async fn get(&self) -> Option { - self.0.read().await.clone() - } - async fn set(&self, session: Session) -> Result<(), TokenStoreError> { - *self.0.write().await = Some(session); - Ok(()) - } - async fn clear(&self) -> Result<(), TokenStoreError> { - *self.0.write().await = None; - Ok(()) - } -} +use std::path::{Path, PathBuf}; /// File-backed token store using a JSON file. /// @@ -77,13 +37,15 @@ struct FileSession { } #[async_trait] -impl TokenStore for FileTokenStore { - async fn get(&self) -> Option { - let data = tokio::fs::read(&self.path).await.ok()?; +impl SessionStore, AtpSession> for FileTokenStore { + async fn get(&self, key: &Did<'static>) -> Option { + let mut path = self.path.clone(); + path.push(key.to_string()); + let data = tokio::fs::read(&path).await.ok()?; let disk: FileSession = serde_json::from_slice(&data).ok()?; let did = Did::new_owned(disk.did).ok()?; let handle = Handle::new_owned(disk.handle).ok()?; - Some(Session { + Some(AtpSession { access_jwt: disk.access_jwt.into(), refresh_jwt: disk.refresh_jwt.into(), did: did.into_static(), @@ -91,35 +53,46 @@ impl TokenStore for FileTokenStore { }) } - async fn set(&self, session: Session) -> Result<(), TokenStoreError> { + async fn set(&self, key: Did<'static>, session: AtpSession) -> Result<(), SessionStoreError> { let disk = FileSession { access_jwt: session.access_jwt.to_string(), refresh_jwt: session.refresh_jwt.to_string(), did: session.did.to_string(), handle: session.handle.to_string(), }; - let buf = - serde_json::to_vec_pretty(&disk).map_err(|e| TokenStoreError::Other(e.to_string()))?; + let buf = serde_json::to_vec_pretty(&disk).map_err(SessionStoreError::from)?; if let Some(parent) = self.path.parent() { tokio::fs::create_dir_all(parent) .await - .map_err(|e| TokenStoreError::Other(e.to_string()))?; + .map_err(SessionStoreError::from)?; } - let tmp = self.path.with_extension("tmp"); + let mut path = self.path.clone(); + path.push(key.to_string()); + let tmp = path.with_extension("tmp"); tokio::fs::write(&tmp, &buf) .await - .map_err(|e| TokenStoreError::Other(e.to_string()))?; - tokio::fs::rename(&tmp, &self.path) + .map_err(SessionStoreError::from)?; + tokio::fs::rename(&tmp, &path) .await - .map_err(|e| TokenStoreError::Other(e.to_string()))?; + .map_err(SessionStoreError::from)?; Ok(()) } - async fn clear(&self) -> Result<(), TokenStoreError> { + async fn del(&self, key: &Did<'static>) -> Result<(), SessionStoreError> { + let mut path = self.path.clone(); + path.push(key.to_string()); + match tokio::fs::remove_file(&path).await { + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(SessionStoreError::from(e)), + } + } + + async fn clear(&self) -> Result<(), SessionStoreError> { match tokio::fs::remove_file(&self.path).await { Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(TokenStoreError::Other(e.to_string())), + Err(e) => Err(SessionStoreError::from(e)), } } } diff --git a/crates/jacquard/src/client/xrpc_call.rs b/crates/jacquard/src/client/xrpc_call.rs deleted file mode 100644 index c83b953d..00000000 --- a/crates/jacquard/src/client/xrpc_call.rs +++ /dev/null @@ -1,154 +0,0 @@ -use bytes::Bytes; -use http::{HeaderName, HeaderValue}; -use url::Url; - -use crate::CowStr; -use crate::client::{self as super_mod, Response, error}; -use crate::client::{AuthorizationToken, HttpClient}; -use jacquard_common::types::xrpc::XrpcRequest; - -/// Per-request options for XRPC calls. -#[derive(Debug, Default, Clone)] -pub struct CallOptions<'a> { - /// Optional Authorization to apply (`Bearer` or `DPoP`). - pub auth: Option>, - /// `atproto-proxy` header value. - pub atproto_proxy: Option>, - /// `atproto-accept-labelers` header values. - pub atproto_accept_labelers: Option>>, - /// Extra headers to attach to this request. - pub extra_headers: Vec<(HeaderName, HeaderValue)>, -} - -/// Extension for stateless XRPC calls on any `HttpClient`. -/// -/// Example -/// ```ignore -/// use jacquard::client::XrpcExt; -/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; -/// use jacquard::types::ident::AtIdentifier; -/// use miette::IntoDiagnostic; -/// -/// #[tokio::main] -/// async fn main() -> miette::Result<()> { -/// let http = reqwest::Client::new(); -/// let base = url::Url::parse("https://public.api.bsky.app")?; -/// let resp = http -/// .xrpc(base) -/// .send( -/// GetAuthorFeed::new() -/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) -/// .limit(5) -/// .build(), -/// ) -/// .await?; -/// let out = resp.into_output()?; -/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?); -/// Ok(()) -/// } -/// ``` -pub trait XrpcExt: HttpClient { - /// Start building an XRPC call for the given base URL. - fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self> - where - Self: Sized, - { - XrpcCall { - client: self, - base, - opts: CallOptions::default(), - } - } -} - -impl XrpcExt for T {} - -/// Stateless XRPC call builder. -/// -/// Example (per-request overrides) -/// ```ignore -/// use jacquard::client::{XrpcExt, AuthorizationToken}; -/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; -/// use jacquard::types::ident::AtIdentifier; -/// use jacquard::CowStr; -/// use miette::IntoDiagnostic; -/// -/// #[tokio::main] -/// async fn main() -> miette::Result<()> { -/// let http = reqwest::Client::new(); -/// let base = url::Url::parse("https://public.api.bsky.app")?; -/// let resp = http -/// .xrpc(base) -/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) -/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) -/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) -/// .send( -/// GetAuthorFeed::new() -/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap()) -/// .limit(5) -/// .build(), -/// ) -/// .await?; -/// let out = resp.into_output()?; -/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?); -/// Ok(()) -/// } -/// ``` -pub struct XrpcCall<'a, C: HttpClient> { - pub(crate) client: &'a C, - pub(crate) base: Url, - pub(crate) opts: CallOptions<'a>, -} - -impl<'a, C: HttpClient> XrpcCall<'a, C> { - /// Apply Authorization to this call. - pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self { - self.opts.auth = Some(token); - self - } - /// Set `atproto-proxy` header for this call. - pub fn proxy(mut self, proxy: CowStr<'a>) -> Self { - self.opts.atproto_proxy = Some(proxy); - self - } - /// Set `atproto-accept-labelers` header(s) for this call. - pub fn accept_labelers(mut self, labelers: Vec>) -> Self { - self.opts.atproto_accept_labelers = Some(labelers); - self - } - /// Add an extra header. - pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self { - self.opts.extra_headers.push((name, value)); - self - } - /// Replace the builder's options entirely. - pub fn with_options(mut self, opts: CallOptions<'a>) -> Self { - self.opts = opts; - self - } - - /// Send the given typed XRPC request and return a response wrapper. - pub async fn send(self, request: R) -> super_mod::Result> { - let http_request = super_mod::build_http_request(&self.base, &request, &self.opts) - .map_err(error::TransportError::from)?; - - let http_response = self - .client - .send_http(http_request) - .await - .map_err(|e| error::TransportError::Other(Box::new(e)))?; - - let status = http_response.status(); - let buffer = Bytes::from(http_response.into_body()); - - if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { - return Err(error::HttpError { - status, - body: Some(buffer), - } - .into()); - } - - Ok(Response::new(buffer, status)) - } -} diff --git a/crates/jacquard/src/identity/resolver.rs b/crates/jacquard/src/identity.rs similarity index 62% rename from crates/jacquard/src/identity/resolver.rs rename to crates/jacquard/src/identity.rs index 78e6c9ab..ad65b1d5 100644 --- a/crates/jacquard/src/identity/resolver.rs +++ b/crates/jacquard/src/identity.rs @@ -1,3 +1,5 @@ +//! Identity resolution utilities: DID and handle resolution, DID document fetch, +//! and helpers for PDS endpoint discovery. See `identity::resolver` for details. //! Identity resolution: handle → DID and DID → document, with smart fallbacks. //! //! Fallback order (default): @@ -9,324 +11,29 @@ //! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer //! and optionally validate the document `id` against the requested DID. -use std::collections::BTreeMap; -use std::str::FromStr; - // use crate::CowStr; // not currently needed directly here -use crate::client::XrpcExt; -use bon::Builder; + use bytes::Bytes; -use jacquard_common::types::did_doc::Service; -use jacquard_common::types::string::AtprotoStr; -use jacquard_common::types::uri::Uri; -use jacquard_common::types::value::Data; -use jacquard_common::{CowStr, IntoStatic}; -use miette::Diagnostic; +use jacquard_common::IntoStatic; +use jacquard_common::error::TransportError; +use jacquard_common::http_client::HttpClient; +use jacquard_common::ident_resolver::{ + DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource, + ResolverOptions, +}; +use jacquard_common::types::xrpc::XrpcExt; use percent_encoding::percent_decode_str; use reqwest::StatusCode; -use thiserror::Error; use url::{ParseError, Url}; use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle}; use crate::types::did_doc::DidDocument; use crate::types::ident::AtIdentifier; use crate::types::string::{Did, Handle}; -use crate::types::value::AtDataError; #[cfg(feature = "dns")] use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; -/// Errors that can occur during identity resolution. -/// -/// Note: when validating a fetched DID document against a requested DID, a -/// `DocIdMismatch` error is returned that includes the owned document so callers -/// can inspect it and decide how to proceed. -#[derive(Debug, Error, Diagnostic)] -#[allow(missing_docs)] -pub enum IdentityError { - #[error("unsupported DID method: {0}")] - UnsupportedDidMethod(String), - #[error("invalid well-known atproto-did content")] - InvalidWellKnown, - #[error("missing PDS endpoint in DID document")] - MissingPdsEndpoint, - #[error("HTTP error: {0}")] - Http(#[from] reqwest::Error), - #[error("HTTP status {0}")] - HttpStatus(StatusCode), - #[error("XRPC error: {0}")] - Xrpc(String), - #[error("URL parse error: {0}")] - Url(#[from] url::ParseError), - #[error("DNS error: {0}")] - #[cfg(feature = "dns")] - Dns(#[from] hickory_resolver::error::ResolveError), - #[error("serialize/deserialize error: {0}")] - Serde(#[from] serde_json::Error), - #[error("invalid DID document: {0}")] - InvalidDoc(String), - #[error(transparent)] - Data(#[from] AtDataError), - /// DID document id did not match requested DID; includes the fetched document - #[error("DID doc id mismatch")] - DocIdMismatch { - expected: Did<'static>, - doc: DidDocument<'static>, - }, -} - -/// Source to fetch PLC (did:plc) documents from. -/// -/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). -/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as -/// `com.atproto.identity.resolveHandle` and a "mini-doc" -/// endpoint (`com.bad-example.identity.resolveMiniDoc`). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PlcSource { - /// Use the public PLC directory - PlcDirectory { - /// Base URL for the PLC directory - base: Url, - }, - /// Use the slingshot mini-docs service - Slingshot { - /// Base URL for the Slingshot service - base: Url, - }, -} - -impl Default for PlcSource { - fn default() -> Self { - Self::PlcDirectory { - base: Url::parse("https://plc.directory/").expect("valid url"), - } - } -} - -impl PlcSource { - /// Default Slingshot source (`https://slingshot.microcosm.blue`) - pub fn slingshot_default() -> Self { - PlcSource::Slingshot { - base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), - } - } -} - -/// DID Document fetch response for borrowed/owned parsing. -/// -/// Carries the raw response bytes and the HTTP status, plus the requested DID -/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer -/// or `parse_validated()` to also enforce that the doc `id` matches the -/// requested DID (returns a `DocIdMismatch` containing the fetched doc on -/// mismatch). Use `into_owned()` to parse into an owned document. -#[derive(Clone)] -pub struct DidDocResponse { - buffer: Bytes, - status: StatusCode, - /// Optional DID we intended to resolve; used for validation helpers - requested: Option>, -} - -impl DidDocResponse { - /// Parse as borrowed DidDocument<'_> - pub fn parse<'b>(&'b self) -> Result, IdentityError> { - if self.status.is_success() { - if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { - Ok(doc) - } else if let Ok(mini_doc) = serde_json::from_slice::>(&self.buffer) { - Ok(DidDocument { - id: mini_doc.did, - also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), - verification_method: None, - service: Some(vec![Service { - id: CowStr::new_static("#atproto_pds"), - r#type: CowStr::new_static("AtprotoPersonalDataServer"), - service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( - Url::from_str(&mini_doc.pds).unwrap(), - )))), - extra_data: BTreeMap::new(), - }]), - extra_data: BTreeMap::new(), - }) - } else { - Err(IdentityError::MissingPdsEndpoint) - } - } else { - Err(IdentityError::HttpStatus(self.status)) - } - } - - /// Parse and validate that the DID in the document matches the requested DID if present. - /// - /// On mismatch, returns an error that contains the owned document for inspection. - pub fn parse_validated<'b>(&'b self) -> Result, IdentityError> { - let doc = self.parse()?; - if let Some(expected) = &self.requested { - if doc.id.as_str() != expected.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: expected.clone(), - doc: doc.clone().into_static(), - }); - } - } - Ok(doc) - } - - /// Parse as owned DidDocument<'static> - pub fn into_owned(self) -> Result, IdentityError> { - if self.status.is_success() { - if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { - Ok(doc.into_static()) - } else if let Ok(mini_doc) = serde_json::from_slice::>(&self.buffer) { - Ok(DidDocument { - id: mini_doc.did, - also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), - verification_method: None, - service: Some(vec![Service { - id: CowStr::new_static("#atproto_pds"), - r#type: CowStr::new_static("AtprotoPersonalDataServer"), - service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( - Url::from_str(&mini_doc.pds).unwrap(), - )))), - extra_data: BTreeMap::new(), - }]), - extra_data: BTreeMap::new(), - } - .into_static()) - } else { - Err(IdentityError::MissingPdsEndpoint) - } - } else { - Err(IdentityError::HttpStatus(self.status)) - } - } -} - -/// Handle → DID fallback step. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HandleStep { - /// DNS TXT _atproto.\ - DnsTxt, - /// HTTPS GET https://\/.well-known/atproto-did - HttpsWellKnown, - /// XRPC com.atproto.identity.resolveHandle against a provided PDS base - PdsResolveHandle, -} - -/// DID → Doc fallback step. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DidStep { - /// For did:web: fetch from the well-known location - DidWebHttps, - /// For did:plc: fetch from PLC source - PlcHttp, - /// If a PDS base is known, ask it for the DID doc - PdsResolveDid, -} - -/// Configurable resolver options. -/// -/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). -/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless -/// XRPC over reqwest; authentication can be layered as needed). -/// - `handle_order`/`did_order`: ordered strategies for resolution. -/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, -/// returning `DocIdMismatch` with the fetched document on mismatch. -/// - `public_fallback_for_handle`: if true (default), attempt -/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. -/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC -/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. -#[derive(Debug, Clone, Builder)] -#[builder(start_fn = new)] -pub struct ResolverOptions { - /// PLC data source (directory or slingshot) - pub plc_source: PlcSource, - /// Optional PDS base to use for fallbacks - pub pds_fallback: Option, - /// Order of attempts for handle → DID resolution - pub handle_order: Vec, - /// Order of attempts for DID → Doc resolution - pub did_order: Vec, - /// Validate that fetched DID document id matches the requested DID - pub validate_doc_id: bool, - /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app - pub public_fallback_for_handle: bool, -} - -impl Default for ResolverOptions { - fn default() -> Self { - // By default, prefer DNS then HTTPS for handles, then PDS fallback - // For DID documents, prefer method-native sources, then PDS fallback - Self::new() - .plc_source(PlcSource::default()) - .handle_order(vec![ - HandleStep::DnsTxt, - HandleStep::HttpsWellKnown, - HandleStep::PdsResolveHandle, - ]) - .did_order(vec![ - DidStep::DidWebHttps, - DidStep::PlcHttp, - DidStep::PdsResolveDid, - ]) - .validate_doc_id(true) - .public_fallback_for_handle(true) - .build() - } -} - -/// Trait for identity resolution, for pluggable implementations. -/// -/// The provided `DefaultResolver` supports: -/// - DNS TXT (`_atproto.`) when compiled with the `dns` feature -/// - HTTPS well-known for handles and `did:web` -/// - PLC directory or Slingshot for `did:plc` -/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source -/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest -#[async_trait::async_trait] -pub trait IdentityResolver { - /// Access options for validation decisions in default methods - fn options(&self) -> &ResolverOptions; - - /// Resolve handle - async fn resolve_handle(&self, handle: &Handle<'_>) -> Result, IdentityError>; - - /// Resolve DID document - async fn resolve_did_doc(&self, did: &Did<'_>) -> Result; - - /// Resolve the DID document and return an owned version - async fn resolve_did_doc_owned( - &self, - did: &Did<'_>, - ) -> Result, IdentityError> { - self.resolve_did_doc(did).await?.into_owned() - } - /// reutrn the PDS url for a DID - async fn pds_for_did(&self, did: &Did<'_>) -> Result { - let resp = self.resolve_did_doc(did).await?; - let doc = resp.parse()?; - // Default-on doc id equality check - if self.options().validate_doc_id { - if doc.id.as_str() != did.as_str() { - return Err(IdentityError::DocIdMismatch { - expected: did.clone().into_static(), - doc: doc.clone().into_static(), - }); - } - } - doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) - } - /// Return the DIS and PDS url for a handle - async fn pds_for_handle( - &self, - handle: &Handle<'_>, - ) -> Result<(Did<'static>, Url), IdentityError> { - let did = self.resolve_handle(handle).await?; - let pds = self.pds_for_did(&did).await?; - Ok((did, pds)) - } -} - /// Default resolver implementation with configurable fallback order. pub struct DefaultResolver { http: reqwest::Client, @@ -346,6 +53,19 @@ impl DefaultResolver { } } + #[cfg(feature = "dns")] + /// Create a new instance of the default resolver with all options, plus default DNS, up front + pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self { + Self { + http, + opts, + dns: Some(TokioAsyncResolver::tokio( + ResolverConfig::default(), + Default::default(), + )), + } + } + #[cfg(feature = "dns")] /// Add default DNS resolution to the resolver pub fn with_system_dns(mut self) -> Self { @@ -415,18 +135,30 @@ impl DefaultResolver { } async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { - let resp = self.http.get(url).send().await?; + let resp = self + .http + .get(url) + .send() + .await + .map_err(TransportError::from)?; let status = resp.status(); - let buf = resp.bytes().await?; + let buf = resp.bytes().await.map_err(TransportError::from)?; Ok((buf, status)) } async fn get_text(&self, url: Url) -> Result { - let resp = self.http.get(url).send().await?; + let resp = self + .http + .get(url) + .send() + .await + .map_err(TransportError::from)?; if resp.status() == StatusCode::OK { - Ok(resp.text().await?) + Ok(resp.text().await.map_err(TransportError::from)?) } else { - Err(IdentityError::Http(resp.error_for_status().unwrap_err())) + Err(IdentityError::Http( + resp.error_for_status().unwrap_err().into(), + )) } } @@ -684,6 +416,17 @@ impl IdentityResolver for DefaultResolver { } } +impl HttpClient for DefaultResolver { + async fn send_http( + &self, + request: http::Request>, + ) -> core::result::Result>, Self::Error> { + self.http.send_http(request).await + } + + type Error = reqwest::Error; +} + /// Warnings produced during identity checks that are not fatal #[derive(Debug, Clone, PartialEq, Eq)] pub enum IdentityWarning { @@ -778,19 +521,40 @@ impl MiniDocResponse { } } -/// Slingshot mini-doc data (subset of DID doc info) -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct MiniDoc<'a> { - #[serde(borrow)] - pub did: Did<'a>, - #[serde(borrow)] - pub handle: Handle<'a>, - #[serde(borrow)] - pub pds: crate::CowStr<'a>, - #[serde(borrow, rename = "signingKey", alias = "signing_key")] - pub signing_key: crate::CowStr<'a>, +/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC +pub type PublicResolver = DefaultResolver; + +impl Default for PublicResolver { + /// Build a resolver with: + /// - reqwest HTTP client + /// - Public fallbacks enabled for handle resolution + /// - default options (DNS enabled if compiled, public fallback for handles enabled) + /// + /// Example + /// ```ignore + /// use jacquard::identity::resolver::PublicResolver; + /// let resolver = PublicResolver::default(); + /// ``` + fn default() -> Self { + let http = reqwest::Client::new(); + let opts = ResolverOptions::default(); + let resolver = DefaultResolver::new(http, opts); + #[cfg(feature = "dns")] + let resolver = resolver.with_system_dns(); + resolver + } +} + +/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and +/// mini-doc fallbacks, unauthenticated by default. +pub fn slingshot_resolver_default() -> PublicResolver { + let http = reqwest::Client::new(); + let mut opts = ResolverOptions::default(); + opts.plc_source = PlcSource::slingshot_default(); + let resolver = DefaultResolver::new(http, opts); + #[cfg(feature = "dns")] + let resolver = resolver.with_system_dns(); + resolver } #[cfg(test)] @@ -810,36 +574,6 @@ mod tests { ); } - #[test] - fn parse_validated_ok() { - let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); - let requested = Did::new_owned("did:plc:alice").unwrap(); - let resp = DidDocResponse { - buffer: buf, - status: StatusCode::OK, - requested: Some(requested), - }; - let _doc = resp.parse_validated().expect("valid"); - } - - #[test] - fn parse_validated_mismatch() { - let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); - let requested = Did::new_owned("did:plc:alice").unwrap(); - let resp = DidDocResponse { - buffer: buf, - status: StatusCode::OK, - requested: Some(requested), - }; - match resp.parse_validated() { - Err(IdentityError::DocIdMismatch { expected, doc }) => { - assert_eq!(expected.as_str(), "did:plc:alice"); - assert_eq!(doc.id.as_str(), "did:plc:bob"); - } - other => panic!("unexpected result: {:?}", other), - } - } - #[test] fn slingshot_mini_doc_url_build() { let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default()); @@ -893,39 +627,3 @@ mod tests { } } } - -/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC -pub type PublicResolver = DefaultResolver; - -impl Default for PublicResolver { - /// Build a resolver with: - /// - reqwest HTTP client - /// - Public fallbacks enabled for handle resolution - /// - default options (DNS enabled if compiled, public fallback for handles enabled) - /// - /// Example - /// ```ignore - /// use jacquard::identity::resolver::PublicResolver; - /// let resolver = PublicResolver::default(); - /// ``` - fn default() -> Self { - let http = reqwest::Client::new(); - let opts = ResolverOptions::default(); - let resolver = DefaultResolver::new(http, opts); - #[cfg(feature = "dns")] - let resolver = resolver.with_system_dns(); - resolver - } -} - -/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and -/// mini-doc fallbacks, unauthenticated by default. -pub fn slingshot_resolver_default() -> PublicResolver { - let http = reqwest::Client::new(); - let mut opts = ResolverOptions::default(); - opts.plc_source = PlcSource::slingshot_default(); - let resolver = DefaultResolver::new(http, opts); - #[cfg(feature = "dns")] - let resolver = resolver.with_system_dns(); - resolver -} diff --git a/crates/jacquard/src/identity/mod.rs b/crates/jacquard/src/identity/mod.rs deleted file mode 100644 index 5f8e5724..00000000 --- a/crates/jacquard/src/identity/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Identity resolution utilities: DID and handle resolution, DID document fetch, -//! and helpers for PDS endpoint discovery. See `identity::resolver` for details. -pub mod resolver; diff --git a/crates/jacquard/src/lib.rs b/crates/jacquard/src/lib.rs index cd2cb5d1..592e4c45 100644 --- a/crates/jacquard/src/lib.rs +++ b/crates/jacquard/src/lib.rs @@ -24,7 +24,7 @@ //! # use jacquard::CowStr; //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; //! use jacquard::api::com_atproto::server::create_session::CreateSession; -//! use jacquard::client::{BasicClient, Session}; +//! use jacquard::client::{BasicClient, AuthSession, AtpSession}; //! # use miette::IntoDiagnostic; //! //! # #[derive(Parser, Debug)] @@ -50,7 +50,7 @@ //! let url = url::Url::parse(&args.pds).unwrap(); //! let client = BasicClient::new(url); //! // Create session -//! let session = Session::from( +//! let session = AtpSession::from( //! client //! .send( //! CreateSession::new() @@ -87,14 +87,15 @@ //! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you //! want to pass auth on each call or build advanced flows. //! ```no_run -//! # use jacquard::client::XrpcExt; +//! # use jacquard::types::xrpc::XrpcExt; //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; //! # use jacquard::types::ident::AtIdentifier; +//! # use miette::IntoDiagnostic; //! # //! #[tokio::main] -//! async fn main() -> anyhow::Result<()> { +//! async fn main() -> miette::Result<()> { //! let http = reqwest::Client::new(); -//! let base = url::Url::parse("https://public.api.bsky.app")?; +//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; //! let resp = http //! .xrpc(base) //! .send( @@ -110,14 +111,15 @@ //! } //! ``` //! - Stateful client: `AtClient` holds a base `Url`, a transport, and a -//! `TokenStore` implementation. It automatically sets Authorization and can +//! `SessionStore` implementation. It automatically sets Authorization and can //! auto-refresh a session when expired, retrying once. //! - Convenience wrapper: `BasicClient` is an ergonomic newtype over -//! `AtClient` with a `new(Url)` constructor. +//! `AtClient>` with a `new(Url)` constructor. //! //! Per-request overrides (stateless) //! ```no_run -//! # use jacquard::client::{XrpcExt, AuthorizationToken}; +//! # use jacquard::AuthorizationToken; +//! # use jacquard::types::xrpc::XrpcExt; //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; //! # use jacquard::types::ident::AtIdentifier; //! # use jacquard::CowStr; @@ -126,7 +128,7 @@ //! #[tokio::main] //! async fn main() -> miette::Result<()> { //! let http = reqwest::Client::new(); -//! let base = url::Url::parse("https://public.api.bsky.app")?; +//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?; //! let resp = http //! .xrpc(base) //! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) @@ -146,8 +148,8 @@ //! ``` //! //! Token storage: -//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs. -//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk. +//! - Use `MemorySessionStore` for ephemeral sessions, tests, and CLIs. +//! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk. //! See `client::token::FileTokenStore` docs for details. //! ```no_run //! use jacquard::client::{AtClient, FileTokenStore}; @@ -161,6 +163,7 @@ /// XRPC client traits and basic implementation pub mod client; +/// OAuth usage helpers (discovery, PAR, token exchange) #[cfg(feature = "api")] /// If enabled, re-export the generated api crate diff --git a/crates/jacquard/src/main.rs b/crates/jacquard/src/main.rs index 82919166..44c0dc51 100644 --- a/crates/jacquard/src/main.rs +++ b/crates/jacquard/src/main.rs @@ -2,8 +2,9 @@ use clap::Parser; use jacquard::CowStr; use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; use jacquard::api::com_atproto::server::create_session::CreateSession; -use jacquard::client::{BasicClient, Session}; -use jacquard::identity::resolver::{IdentityResolver, slingshot_resolver_default}; +use jacquard::client::{AtpSession, AuthSession, BasicClient}; +use jacquard::ident_resolver::IdentityResolver; +use jacquard::identity::slingshot_resolver_default; use jacquard::types::string::Handle; use miette::IntoDiagnostic; @@ -33,7 +34,7 @@ async fn main() -> miette::Result<()> { let client = BasicClient::new(pds_url); // Create session - let session = Session::from( + let session = AtpSession::from( client .send( CreateSession::new() @@ -46,7 +47,7 @@ async fn main() -> miette::Result<()> { ); println!("logged in as {} ({})", session.handle, session.did); - client.set_session(session).await.into_diagnostic()?; + client.set_session(session.into()).await.into_diagnostic()?; // Fetch timeline println!("\nfetching timeline..."); -- 2.51.2