A better Rust ATProto crate

part-way through oauth stuff :(

Orual a0fe35e3 05de6ab5

+1
.gitignore
···
crates/jacquard-lexicon/target
codegen_plan.md
/lex_js
+
/docs
+511 -4
Cargo.lock
···
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"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
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"
···
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[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"
···
"wasm-bindgen",
"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"
···
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
+
"pem-rfc7468",
"zeroize",
]
···
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+
"const-oid",
"crypto-common",
+
"subtle",
]
[[package]]
···
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"base16ct",
"crypto-bigint",
+
"digest",
"ff",
"generic-array",
"group",
+
"pem-rfc7468",
+
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
···
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[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"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
dependencies = [
-
"base64",
+
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
···
"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",
···
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",
···
"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",
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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"
···
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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"
···
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]]
···
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",
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
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_check",
"yansi",
+
+
[[package]]
+
name = "quick-error"
+
version = "1.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
···
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"async-compression",
-
"base64",
+
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
···
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
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"
···
"base16ct",
"der",
"generic-array",
+
"pkcs8",
"subtle",
"zeroize",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
dependencies = [
-
"base64",
+
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
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"
···
[[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",
···
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"deranged",
"itoa",
+
"libc",
"num-conv",
+
"num_threads",
"powerfmt",
"serde",
"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"
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 = "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 = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
dependencies = [
+
"serde",
+
]
[[package]]
name = "zerotrie"
+8
crates/jacquard-common/Cargo.toml
···
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"
+7
crates/jacquard-common/src/cowstr.rs
···
}
}
+
impl Default for CowStr<'_> {
+
#[inline]
+
fn default() -> Self {
+
CowStr::new_static("")
+
}
+
}
+
impl From<String> for CowStr<'_> {
#[inline]
fn from(s: String) -> Self {
+64
crates/jacquard-common/src/http_client.rs
···
+
//! 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<Vec<u8>>,
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
+
}
+
+
#[cfg(feature = "reqwest-client")]
+
impl HttpClient for reqwest::Client {
+
type Error = reqwest::Error;
+
+
async fn send_http(
+
&self,
+
request: http::Request<Vec<u8>>,
+
) -> core::result::Result<http::Response<Vec<u8>>, 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<T: HttpClient> HttpClient for Arc<T> {
+
type Error = T::Error;
+
+
fn send_http(
+
&self,
+
request: http::Request<Vec<u8>>,
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
+
{
+
self.as_ref().send_http(request)
+
}
+
}
+399
crates/jacquard-common/src/ident_resolver.rs
···
+
//! 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<Did<'static>>,
+
}
+
+
impl DidDocResponse {
+
/// Parse as borrowed DidDocument<'_>
+
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
+
if self.status.is_success() {
+
if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
+
Ok(doc)
+
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&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<DidDocument<'b>, 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<DidDocument<'static>, IdentityError> {
+
if self.status.is_success() {
+
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
+
Ok(doc.into_static())
+
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&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.\<handle\>
+
DnsTxt,
+
/// HTTPS GET https://\<handle\>/.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<Url>,
+
/// Order of attempts for handle → DID resolution
+
pub handle_order: Vec<HandleStep>,
+
/// Order of attempts for DID → Doc resolution
+
pub did_order: Vec<DidStep>,
+
/// 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.<handle>`) 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<Did<'static>, IdentityError>;
+
+
/// Resolve DID document
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
+
+
/// Resolve DID doc from an identifier
+
async fn resolve_ident(
+
&self,
+
actor: &AtIdentifier<'_>,
+
) -> Result<DidDocResponse, IdentityError> {
+
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<DidDocument<'static>, 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<DidDocument<'static>, 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<Url, IdentityError> {
+
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),
+
}
+
}
+
}
+15
crates/jacquard-common/src/lib.rs
···
#[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>),
+
}
+76
crates/jacquard-common/src/session.rs
···
+
//! 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<dyn StdError + Send + Sync>),
+
}
+
+
/// Pluggable storage for arbitrary session records.
+
#[async_trait]
+
pub trait SessionStore<K, T>: Send + Sync
+
where
+
K: Eq + Hash,
+
T: Clone,
+
{
+
/// Get the current session if present.
+
async fn get(&self, key: &K) -> Option<T>;
+
/// 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<K, T>(Arc<RwLock<HashMap<K, T>>>);
+
+
impl<K, T> Default for MemorySessionStore<K, T> {
+
fn default() -> Self {
+
Self(Arc::new(RwLock::new(HashMap::new())))
+
}
+
}
+
+
#[async_trait]
+
impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
+
where
+
K: Eq + Hash + Send + Sync,
+
T: Clone + Send + Sync + 'static,
+
{
+
async fn get(&self, key: &K) -> Option<T> {
+
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(())
+
}
+
}
+1 -1
crates/jacquard-common/src/types/did.rs
···
/// method-specific-id allows alphanumerics, dots, colons, hyphens, underscores, and percent signs.
///
/// See: <https://atproto.com/specs/did>
-
#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
+
#[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)]
#[serde(transparent)]
#[repr(transparent)]
pub struct Did<'d>(CowStr<'d>);
+1 -1
crates/jacquard-common/src/types/nsid.rs
···
/// - Case-sensitive
///
/// See: <https://atproto.com/specs/nsid>
-
#[derive(Clone, PartialEq, Eq, Serialize, Hash)]
+
#[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)]
#[serde(transparent)]
#[repr(transparent)]
pub struct Nsid<'n>(CowStr<'n>);
+2 -2
crates/jacquard-common/src/types/string.rs
···
/// 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}"),
···
}
/// 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}")]
+446 -1
crates/jacquard-common/src/types/xrpc.rs
···
+
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)]
···
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<AuthorizationToken<'a>>,
+
/// `atproto-proxy` header value.
+
pub atproto_proxy: Option<CowStr<'a>>,
+
/// `atproto-accept-labelers` header values.
+
pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
+
/// 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<T: HttpClient> 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<CowStr<'a>>) -> 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<R: XrpcRequest + Send>(self, request: R) -> XrpcResult<Response<R>> {
+
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: <https://atproto.com/specs/xrpc#service-proxying>
+
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<Header> 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<R: XrpcRequest>(
+
base: &Url,
+
req: &R,
+
opts: &CallOptions<'_>,
+
) -> core::result::Result<Request<Vec<u8>>, 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::<Vec<_>>()
+
.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<R: XrpcRequest> {
+
buffer: Bytes,
+
status: StatusCode,
+
_marker: PhantomData<R>,
+
}
+
+
impl<R: XrpcRequest> Response<R> {
+
/// 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<R::Output<'_>, XrpcError<R::Err<'_>>> {
+
// Use a helper to make lifetime inference work
+
fn parse_output<'b, R: XrpcRequest>(
+
buffer: &'b [u8],
+
) -> Result<R::Output<'b>, serde_json::Error> {
+
serde_json::from_slice(buffer)
+
}
+
+
fn parse_error<'b, R: XrpcRequest>(
+
buffer: &'b [u8],
+
) -> Result<R::Err<'b>, serde_json::Error> {
+
serde_json::from_slice(buffer)
+
}
+
+
// 200: parse as output
+
if self.status.is_success() {
+
match parse_output::<R>(&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::<R>(&self.buffer) {
+
Ok(error) => Err(XrpcError::Xrpc(error)),
+
Err(_) => {
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
+
match serde_json::from_slice::<GenericXrpcError>(&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::<GenericXrpcError>(&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<R::Output<'static>, XrpcError<R::Err<'static>>>
+
where
+
for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>,
+
for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>,
+
{
+
// Use a helper to make lifetime inference work
+
fn parse_output<'b, R: XrpcRequest>(
+
buffer: &'b [u8],
+
) -> Result<R::Output<'b>, serde_json::Error> {
+
serde_json::from_slice(buffer)
+
}
+
+
fn parse_error<'b, R: XrpcRequest>(
+
buffer: &'b [u8],
+
) -> Result<R::Err<'b>, serde_json::Error> {
+
serde_json::from_slice(buffer)
+
}
+
+
// 200: parse as output
+
if self.status.is_success() {
+
match parse_output::<R>(&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::<R>(&self.buffer) {
+
Ok(error) => Err(XrpcError::Xrpc(error.into_static())),
+
Err(_) => {
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
+
match serde_json::from_slice::<GenericXrpcError>(&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::<GenericXrpcError>(&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<SmolStr>,
+
}
+
+
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<E: std::error::Error + IntoStatic> {
+
/// 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),
+
}
+29
crates/jacquard-oauth/Cargo.toml
···
+
[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"
+360
crates/jacquard-oauth/src/atproto.rs
···
+
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<T> = core::result::Result<T, Error>;
+
+
#[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<AuthMethod> 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<GrantType> 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<Vec<Url>>,
+
scopes: Option<&'s [Scope<'s>]>,
+
) -> Result<OAuthClientMetadata<'s>> {
+
// 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<Vec<Url>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
scope: Option<CowStr<'a>>,
+
}
+
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<Url>,
+
pub redirect_uris: Vec<Url>,
+
pub token_endpoint_auth_method: AuthMethod,
+
pub grant_types: Vec<GrantType>,
+
pub scopes: Vec<Scope<'m>>,
+
pub jwks_uri: Option<Url>,
+
pub token_endpoint_auth_signing_alg: Option<CowStr<'m>>,
+
}
+
+
pub fn atproto_client_metadata<'m>(
+
metadata: AtprotoClientMetadata<'m>,
+
keyset: &Option<Keyset>,
+
) -> Result<OAuthClientMetadata<'m>> {
+
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::<p256::NistP256>::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")),
+
}
+
);
+
}
+
}
+
}
+260
crates/jacquard-oauth/src/dpop.rs
···
+
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<dyn std::error::Error + Send + Sync>),
+
}
+
+
type Result<T> = core::result::Result<T, Error>;
+
+
#[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<CowStr<'s>>,
+
ath: Option<CowStr<'s>>,
+
) -> Result<CowStr<'s>> {
+
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<CowStr<'s>>,
+
ath: Option<CowStr<'s>>,
+
) -> Result<CowStr<'s>> {
+
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<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>>
+
where
+
S: SessionStore<CowStr<'static>, CowStr<'static>>,
+
{
+
inner: T,
+
pub(crate) key: Key,
+
nonces: S,
+
is_auth_server: bool,
+
}
+
+
impl<T> DpopClient<T> {
+
pub fn new(
+
key: Key,
+
http_client: T,
+
is_auth_server: bool,
+
supported_algs: &Option<Vec<CowStr<'static>>>,
+
) -> Result<Self> {
+
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>, CowStr<'static>>::default();
+
Ok(Self {
+
inner: http_client,
+
key,
+
nonces,
+
is_auth_server,
+
})
+
}
+
}
+
+
impl<T, S> DpopClient<T, S>
+
where
+
S: SessionStore<CowStr<'static>, CowStr<'static>>,
+
{
+
fn build_proof<'s>(
+
&self,
+
method: CowStr<'s>,
+
url: CowStr<'s>,
+
ath: Option<CowStr<'s>>,
+
nonce: Option<CowStr<'s>>,
+
) -> Result<CowStr<'s>> {
+
build_dpop_proof(&self.key, method, url, nonce, ath)
+
}
+
fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> 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::<ErrorResponse>(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<T, S> HttpClient for DpopClient<T, S>
+
where
+
T: HttpClient + Send + Sync + 'static,
+
S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static,
+
{
+
type Error = Error;
+
+
async fn send_http(
+
&self,
+
mut request: Request<Vec<u8>>,
+
) -> core::result::Result<Response<Vec<u8>>, 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<T: Clone> Clone for DpopClient<T> {
+
fn clone(&self) -> Self {
+
Self {
+
inner: self.inner.clone(),
+
key: self.key.clone(),
+
nonces: self.nonces.clone(),
+
is_auth_server: self.is_auth_server,
+
}
+
}
+
}
+42
crates/jacquard-oauth/src/error.rs
···
+
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<T> = core::result::Result<T, OAuthError>;
+14
crates/jacquard-oauth/src/jose.rs
···
+
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;
+85
crates/jacquard-oauth/src/jose/jws.rs
···
+
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<Header<'a>> 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<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub jwk: Option<Jwk>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub kid: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub x5u: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub x5c: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub x5t: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
#[serde(rename = "x5t#S256")]
+
pub x5ts256: Option<CowStr<'a>>,
+
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub typ: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub cty: Option<CowStr<'a>>,
+
}
+
+
impl From<Algorithm> 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<RegisteredHeader<'a>> 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),
+
}
+
}
+
}
+101
crates/jacquard-oauth/src/jose/jwt.rs
···
+
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<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub sub: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub aud: Option<RegisteredClaimsAud<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub exp: Option<i64>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub nbf: Option<i64>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub iat: Option<i64>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub jti: Option<CowStr<'a>>,
+
}
+
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
+
+
pub struct PublicClaims<'a> {
+
#[serde(borrow)]
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub htm: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub htu: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub ath: Option<CowStr<'a>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub nonce: Option<CowStr<'a>>,
+
}
+
+
impl<'a> From<RegisteredClaims<'a>> 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<CowStr<'a>>),
+
}
+
+
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())
+
}
+
}
+
}
+
}
+21
crates/jacquard-oauth/src/jose/signing.rs
···
+
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<CowStr<'static>> {
+
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())
+
}
+128
crates/jacquard-oauth/src/keyset.rs
···
+
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<SmolStr>),
+
#[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<T> = core::result::Result<T, Error>;
+
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
+
pub struct Keyset(Vec<Jwk>);
+
+
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<CowStr<'static>> {
+
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::<Vec<_>>();
+
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<CowStr<'static>> {
+
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<Vec<Jwk>> for Keyset {
+
type Error = Error;
+
+
fn try_from(keys: Vec<Jwk>) -> Result<Self> {
+
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))
+
}
+
}
+14
crates/jacquard-oauth/src/lib.rs
···
+
//! 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";
+214
crates/jacquard-oauth/src/resolver.rs
···
+
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<dyn std::error::Error + Send + Sync + 'static>),
+
#[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<DidDocument<'static>>,
+
),
+
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<OAuthAuthorizationServerMetadata<'static>, 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<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
+
Ok(resolve_authorization_server(self, issuer).await?)
+
}
+
async fn get_resource_server_metadata(
+
&self,
+
pds: &Url,
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, 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<T: HttpClient + ?Sized>(
+
client: &T,
+
server: &Url,
+
) -> Result<OAuthAuthorizationServerMetadata<'static>, 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::<OAuthAuthorizationServerMetadata>(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<T: HttpClient + ?Sized>(
+
client: &T,
+
server: &Url,
+
) -> Result<OAuthProtectedResourceMetadata<'static>, 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::<OAuthProtectedResourceMetadata>(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()))
+
}
+
}
+1969
crates/jacquard-oauth/src/scopes.rs
···
+
//! 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<MimePattern<'s>>,
+
}
+
+
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<RepoAction>,
+
}
+
+
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<RpcLexicon<'s>>,
+
/// Audiences (DIDs or wildcard)
+
pub aud: BTreeSet<RpcAudience<'s>>,
+
}
+
+
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<Vec<Self>, 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<Vec<Self>, ParseError> {
+
let all_scopes = Self::parse_multiple(s)?;
+
+
if all_scopes.is_empty() {
+
return Ok(Vec::new());
+
}
+
+
let mut result: Vec<Self> = 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<String> = 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<Self> {
+
scopes
+
.iter()
+
.filter(|s| *s != scope_to_remove)
+
.cloned()
+
.collect()
+
}
+
+
/// Parse a scope from a string
+
pub fn parse(s: &'s str) -> Result<Self, ParseError> {
+
// 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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, ParseError> {
+
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<Self, Self::Err> {
+
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<Scope<'static>, 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<SmolStr, Vec<CowStr<'static>>> {
+
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<Scope> = 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<Scope> = 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()));
+
}
+
}
+23
crates/jacquard-oauth/src/session.rs
···
+
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(),
+
}
+
}
+
}
+61
crates/jacquard-oauth/src/types.rs
···
+
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<AuthorizeOptionPrompt> 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<CowStr<'s>>,
+
pub scopes: Vec<Scope<'s>>,
+
pub prompt: Option<AuthorizeOptionPrompt>,
+
pub state: Option<CowStr<'s>>,
+
}
+
+
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<CowStr<'s>>,
+
pub iss: Option<CowStr<'s>>,
+
}
+55
crates/jacquard-oauth/src/types/client_metadata.rs
···
+
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<Url>,
+
pub redirect_uris: Vec<Url>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
#[serde(borrow)]
+
pub scope: Option<CowStr<'c>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub grant_types: Option<Vec<CowStr<'c>>>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub token_endpoint_auth_method: Option<CowStr<'c>>,
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-5.2
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub dpop_bound_access_tokens: Option<bool>,
+
// https://datatracker.ietf.org/doc/html/rfc7591#section-2
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub jwks_uri: Option<Url>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub jwks: Option<JwkSet>,
+
// 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<CowStr<'c>>,
+
}
+
+
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()),
+
}
+
}
+
}
+144
crates/jacquard-oauth/src/types/metadata.rs
···
+
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<CowStr<'s>>,
+
pub registration_endpoint: Option<CowStr<'s>>,
+
pub scopes_supported: Vec<CowStr<'s>>,
+
pub response_types_supported: Vec<CowStr<'s>>,
+
pub response_modes_supported: Option<Vec<CowStr<'s>>>,
+
pub grant_types_supported: Option<Vec<CowStr<'s>>>,
+
pub token_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
+
pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
+
pub service_documentation: Option<CowStr<'s>>,
+
pub ui_locales_supported: Option<Vec<Language>>,
+
pub op_policy_uri: Option<CowStr<'s>>,
+
pub op_tos_uri: Option<CowStr<'s>>,
+
pub revocation_endpoint: Option<CowStr<'s>>,
+
pub revocation_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
+
pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
+
pub introspection_endpoint: Option<CowStr<'s>>,
+
pub introspection_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>,
+
pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
+
pub code_challenge_methods_supported: Option<Vec<CowStr<'s>>>,
+
+
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
+
pub subject_types_supported: Option<Vec<CowStr<'s>>>,
+
pub require_request_uri_registration: Option<bool>,
+
+
// https://datatracker.ietf.org/doc/html/rfc9126#section-5
+
pub pushed_authorization_request_endpoint: Option<CowStr<'s>>,
+
pub require_pushed_authorization_requests: Option<bool>,
+
+
// https://datatracker.ietf.org/doc/html/rfc9207#section-3
+
pub authorization_response_iss_parameter_supported: Option<bool>,
+
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-5.1
+
pub dpop_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
+
+
// 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<bool>,
+
+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
+
pub protected_resources: Option<Vec<CowStr<'s>>>,
+
}
+
+
// 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<Vec<Url>>,
+
pub jwks_uri: Option<CowStr<'s>>,
+
pub scopes_supported: Vec<CowStr<'s>>,
+
pub bearer_methods_supported: Option<Vec<CowStr<'s>>>,
+
pub resource_signing_alg_values_supported: Option<Vec<CowStr<'s>>>,
+
pub resource_documentation: Option<CowStr<'s>>,
+
pub resource_policy_uri: Option<CowStr<'s>>,
+
pub resource_tos_uri: Option<CowStr<'s>>,
+
}
+
+
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(),
+
}
+
}
+
}
+134
crates/jacquard-oauth/src/types/request.rs
···
+
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<CowStr<'a>>,
+
// https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
+
pub response_mode: Option<AuthorizationResponseMode>,
+
// 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<CowStr<'a>>,
+
pub prompt: Option<CowStr<'a>>,
+
}
+
+
#[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<CowStr<'a>>,
+
}
+
+
// 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<String>,
+
}
+
+
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(),
+
}
+
}
+
}
+56
crates/jacquard-oauth/src/types/response.rs
···
+
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<u32>,
+
}
+
+
#[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<i64>,
+
pub refresh_token: Option<CowStr<'r>>,
+
pub scope: Option<CowStr<'r>>,
+
// 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<CowStr<'r>>,
+
}
+
+
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,
+
}
+
}
+
}
+36
crates/jacquard-oauth/src/types/token.rs
···
+
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<CowStr<'s>>,
+
+
pub refresh_token: Option<CowStr<'s>>,
+
pub access_token: CowStr<'s>,
+
pub token_type: OAuthTokenType,
+
+
pub expires_at: Option<Datetime>,
+
}
+
+
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()),
+
}
+
}
+
}
+9 -3
crates/jacquard/Cargo.toml
···
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"
···
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"] }
···
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 }
+90 -184
crates/jacquard/src/client.rs
···
//! 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;
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;
+
use p256::SecretKey;
-
async fn send_http(
-
&self,
-
request: Request<Vec<u8>>,
-
) -> core::result::Result<http::Response<Vec<u8>>, 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"))
-
}
-
}
-
-
/// 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<Vec<u8>>,
-
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, 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<reqwest::Client, MemoryTokenStore>);
+
/// Basic client wrapper: reqwest transport + in-memory session store.
+
pub struct BasicClient(AtClient<reqwest::Client, MemorySessionStore<Did<'static>, AuthSession>>);
impl BasicClient {
/// Construct a basic client with minimal inputs.
···
Self(AtClient::new(
reqwest::Client::new(),
base,
-
MemoryTokenStore::default(),
+
MemorySessionStore::default(),
))
}
/// Access the inner stateful client.
-
pub fn inner(&self) -> &AtClient<reqwest::Client, MemoryTokenStore> {
+
pub fn inner(
+
&self,
+
) -> &AtClient<reqwest::Client, MemorySessionStore<Did<'static>, AuthSession>> {
&self.0
}
/// Send an XRPC request.
-
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> Result<Response<R>> {
+
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> XrpcResult<Response<R>> {
self.0.send(req).await
}
···
&self,
req: R,
overrides: SendOverrides<'_>,
-
) -> Result<Response<R>> {
+
) -> XrpcResult<Response<R>> {
self.0.send_with(req, overrides).await
}
/// Get current session.
-
pub async fn session(&self) -> Option<Session> {
-
self.0.session().await
+
pub async fn session(&self, did: &Did<'static>) -> Option<AuthSession> {
+
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
}
···
}
}
-
/// 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: <https://atproto.com/specs/xrpc#service-proxying>
-
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<Header> 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<R: XrpcRequest>(
-
base: &Url,
-
req: &R,
-
opts: &xrpc_call::CallOptions<'_>,
-
) -> core::result::Result<Request<Vec<u8>>, 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::<Vec<_>>()
-
.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
···
pub handle: Handle<'static>,
}
-
impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>> for Session {
+
impl From<jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>>
+
for AtpSession
+
{
fn from(
output: jacquard_api::com_atproto::server::create_session::CreateSessionOutput<'_>,
) -> Self {
···
}
impl From<jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>>
-
for Session
+
for AtpSession
{
fn from(
output: jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput<'_>,
···
}
}
}
+
+
#[derive(Debug, Clone)]
+
pub enum AuthSession {
+
AppPassword(AtpSession),
+
OAuth(jacquard_oauth::session::OauthSession<'static>),
+
}
+
+
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<AtpSession> for AuthSession {
+
fn from(session: AtpSession) -> Self {
+
AuthSession::AppPassword(session)
+
}
+
}
+
+
impl From<jacquard_oauth::session::OauthSession<'static>> for AuthSession {
+
fn from(session: jacquard_oauth::session::OauthSession<'static>) -> Self {
+
AuthSession::OAuth(session)
+
}
+
}
+99 -53
crates/jacquard/src/client/at_client.rs
···
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<Did<'a>>,
/// Optional base URI override for this call.
pub base_uri: Option<Url>,
/// Per-request options such as auth, proxy, labelers, extra headers.
···
/// 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,
···
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;
···
/// Ok(())
/// }
/// ```
-
pub struct AtClient<C: HttpClient, S: TokenStore> {
+
pub struct AtClient<C: HttpClient, S> {
transport: C,
base: Url,
tokens: S,
-
refresh_lock: tokio::sync::Mutex<()>,
+
refresh_lock: tokio::sync::Mutex<Option<Did<'static>>>,
}
-
impl<C: HttpClient, S: TokenStore> AtClient<C, S> {
+
impl<C: HttpClient, S: SessionStore<Did<'static>, AuthSession>> AtClient<C, S> {
/// 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),
}
}
···
}
/// Get the current session, if any.
-
pub async fn session(&self) -> Option<Session> {
-
self.tokens.get().await
+
pub async fn session(&self, did: &Did<'static>) -> Option<AuthSession> {
+
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<R: XrpcRequest + Send>(&self, req: R) -> super_mod::Result<Response<R>> {
+
pub async fn send<R: XrpcRequest + Send>(&self, req: R) -> XrpcResult<Response<R>> {
self.send_with(req, SendOverrides::new()).await
}
···
&self,
req: R,
mut overrides: SendOverrides<'_>,
-
) -> super_mod::Result<Response<R>> {
+
) -> XrpcResult<Response<R>> {
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),
}
···
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),
}
···
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 {
+21 -21
crates/jacquard/src/client/error.rs crates/jacquard-common/src/error.rs
···
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 {
···
Other(Box<dyn std::error::Error + Send + Sync>),
}
-
// Re-export EncodeError from common
-
pub use jacquard_common::types::xrpc::EncodeError;
-
/// Response deserialization errors
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum DecodeError {
···
CborRemote(
#[from]
#[source]
-
serde_ipld_dagcbor::DecodeError<reqwest::Error>,
+
serde_ipld_dagcbor::DecodeError<HttpError>,
),
}
···
}
}
+
/// Result type for client operations
+
pub type XrpcResult<T> = std::result::Result<T, ClientError>;
+
+
#[cfg(feature = "reqwest-client")]
+
impl From<reqwest::Error> 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 {
···
#[error("Authentication error: {0:?}")]
Other(http::HeaderValue),
}
-
-
/// Result type for client operations
-
pub type Result<T> = std::result::Result<T, ClientError>;
-
-
impl From<reqwest::Error> 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))
-
}
-
}
-
}
-198
crates/jacquard/src/client/response.rs
···
-
//! 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<R: XrpcRequest> {
-
buffer: Bytes,
-
status: StatusCode,
-
_marker: PhantomData<R>,
-
}
-
-
impl<R: XrpcRequest> Response<R> {
-
/// 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<R::Output<'_>, XrpcError<R::Err<'_>>> {
-
// Use a helper to make lifetime inference work
-
fn parse_output<'b, R: XrpcRequest>(
-
buffer: &'b [u8],
-
) -> Result<R::Output<'b>, serde_json::Error> {
-
serde_json::from_slice(buffer)
-
}
-
-
fn parse_error<'b, R: XrpcRequest>(
-
buffer: &'b [u8],
-
) -> Result<R::Err<'b>, serde_json::Error> {
-
serde_json::from_slice(buffer)
-
}
-
-
// 200: parse as output
-
if self.status.is_success() {
-
match parse_output::<R>(&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::<R>(&self.buffer) {
-
Ok(error) => Err(XrpcError::Xrpc(error)),
-
Err(_) => {
-
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
-
match serde_json::from_slice::<GenericXrpcError>(&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::<GenericXrpcError>(&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<R::Output<'static>, XrpcError<R::Err<'static>>>
-
where
-
for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>,
-
for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>,
-
{
-
// Use a helper to make lifetime inference work
-
fn parse_output<'b, R: XrpcRequest>(
-
buffer: &'b [u8],
-
) -> Result<R::Output<'b>, serde_json::Error> {
-
serde_json::from_slice(buffer)
-
}
-
-
fn parse_error<'b, R: XrpcRequest>(
-
buffer: &'b [u8],
-
) -> Result<R::Err<'b>, serde_json::Error> {
-
serde_json::from_slice(buffer)
-
}
-
-
// 200: parse as output
-
if self.status.is_success() {
-
match parse_output::<R>(&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::<R>(&self.buffer) {
-
Ok(error) => Err(XrpcError::Xrpc(error.into_static())),
-
Err(_) => {
-
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
-
match serde_json::from_slice::<GenericXrpcError>(&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::<GenericXrpcError>(&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<SmolStr>,
-
}
-
-
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<E: std::error::Error + IntoStatic> {
-
/// 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),
-
}
+30 -57
crates/jacquard/src/client/token.rs
···
+
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<Session>;
-
/// 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<tokio::sync::RwLock<Option<Session>>>);
-
-
#[async_trait]
-
impl TokenStore for MemoryTokenStore {
-
async fn get(&self) -> Option<Session> {
-
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.
///
···
}
#[async_trait]
-
impl TokenStore for FileTokenStore {
-
async fn get(&self) -> Option<Session> {
-
let data = tokio::fs::read(&self.path).await.ok()?;
+
impl SessionStore<Did<'static>, AtpSession> for FileTokenStore {
+
async fn get(&self, key: &Did<'static>) -> Option<AtpSession> {
+
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(),
···
})
}
-
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)),
}
}
}
-154
crates/jacquard/src/client/xrpc_call.rs
···
-
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<AuthorizationToken<'a>>,
-
/// `atproto-proxy` header value.
-
pub atproto_proxy: Option<CowStr<'a>>,
-
/// `atproto-accept-labelers` header values.
-
pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
-
/// 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<T: HttpClient> 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<CowStr<'a>>) -> 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<R: XrpcRequest + Send>(self, request: R) -> super_mod::Result<Response<R>> {
-
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))
-
}
-
}
-3
crates/jacquard/src/identity/mod.rs
···
-
//! Identity resolution utilities: DID and handle resolution, DID document fetch,
-
//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
-
pub mod resolver;
+86 -388
crates/jacquard/src/identity/resolver.rs crates/jacquard/src/identity.rs
···
+
//! 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):
···
//! 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<Did<'static>>,
-
}
-
-
impl DidDocResponse {
-
/// Parse as borrowed DidDocument<'_>
-
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
-
if self.status.is_success() {
-
if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
-
Ok(doc)
-
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&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<DidDocument<'b>, 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<DidDocument<'static>, IdentityError> {
-
if self.status.is_success() {
-
if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
-
Ok(doc.into_static())
-
} else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&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.\<handle\>
-
DnsTxt,
-
/// HTTPS GET https://\<handle\>/.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<Url>,
-
/// Order of attempts for handle → DID resolution
-
pub handle_order: Vec<HandleStep>,
-
/// Order of attempts for DID → Doc resolution
-
pub did_order: Vec<DidStep>,
-
/// 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.<handle>`) 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<Did<'static>, IdentityError>;
-
-
/// Resolve DID document
-
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
-
-
/// Resolve the DID document and return an owned version
-
async fn resolve_did_doc_owned(
-
&self,
-
did: &Did<'_>,
-
) -> Result<DidDocument<'static>, 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<Url, IdentityError> {
-
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,
···
opts,
#[cfg(feature = "dns")]
dns: None,
+
}
+
}
+
+
#[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(),
+
)),
}
}
···
}
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<String, IdentityError> {
-
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(),
+
))
}
}
···
}
}
+
impl HttpClient for DefaultResolver {
+
async fn send_http(
+
&self,
+
request: http::Request<Vec<u8>>,
+
) -> core::result::Result<http::Response<Vec<u8>>, 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 {
···
}
}
-
/// 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)]
···
}
#[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());
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
···
}
}
}
-
-
/// 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
-
}
+14 -11
crates/jacquard/src/lib.rs
···
//! # 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)]
···
//! 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()
···
//! 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(
···
//! }
//! ```
//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
-
//! `TokenStore` implementation. It automatically sets Authorization and can
+
//! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can
//! auto-refresh a session when expired, retrying once.
//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
-
//! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor.
+
//! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` 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;
···
//! #[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")))
···
//! ```
//!
//! Token storage:
-
//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs.
-
//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk.
+
//! - Use `MemorySessionStore<AuthSession>` 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};
···
/// 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
+5 -4
crates/jacquard/src/main.rs
···
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;
···
let client = BasicClient::new(pds_url);
// Create session
-
let session = Session::from(
+
let session = AtpSession::from(
client
.send(
CreateSession::new()
···
);
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...");