From bf75a708da02e443cefe7d104ce63c59ef883c6f Mon Sep 17 00:00:00 2001 From: Yoshihiro Sugi Date: Wed, 23 Oct 2024 22:47:52 +0900 Subject: [PATCH] feat: OAuth (#219) * WIP: Add OAuth resolvers * WIP: Add DpopClient * Add PKSC, exchange code * Update http_client, use generics * Update config for client and resolvers * Unify resolver traits * Update * Implement client authentication * Remove k256, use jose methods for dpop request * Support wasm32 * Update resolvers, add examples for oauth * Update resolver config * Add DohDnsTxtResolver as optional * Update store * Move to atrium-identity, add cached_resolver * Add workflows * Add wasm32 cached_resolver * Add wasm workflows * identity: Add throttled_resolver * Update atrium-identity * Update oauth-client * Update resolvers * Fix SimpleStore trait --- .github/workflows/identity.yml | 23 + .github/workflows/oauth-client.yml | 23 + .github/workflows/wasm.yml | 1 + Cargo.lock | 1045 +++++++++++++---- Cargo.toml | 21 +- atrium-api/src/agent.rs | 1 + atrium-api/src/agent/inner.rs | 28 +- atrium-api/src/did_doc.rs | 42 +- atrium-oauth/identity/Cargo.toml | 48 + atrium-oauth/identity/src/did.rs | 11 + .../identity/src/did/common_resolver.rs | 54 + atrium-oauth/identity/src/did/plc_resolver.rs | 54 + atrium-oauth/identity/src/did/web_resolver.rs | 60 + atrium-oauth/identity/src/error.rs | 38 + atrium-oauth/identity/src/handle.rs | 17 + .../identity/src/handle/appview_resolver.rs | 58 + .../identity/src/handle/atproto_resolver.rs | 58 + .../identity/src/handle/dns_resolver.rs | 60 + .../src/handle/doh_dns_txt_resolver.rs | 70 ++ .../src/handle/well_known_resolver.rs | 50 + .../identity/src/identity_resolver.rs | 64 + atrium-oauth/identity/src/lib.rs | 8 + atrium-oauth/identity/src/resolver.rs | 247 ++++ .../identity/src/resolver/cache_impl.rs | 9 + .../identity/src/resolver/cache_impl/moka.rs | 35 + .../identity/src/resolver/cache_impl/wasm.rs | 95 ++ .../identity/src/resolver/cached_resolver.rs | 61 + .../src/resolver/throttled_resolver.rs | 59 + atrium-oauth/oauth-client/Cargo.toml | 42 + atrium-oauth/oauth-client/examples/main.rs | 84 ++ atrium-oauth/oauth-client/src/atproto.rs | 162 +++ atrium-oauth/oauth-client/src/constants.rs | 1 + atrium-oauth/oauth-client/src/error.rs | 21 + atrium-oauth/oauth-client/src/http_client.rs | 3 + .../oauth-client/src/http_client/default.rs | 29 + .../oauth-client/src/http_client/dpop.rs | 159 +++ atrium-oauth/oauth-client/src/jose.rs | 28 + atrium-oauth/oauth-client/src/jose/jws.rs | 64 + atrium-oauth/oauth-client/src/jose/jwt.rs | 99 ++ atrium-oauth/oauth-client/src/jose/signing.rs | 28 + atrium-oauth/oauth-client/src/keyset.rs | 125 ++ atrium-oauth/oauth-client/src/lib.rs | 25 + atrium-oauth/oauth-client/src/oauth_client.rs | 262 +++++ atrium-oauth/oauth-client/src/resolver.rs | 197 ++++ .../oauth_authorization_server_resolver.rs | 50 + .../oauth_protected_resource_resolver.rs | 50 + atrium-oauth/oauth-client/src/server_agent.rs | 277 +++++ atrium-oauth/oauth-client/src/store.rs | 20 + atrium-oauth/oauth-client/src/store/memory.rs | 45 + atrium-oauth/oauth-client/src/store/state.rs | 17 + atrium-oauth/oauth-client/src/types.rs | 54 + .../oauth-client/src/types/client_metadata.rs | 37 + .../oauth-client/src/types/metadata.rs | 64 + .../oauth-client/src/types/request.rs | 62 + .../oauth-client/src/types/response.rs | 27 + atrium-oauth/oauth-client/src/types/token.rs | 17 + atrium-oauth/oauth-client/src/utils.rs | 62 + bsky-sdk/Cargo.toml | 2 +- 58 files changed, 4168 insertions(+), 285 deletions(-) create mode 100644 .github/workflows/identity.yml create mode 100644 .github/workflows/oauth-client.yml create mode 100644 atrium-oauth/identity/Cargo.toml create mode 100644 atrium-oauth/identity/src/did.rs create mode 100644 atrium-oauth/identity/src/did/common_resolver.rs create mode 100644 atrium-oauth/identity/src/did/plc_resolver.rs create mode 100644 atrium-oauth/identity/src/did/web_resolver.rs create mode 100644 atrium-oauth/identity/src/error.rs create mode 100644 atrium-oauth/identity/src/handle.rs create mode 100644 atrium-oauth/identity/src/handle/appview_resolver.rs create mode 100644 atrium-oauth/identity/src/handle/atproto_resolver.rs create mode 100644 atrium-oauth/identity/src/handle/dns_resolver.rs create mode 100644 atrium-oauth/identity/src/handle/doh_dns_txt_resolver.rs create mode 100644 atrium-oauth/identity/src/handle/well_known_resolver.rs create mode 100644 atrium-oauth/identity/src/identity_resolver.rs create mode 100644 atrium-oauth/identity/src/lib.rs create mode 100644 atrium-oauth/identity/src/resolver.rs create mode 100644 atrium-oauth/identity/src/resolver/cache_impl.rs create mode 100644 atrium-oauth/identity/src/resolver/cache_impl/moka.rs create mode 100644 atrium-oauth/identity/src/resolver/cache_impl/wasm.rs create mode 100644 atrium-oauth/identity/src/resolver/cached_resolver.rs create mode 100644 atrium-oauth/identity/src/resolver/throttled_resolver.rs create mode 100644 atrium-oauth/oauth-client/Cargo.toml create mode 100644 atrium-oauth/oauth-client/examples/main.rs create mode 100644 atrium-oauth/oauth-client/src/atproto.rs create mode 100644 atrium-oauth/oauth-client/src/constants.rs create mode 100644 atrium-oauth/oauth-client/src/error.rs create mode 100644 atrium-oauth/oauth-client/src/http_client.rs create mode 100644 atrium-oauth/oauth-client/src/http_client/default.rs create mode 100644 atrium-oauth/oauth-client/src/http_client/dpop.rs create mode 100644 atrium-oauth/oauth-client/src/jose.rs create mode 100644 atrium-oauth/oauth-client/src/jose/jws.rs create mode 100644 atrium-oauth/oauth-client/src/jose/jwt.rs create mode 100644 atrium-oauth/oauth-client/src/jose/signing.rs create mode 100644 atrium-oauth/oauth-client/src/keyset.rs create mode 100644 atrium-oauth/oauth-client/src/lib.rs create mode 100644 atrium-oauth/oauth-client/src/oauth_client.rs create mode 100644 atrium-oauth/oauth-client/src/resolver.rs create mode 100644 atrium-oauth/oauth-client/src/resolver/oauth_authorization_server_resolver.rs create mode 100644 atrium-oauth/oauth-client/src/resolver/oauth_protected_resource_resolver.rs create mode 100644 atrium-oauth/oauth-client/src/server_agent.rs create mode 100644 atrium-oauth/oauth-client/src/store.rs create mode 100644 atrium-oauth/oauth-client/src/store/memory.rs create mode 100644 atrium-oauth/oauth-client/src/store/state.rs create mode 100644 atrium-oauth/oauth-client/src/types.rs create mode 100644 atrium-oauth/oauth-client/src/types/client_metadata.rs create mode 100644 atrium-oauth/oauth-client/src/types/metadata.rs create mode 100644 atrium-oauth/oauth-client/src/types/request.rs create mode 100644 atrium-oauth/oauth-client/src/types/response.rs create mode 100644 atrium-oauth/oauth-client/src/types/token.rs create mode 100644 atrium-oauth/oauth-client/src/utils.rs diff --git a/.github/workflows/identity.yml b/.github/workflows/identity.yml new file mode 100644 index 00000000..ba699c10 --- /dev/null +++ b/.github/workflows/identity.yml @@ -0,0 +1,23 @@ +name: Identity + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build -p atrium-identity --verbose + - name: Run tests + run: | + cargo test -p atrium-identity --lib + cargo test -p atrium-identity --lib --no-default-features --features doh-handle-resolver diff --git a/.github/workflows/oauth-client.yml b/.github/workflows/oauth-client.yml new file mode 100644 index 00000000..ee7da138 --- /dev/null +++ b/.github/workflows/oauth-client.yml @@ -0,0 +1,23 @@ +name: OAuth Client + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build -p atrium-oauth-client --verbose + - name: Run tests + run: | + cargo test -p atrium-oauth-client --lib + cargo test -p atrium-oauth-client --lib --no-default-features diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index c5e894bb..472e97de 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -66,3 +66,4 @@ jobs: - run: wasm-pack test --node atrium-api - run: wasm-pack test --node atrium-xrpc - run: wasm-pack test --node atrium-xrpc-client + - run: wasm-pack test --node atrium-oauth/identity diff --git a/Cargo.lock b/Cargo.lock index de936709..fb95f17e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,25 +4,31 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" dependencies = [ "gimli", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -32,6 +38,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -49,9 +61,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.14" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ "anstyle", "anstyle-parse", @@ -64,33 +76,33 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", "windows-sys 0.52.0", @@ -98,9 +110,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "assert-json-diff" @@ -119,7 +131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] @@ -136,6 +148,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "atrium-api" version = "0.24.6" @@ -174,6 +214,54 @@ dependencies = [ "thiserror", ] +[[package]] +name = "atrium-identity" +version = "0.1.0" +dependencies = [ + "atrium-api", + "atrium-xrpc", + "dashmap", + "futures", + "gloo-timers", + "hickory-proto", + "lru", + "moka", + "serde", + "serde_html_form", + "serde_json", + "thiserror", + "tokio", + "trait-variant", + "wasm-bindgen-test", + "web-time", +] + +[[package]] +name = "atrium-oauth-client" +version = "0.1.0" +dependencies = [ + "atrium-api", + "atrium-identity", + "atrium-xrpc", + "base64", + "chrono", + "ecdsa", + "elliptic-curve", + "hickory-resolver", + "jose-jwa", + "jose-jwk", + "p256", + "rand", + "reqwest", + "serde", + "serde_html_form", + "serde_json", + "sha2", + "thiserror", + "tokio", + "trait-variant", +] + [[package]] name = "atrium-xrpc" version = "0.11.5" @@ -212,17 +300,17 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", - "miniz_oxide 0.7.4", + "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -310,11 +398,17 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" -version = "1.6.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "castaway" @@ -333,9 +427,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.5" +version = "1.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324c74f2155653c90b04f25b2a47a8a631360cb908f92a772695f430c7e31052" +checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +dependencies = [ + "shlex", +] [[package]] name = "cfg-if" @@ -400,10 +497,10 @@ version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -414,9 +511,9 @@ checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "colorchoice" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "colored" @@ -465,9 +562,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core2" @@ -480,9 +577,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -496,6 +593,24 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -541,9 +656,9 @@ dependencies = [ [[package]] name = "curl-sys" -version = "0.4.73+curl-8.8.0" +version = "0.4.75+curl-8.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "450ab250ecf17227c39afb9a2dd9261dc0035cb80f2612472fc0c4aac2dcb84d" +checksum = "2a4fd752d337342e4314717c0d9b6586b059a120c80029ebe4d49b11fec7875e" dependencies = [ "cc", "libc", @@ -555,6 +670,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -666,6 +795,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -688,6 +829,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener 5.3.1", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -699,9 +861,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "ff" @@ -720,7 +882,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -804,6 +966,17 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -823,10 +996,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", + "futures-macro", "futures-sink", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -853,9 +1028,21 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" + +[[package]] +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] [[package]] name = "group" @@ -870,16 +1057,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.26" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", - "http 0.2.12", + "http 1.1.0", "indexmap", "slab", "tokio", @@ -892,6 +1079,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -899,6 +1090,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -911,6 +1108,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07698b8420e2f0d6447a436ba999ec85d8fbf2a398bbd737b82cac4a2e96e512" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.4.0", + "ipnet", + "once_cell", + "rand", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28757f23aa75c98f254cf0405e6d8c25b831b32921b050a66692427679b1f243" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -920,6 +1162,17 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "http" version = "0.2.12" @@ -942,17 +1195,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -972,7 +1214,7 @@ dependencies = [ "bytes", "futures-util", "http 1.1.0", - "http-body 1.0.1", + "http-body", "pin-project-lite", ] @@ -988,29 +1230,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hyper" -version = "0.14.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.4.1" @@ -1020,9 +1239,11 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http 1.1.0", - "http-body 1.0.1", + "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1032,13 +1253,13 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http 1.1.0", - "hyper 1.4.1", + "hyper", "hyper-util", "rustls", "rustls-pki-types", @@ -1056,7 +1277,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.4.1", + "hyper", "hyper-util", "native-tls", "tokio", @@ -1066,16 +1287,16 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" +checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" dependencies = [ "bytes", "futures-channel", "futures-util", "http 1.1.0", - "http-body 1.0.1", - "hyper 1.4.1", + "http-body", + "hyper", "pin-project-lite", "socket2", "tokio", @@ -1086,9 +1307,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1107,6 +1328,16 @@ dependencies = [ "cc", ] +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -1119,9 +1350,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", "hashbrown", @@ -1136,6 +1367,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + [[package]] name = "ipld-core" version = "0.4.1" @@ -1149,15 +1392,15 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" [[package]] name = "is_terminal_polyfill" -version = "1.70.0" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "isahc" @@ -1171,7 +1414,7 @@ dependencies = [ "curl", "curl-sys", "encoding_rs", - "event-listener", + "event-listener 2.5.3", "futures-lite", "http 0.2.12", "log", @@ -1192,11 +1435,45 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[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", + "serde", + "zeroize", +] + [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -1230,9 +1507,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libnghttp2-sys" @@ -1256,9 +1533,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.18" +version = "1.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" dependencies = [ "cc", "libc", @@ -1266,6 +1543,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1288,6 +1571,30 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "memchr" version = "2.7.4" @@ -1301,12 +1608,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.7.4" +name = "minicov" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" dependencies = [ - "adler", + "cc", + "walkdir", ] [[package]] @@ -1320,25 +1628,31 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi", "libc", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "mockito" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6e023aa5bdf392aa06c78e4a4e6d498baab5138d0c993503350ebbc37bf1e" +checksum = "09b34bd91b9e5c5b06338d392463e1318d683cf82ec3d3af4014609be6e2108d" dependencies = [ "assert-json-diff", + "bytes", "colored", - "futures-core", - "hyper 0.14.30", + "futures-util", + "http 1.1.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", "log", "rand", "regex", @@ -1348,6 +1662,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +dependencies = [ + "async-lock", + "async-trait", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "event-listener 5.3.1", + "futures-util", + "once_cell", + "parking_lot", + "quanta", + "rustc_version", + "smallvec", + "tagptr", + "thiserror", + "triomphe", + "uuid", +] + [[package]] name = "multibase" version = "0.9.1" @@ -1396,21 +1734,11 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" -version = "0.36.1" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" dependencies = [ "memchr", ] @@ -1444,7 +1772,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -1485,9 +1813,9 @@ dependencies = [ [[package]] name = "parking" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -1535,7 +1863,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -1584,9 +1912,12 @@ dependencies = [ [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] [[package]] name = "primeorder" @@ -1608,9 +1939,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.52" +version = "2.1.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9175d202d68cb9075415cd2d00062282941010af4174edb7a82d20514b89bc36" +checksum = "ce9398ad066421139b2e3afe16ea46772ffda30bd9ba57554dc035df5e26edc8" dependencies = [ "psl-types", ] @@ -1621,18 +1952,40 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad" +checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" dependencies = [ "bytes", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 1.1.0", + "rustc-hash", "rustls", + "socket2", "thiserror", "tokio", "tracing", @@ -1647,7 +2000,7 @@ dependencies = [ "bytes", "rand", "ring", - "rustc-hash 2.0.0", + "rustc-hash", "rustls", "slab", "thiserror", @@ -1657,22 +2010,22 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46" +checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" dependencies = [ "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -1707,20 +2060,29 @@ dependencies = [ "getrandom", ] +[[package]] +name = "raw-cpuid" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9ee317cfe3fbd54b36a511efc1edd42e216903c9cd575e686dd68a2ba90d8d" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "redox_users" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", @@ -1729,9 +2091,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.5" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", @@ -1758,9 +2120,9 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "reqwest" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" dependencies = [ "async-compression", "base64", @@ -1768,9 +2130,9 @@ dependencies = [ "futures-core", "futures-util", "http 1.1.0", - "http-body 1.0.1", + "http-body", "http-body-util", - "hyper 1.4.1", + "hyper", "hyper-rustls", "hyper-tls", "hyper-util", @@ -1800,7 +2162,17 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "webpki-roots", - "winreg", + "windows-registry", +] + +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error", ] [[package]] @@ -1836,21 +2208,24 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] -name = "rustc-hash" -version = "2.0.0" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", @@ -1861,9 +2236,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.11" +version = "0.23.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4828ea528154ae444e5a642dbb7d5623354030dc9822b83fd9bb79683c7399d0" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "once_cell", "ring", @@ -1875,9 +2250,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" dependencies = [ "base64", "rustls-pki-types", @@ -1885,15 +2260,15 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" -version = "0.102.5" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", @@ -1906,13 +2281,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1964,11 +2348,17 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" -version = "1.0.204" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] @@ -1984,13 +2374,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -2020,20 +2410,21 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" dependencies = [ "serde", ] @@ -2061,6 +2452,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2082,9 +2479,9 @@ dependencies = [ [[package]] name = "similar" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" [[package]] name = "slab" @@ -2163,9 +2560,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.71" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -2177,17 +2574,27 @@ name = "sync_wrapper" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.10.1" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", - "fastrand 2.1.0", + "fastrand 2.1.1", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2207,7 +2614,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -2227,32 +2634,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.1" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -2278,9 +2684,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", @@ -2291,9 +2697,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.15" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -2303,18 +2709,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.16" +version = "0.22.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" +checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf" dependencies = [ "indexmap", "serde", @@ -2340,15 +2746,15 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -2370,7 +2776,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] @@ -2400,9 +2806,15 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] +[[package]] +name = "triomphe" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" + [[package]] name = "try-lock" version = "0.2.5" @@ -2423,24 +2835,24 @@ checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unsigned-varint" @@ -2467,7 +2879,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -2477,6 +2889,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2485,9 +2906,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "waker-fn" @@ -2495,6 +2916,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2512,34 +2943,35 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" dependencies = [ "cfg-if", "js-sys", @@ -2549,9 +2981,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2559,31 +2991,32 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "wasm-bindgen-test" -version = "0.3.42" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bf62a58e0780af3e852044583deee40983e5886da43a271dd772379987667b" +checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9" dependencies = [ "console_error_panic_hook", "js-sys", + "minicov", "scoped-tls", "wasm-bindgen", "wasm-bindgen-futures", @@ -2592,20 +3025,30 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.42" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f89739351a2e03cb94beb799d47fb2cac01759b40ec441f7de39b00cbf7ef0" +checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" dependencies = [ "proc-macro2", "quote", - "syn 2.0.71", + "syn 2.0.77", ] [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -2613,13 +3056,50 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.3" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" @@ -2629,6 +3109,36 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2647,6 +3157,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -2770,25 +3289,49 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.13" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" dependencies = [ "memchr", ] [[package]] name = "winreg" -version = "0.52.0" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", "windows-sys 0.48.0", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "serde", +] diff --git a/Cargo.toml b/Cargo.toml index 759b59e2..a33c51ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "atrium-crypto", "atrium-xrpc", "atrium-xrpc-client", + "atrium-oauth/identity", + "atrium-oauth/oauth-client", "bsky-cli", "bsky-sdk", ] @@ -23,7 +25,8 @@ keywords = ["atproto", "bluesky"] [workspace.dependencies] # Intra-workspace dependencies -atrium-api = { version = "0.24.6", path = "atrium-api" } +atrium-api = { version = "0.24.6", path = "atrium-api", default-features = false } +atrium-identity = { version = "0.1.0", path = "atrium-oauth/identity" } atrium-xrpc = { version = "0.11.5", path = "atrium-xrpc" } atrium-xrpc-client = { version = "0.5.8", path = "atrium-xrpc-client" } bsky-sdk = { version = "0.1.11", path = "bsky-sdk" } @@ -33,6 +36,7 @@ ipld-core = { version = "0.4.1", default-features = false, features = ["std"] } serde_ipld_dagcbor = { version = "0.6.0", default-features = false, features = ["std"] } # Parsing and validation +base64 = "0.22.1" chrono = "0.4" hex = "0.4.3" langtag = "0.3" @@ -40,19 +44,28 @@ multibase = "0.9.1" regex = "1" serde = "1.0.202" serde_bytes = "0.11.9" -serde_json = "1.0.117" serde_html_form = "0.2.6" +serde_json = "1.0.125" # Cryptography ecdsa = "0.16.9" +elliptic-curve = "0.13.6" +jose-jwa = "0.1.2" +jose-jwk = { version = "0.1.2", default-features = false } k256 = { version = "0.13.3", default-features = false } p256 = { version = "0.13.2", default-features = false } rand = "0.8.5" +sha2 = "0.10.8" # Networking +dashmap = "6.1.0" futures = { version = "0.3.30", default-features = false, features = ["alloc"] } +hickory-proto = { version = "0.24.1", default-features = false } +hickory-resolver = "0.24.1" http = "1.1.0" -tokio = { version = "1.37", default-features = false } +lru = "0.12.4" +moka = "0.12.8" +tokio = { version = "1.39", default-features = false } # HTTP client integrations isahc = "1.7.2" @@ -67,10 +80,12 @@ clap = { version = "~4.4.18", features = ["derive"] } dirs = "5.0.1" # Testing +gloo-timers = { version = "0.3.0", features = ["futures"] } mockito = "1.4" # WebAssembly wasm-bindgen-test = "0.3.41" +web-time = "1.1.0" bumpalo = "~3.14.0" # Code generation diff --git a/atrium-api/src/agent.rs b/atrium-api/src/agent.rs index 298284e4..c61296a7 100644 --- a/atrium-api/src/agent.rs +++ b/atrium-api/src/agent.rs @@ -538,6 +538,7 @@ mod tests { async fn test_login_with_diddoc() { let session_data = session_data(); let did_doc = DidDocument { + context: None, id: "did:plc:ewvi7nxzyoun6zhxrhs64oiz".into(), also_known_as: Some(vec!["at://atproto.com".into()]), verification_method: Some(vec![VerificationMethod { diff --git a/atrium-api/src/agent/inner.rs b/atrium-api/src/agent/inner.rs index 2c7ee72e..da1e9876 100644 --- a/atrium-api/src/agent/inner.rs +++ b/atrium-api/src/agent/inner.rs @@ -4,7 +4,7 @@ use crate::types::string::Did; use crate::types::TryFromUnknown; use atrium_xrpc::error::{Error, Result, XrpcErrorKind}; use atrium_xrpc::{HttpClient, OutputDataOrBytes, XrpcClient, XrpcRequest}; -use http::{Method, Request, Response, Uri}; +use http::{Method, Request, Response}; use serde::{de::DeserializeOwned, Serialize}; use std::fmt::Debug; use std::sync::{Arc, RwLock}; @@ -282,34 +282,10 @@ impl Store { self.endpoint.read().expect("failed to read endpoint").clone() } pub fn update_endpoint(&self, did_doc: &DidDocument) { - if let Some(endpoint) = Self::get_pds_endpoint(did_doc) { + if let Some(endpoint) = did_doc.get_pds_endpoint() { *self.endpoint.write().expect("failed to write endpoint") = endpoint; } } - fn get_pds_endpoint(did_doc: &DidDocument) -> Option { - Self::get_service_endpoint(did_doc, ("#atproto_pds", "AtprotoPersonalDataServer")) - } - fn get_service_endpoint(did_doc: &DidDocument, (id, r#type): (&str, &str)) -> Option { - let full_id = did_doc.id.clone() + id; - if let Some(services) = &did_doc.service { - let service = - services.iter().find(|service| service.id == id || service.id == full_id)?; - if service.r#type == r#type && Self::validate_url(&service.service_endpoint) { - return Some(service.service_endpoint.clone()); - } - } - None - } - fn validate_url(url: &str) -> bool { - if let Ok(uri) = url.parse::() { - if let Some(scheme) = uri.scheme() { - if (scheme == "https" || scheme == "http") && uri.host().is_some() { - return true; - } - } - } - false - } } impl SessionStore for Store diff --git a/atrium-api/src/did_doc.rs b/atrium-api/src/did_doc.rs index 23e4a1ba..23408add 100644 --- a/atrium-api/src/did_doc.rs +++ b/atrium-api/src/did_doc.rs @@ -1,7 +1,13 @@ //! Definitions for DID document types. -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +use http::{uri::Scheme, Uri}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DidDocument { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "@context")] + pub context: Option>, pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub also_known_as: Option>, @@ -11,7 +17,7 @@ pub struct DidDocument { pub service: Option>, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VerificationMethod { pub id: String, @@ -21,11 +27,39 @@ pub struct VerificationMethod { pub public_key_multibase: Option, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Service { pub id: String, pub r#type: String, - // TODO: enum? pub service_endpoint: String, } + +impl DidDocument { + pub fn get_pds_endpoint(&self) -> Option { + self.get_service_endpoint("#atproto_pds", "AtprotoPersonalDataServer") + } + fn get_service_endpoint(&self, id: &str, r#type: &str) -> Option { + let full_id = self.id.to_string() + id; + if let Some(services) = &self.service { + let service_endpoint = services + .iter() + .find(|service| { + (service.id == id || service.id == full_id) && service.r#type == r#type + }) + .map(|service| service.service_endpoint.clone())?; + return Some(service_endpoint).filter(|s| Self::validate_url(s)); + } + None + } + fn validate_url(url: &str) -> bool { + url.parse::() + .map(|uri| match uri.scheme() { + Some(scheme) if (scheme == &Scheme::HTTP || scheme == &Scheme::HTTPS) => { + uri.host().is_some() + } + _ => false, + }) + .unwrap_or_default() + } +} diff --git a/atrium-oauth/identity/Cargo.toml b/atrium-oauth/identity/Cargo.toml new file mode 100644 index 00000000..55a0b15b --- /dev/null +++ b/atrium-oauth/identity/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "atrium-identity" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "Resolver library for decentralized identities in atproto using DIDs and handles" +documentation = "https://docs.rs/atrium-identity" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords = ["atproto", "bluesky", "identity"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +atrium-api = { workspace = true, default-features = false } +atrium-xrpc.workspace = true +dashmap.workspace = true +hickory-proto = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +serde_html_form.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, default-features = false, features = ["sync"] } +trait-variant.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +moka = { workspace = true, features = ["future"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +lru.workspace = true +web-time.workspace = true + +[dev-dependencies] +futures.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +gloo-timers.workspace = true +tokio = { workspace = true, features = ["time"] } +wasm-bindgen-test.workspace = true + +[features] +default = [] +doh-handle-resolver = ["dep:hickory-proto"] diff --git a/atrium-oauth/identity/src/did.rs b/atrium-oauth/identity/src/did.rs new file mode 100644 index 00000000..79621721 --- /dev/null +++ b/atrium-oauth/identity/src/did.rs @@ -0,0 +1,11 @@ +mod common_resolver; +mod plc_resolver; +mod web_resolver; + +pub use self::common_resolver::{CommonDidResolver, CommonDidResolverConfig}; +pub use self::plc_resolver::DEFAULT_PLC_DIRECTORY_URL; +use crate::Resolver; +use atrium_api::did_doc::DidDocument; +use atrium_api::types::string::Did; + +pub trait DidResolver: Resolver {} diff --git a/atrium-oauth/identity/src/did/common_resolver.rs b/atrium-oauth/identity/src/did/common_resolver.rs new file mode 100644 index 00000000..601127f7 --- /dev/null +++ b/atrium-oauth/identity/src/did/common_resolver.rs @@ -0,0 +1,54 @@ +use atrium_api::did_doc::DidDocument; +use atrium_api::types::string::Did; +use atrium_xrpc::HttpClient; + +use super::plc_resolver::{PlcDidResolver, PlcDidResolverConfig}; +use super::web_resolver::{WebDidResolver, WebDidResolverConfig}; +use super::DidResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct CommonDidResolverConfig { + pub plc_directory_url: String, + pub http_client: Arc, +} + +pub struct CommonDidResolver { + plc_resolver: PlcDidResolver, + web_resolver: WebDidResolver, +} + +impl CommonDidResolver { + pub fn new(config: CommonDidResolverConfig) -> Self { + Self { + plc_resolver: PlcDidResolver::new(PlcDidResolverConfig { + plc_directory_url: config.plc_directory_url, + http_client: config.http_client.clone(), + }), + web_resolver: WebDidResolver::new(WebDidResolverConfig { + http_client: config.http_client, + }), + } + } +} + +impl Resolver for CommonDidResolver +where + PlcDidResolver: DidResolver + Send + Sync + 'static, + WebDidResolver: DidResolver + Send + Sync + 'static, +{ + type Input = Did; + type Output = DidDocument; + + async fn resolve(&self, did: &Self::Input) -> Result { + match did.strip_prefix("did:").and_then(|s| s.split_once(':').map(|(method, _)| method)) { + Some("plc") => self.plc_resolver.resolve(did).await, + Some("web") => self.web_resolver.resolve(did).await, + _ => Err(Error::UnsupportedDidMethod(did.clone())), + } + } +} + +impl DidResolver for CommonDidResolver where T: HttpClient + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/did/plc_resolver.rs b/atrium-oauth/identity/src/did/plc_resolver.rs new file mode 100644 index 00000000..5f8dc1e7 --- /dev/null +++ b/atrium-oauth/identity/src/did/plc_resolver.rs @@ -0,0 +1,54 @@ +use super::DidResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use atrium_api::did_doc::DidDocument; +use atrium_api::types::string::Did; +use atrium_xrpc::http::uri::Builder; +use atrium_xrpc::http::{Request, Uri}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +pub const DEFAULT_PLC_DIRECTORY_URL: &str = "https://plc.directory/"; + +#[derive(Clone, Debug)] +pub struct PlcDidResolverConfig { + pub plc_directory_url: String, + pub http_client: Arc, +} + +pub struct PlcDidResolver { + plc_directory_url: String, + http_client: Arc, +} + +impl PlcDidResolver { + pub fn new(config: PlcDidResolverConfig) -> Self { + Self { plc_directory_url: config.plc_directory_url, http_client: config.http_client } + } +} + +impl Resolver for PlcDidResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = Did; + type Output = DidDocument; + + async fn resolve(&self, did: &Self::Input) -> Result { + let uri = Builder::from(self.plc_directory_url.parse::()?) + .path_and_query(format!("/{}", did.as_str())) + .build()?; + let res = self + .http_client + .send_http(Request::builder().uri(uri).body(Vec::new())?) + .await + .map_err(Error::HttpClient)?; + if res.status().is_success() { + Ok(serde_json::from_slice(res.body())?) + } else { + Err(Error::HttpStatus(res.status())) + } + } +} + +impl DidResolver for PlcDidResolver where T: HttpClient + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/did/web_resolver.rs b/atrium-oauth/identity/src/did/web_resolver.rs new file mode 100644 index 00000000..582bdd00 --- /dev/null +++ b/atrium-oauth/identity/src/did/web_resolver.rs @@ -0,0 +1,60 @@ +use super::DidResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use atrium_api::did_doc::DidDocument; +use atrium_api::types::string::Did; +use atrium_xrpc::http::{header::ACCEPT, Request, Uri}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +const DID_WEB_PREFIX: &str = "did:web:"; + +#[derive(Clone, Debug)] +pub struct WebDidResolverConfig { + pub http_client: Arc, +} + +pub struct WebDidResolver { + http_client: Arc, +} + +impl WebDidResolver { + pub fn new(config: WebDidResolverConfig) -> Self { + Self { http_client: config.http_client } + } +} + +impl Resolver for WebDidResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = Did; + type Output = DidDocument; + + async fn resolve(&self, did: &Self::Input) -> Result { + let document_url = format!( + "https://{}/.well-known/did.json", + did.as_str() + .strip_prefix(DID_WEB_PREFIX) + .ok_or_else(|| Error::Did(did.as_str().to_string()))? + ) + .parse::()?; + let res = self + .http_client + .send_http( + Request::builder() + .header(ACCEPT, "application/did+ld+json,application/json") + .uri(document_url) + .body(Vec::new())?, + ) + .await + .map_err(Error::HttpClient)?; + if res.status().is_success() { + Ok(serde_json::from_slice(res.body())?) + } else { + Err(Error::HttpStatus(res.status())) + } + } +} + +impl DidResolver for WebDidResolver where T: HttpClient + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/error.rs b/atrium-oauth/identity/src/error.rs new file mode 100644 index 00000000..8dc0dc6f --- /dev/null +++ b/atrium-oauth/identity/src/error.rs @@ -0,0 +1,38 @@ +use atrium_api::types::string::Did; +use atrium_xrpc::http::uri::InvalidUri; +use atrium_xrpc::http::StatusCode; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[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("dns resolver error: {0}")] + DnsResolver(Box), + #[error("unsupported did method: {0:?}")] + UnsupportedDidMethod(Did), + #[error(transparent)] + Http(#[from] atrium_xrpc::http::Error), + #[error("http client error: {0}")] + HttpClient(Box), + #[error("http status: {0:?}")] + HttpStatus(StatusCode), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + #[error(transparent)] + SerdeHtmlForm(#[from] serde_html_form::ser::Error), + #[error(transparent)] + Uri(#[from] InvalidUri), +} + +pub type Result = core::result::Result; diff --git a/atrium-oauth/identity/src/handle.rs b/atrium-oauth/identity/src/handle.rs new file mode 100644 index 00000000..2ae285dd --- /dev/null +++ b/atrium-oauth/identity/src/handle.rs @@ -0,0 +1,17 @@ +mod appview_resolver; +mod atproto_resolver; +mod dns_resolver; +#[cfg(feature = "doh-handle-resolver")] +mod doh_dns_txt_resolver; +mod well_known_resolver; + +pub use self::appview_resolver::{AppViewHandleResolver, AppViewHandleResolverConfig}; +pub use self::atproto_resolver::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +pub use self::dns_resolver::DnsTxtResolver; +#[cfg(feature = "doh-handle-resolver")] +pub use self::doh_dns_txt_resolver::{DohDnsTxtResolver, DohDnsTxtResolverConfig}; +pub use self::well_known_resolver::{WellKnownHandleResolver, WellKnownHandleResolverConfig}; +use crate::Resolver; +use atrium_api::types::string::{Did, Handle}; + +pub trait HandleResolver: Resolver {} diff --git a/atrium-oauth/identity/src/handle/appview_resolver.rs b/atrium-oauth/identity/src/handle/appview_resolver.rs new file mode 100644 index 00000000..90255a35 --- /dev/null +++ b/atrium-oauth/identity/src/handle/appview_resolver.rs @@ -0,0 +1,58 @@ +use super::HandleResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use atrium_api::com::atproto::identity::resolve_handle; +use atrium_api::types::string::{Did, Handle}; +use atrium_xrpc::http::uri::Builder; +use atrium_xrpc::http::{Request, Uri}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct AppViewHandleResolverConfig { + pub service_url: String, + pub http_client: Arc, +} + +pub struct AppViewHandleResolver { + service_url: String, + http_client: Arc, +} + +impl AppViewHandleResolver { + pub fn new(config: AppViewHandleResolverConfig) -> Self { + Self { service_url: config.service_url, http_client: config.http_client } + } +} + +impl Resolver for AppViewHandleResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = Handle; + type Output = Did; + + async fn resolve(&self, handle: &Self::Input) -> Result { + let uri = Builder::from(self.service_url.parse::()?) + .path_and_query(format!( + "/xrpc/com.atproto.identity.resolveHandle?{}", + serde_html_form::to_string(resolve_handle::ParametersData { + handle: handle.clone(), + })? + )) + .build()?; + // TODO: no-cache? + let res = self + .http_client + .send_http(Request::builder().uri(uri).body(Vec::new())?) + .await + .map_err(Error::HttpClient)?; + if res.status().is_success() { + Ok(serde_json::from_slice::(res.body())?.did) + } else { + Err(Error::HttpStatus(res.status())) + } + } +} + +impl HandleResolver for AppViewHandleResolver where T: HttpClient + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/handle/atproto_resolver.rs b/atrium-oauth/identity/src/handle/atproto_resolver.rs new file mode 100644 index 00000000..25ec54b4 --- /dev/null +++ b/atrium-oauth/identity/src/handle/atproto_resolver.rs @@ -0,0 +1,58 @@ +use super::dns_resolver::{DnsHandleResolver, DnsHandleResolverConfig, DnsTxtResolver}; +use super::well_known_resolver::{WellKnownHandleResolver, WellKnownHandleResolverConfig}; +use super::HandleResolver; +use crate::error::Result; +use crate::Resolver; +use atrium_api::types::string::{Did, Handle}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct AtprotoHandleResolverConfig { + pub dns_txt_resolver: R, + pub http_client: Arc, +} + +pub struct AtprotoHandleResolver { + dns: DnsHandleResolver, + http: WellKnownHandleResolver, +} + +impl AtprotoHandleResolver { + pub fn new(config: AtprotoHandleResolverConfig) -> Self { + Self { + dns: DnsHandleResolver::new(DnsHandleResolverConfig { + dns_txt_resolver: config.dns_txt_resolver, + }), + http: WellKnownHandleResolver::new(WellKnownHandleResolverConfig { + http_client: config.http_client, + }), + } + } +} + +impl Resolver for AtprotoHandleResolver +where + R: DnsTxtResolver + Send + Sync + 'static, + T: HttpClient + Send + Sync + 'static, +{ + type Input = Handle; + type Output = Did; + + async fn resolve(&self, handle: &Self::Input) -> Result { + let d_fut = self.dns.resolve(handle); + let h_fut = self.http.resolve(handle); + if let Ok(did) = d_fut.await { + Ok(did) + } else { + h_fut.await + } + } +} + +impl HandleResolver for AtprotoHandleResolver +where + R: DnsTxtResolver + Send + Sync + 'static, + T: HttpClient + Send + Sync + 'static, +{ +} diff --git a/atrium-oauth/identity/src/handle/dns_resolver.rs b/atrium-oauth/identity/src/handle/dns_resolver.rs new file mode 100644 index 00000000..7cdc6a92 --- /dev/null +++ b/atrium-oauth/identity/src/handle/dns_resolver.rs @@ -0,0 +1,60 @@ +use super::HandleResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use atrium_api::types::string::{Did, Handle}; +use std::future::Future; + +const SUBDOMAIN: &str = "_atproto"; +const PREFIX: &str = "did="; + +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait DnsTxtResolver { + fn resolve( + &self, + query: &str, + ) -> impl Future< + Output = core::result::Result< + Vec, + Box, + >, + >; +} + +#[derive(Clone, Debug)] +pub struct DnsHandleResolverConfig { + pub dns_txt_resolver: R, +} + +pub struct DnsHandleResolver { + dns_txt_resolver: R, +} + +impl DnsHandleResolver { + pub fn new(config: DnsHandleResolverConfig) -> Self { + Self { dns_txt_resolver: config.dns_txt_resolver } + } +} + +impl Resolver for DnsHandleResolver +where + R: DnsTxtResolver + Send + Sync + 'static, +{ + type Input = Handle; + type Output = Did; + + async fn resolve(&self, handle: &Self::Input) -> Result { + for result in self + .dns_txt_resolver + .resolve(&format!("{SUBDOMAIN}.{}", handle.as_ref())) + .await + .map_err(Error::DnsResolver)? + { + if let Some(did) = result.strip_prefix(PREFIX) { + return did.parse::().map_err(|e| Error::Did(e.to_string())); + } + } + Err(Error::NotFound) + } +} + +impl HandleResolver for DnsHandleResolver where R: DnsTxtResolver + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/handle/doh_dns_txt_resolver.rs b/atrium-oauth/identity/src/handle/doh_dns_txt_resolver.rs new file mode 100644 index 00000000..e2b00a78 --- /dev/null +++ b/atrium-oauth/identity/src/handle/doh_dns_txt_resolver.rs @@ -0,0 +1,70 @@ +use super::DnsTxtResolver; +use atrium_xrpc::http::StatusCode; +use atrium_xrpc::HttpClient; +use hickory_proto::op::{Message, Query}; +use hickory_proto::rr::{RData, RecordType}; +use std::sync::Arc; +use thiserror::Error; + +const DOH_MEDIA_TYPE: &str = "application/dns-message"; + +#[derive(Error, Debug)] +pub enum Error { + #[error("http status: {0:?}")] + HttpStatus(StatusCode), +} + +#[derive(Clone, Debug)] +pub struct DohDnsTxtResolverConfig { + pub service_url: String, + pub http_client: Arc, +} + +pub struct DohDnsTxtResolver { + service_url: String, + http_client: Arc, +} + +impl DohDnsTxtResolver { + #[allow(dead_code)] + pub fn new(config: DohDnsTxtResolverConfig) -> Self { + Self { service_url: config.service_url, http_client: config.http_client } + } +} + +impl DnsTxtResolver for DohDnsTxtResolver +where + T: HttpClient + Send + Sync + 'static, +{ + async fn resolve( + &self, + query: &str, + ) -> core::result::Result, Box> { + let mut message = Message::new(); + message + .set_recursion_desired(true) + .add_query(Query::query(query.parse()?, RecordType::TXT)); + let res = self + .http_client + .send_http( + atrium_xrpc::http::Request::builder() + .method(atrium_xrpc::http::Method::POST) + .header(atrium_xrpc::http::header::CONTENT_TYPE, DOH_MEDIA_TYPE) + .uri(&self.service_url) + .body(message.to_vec()?)?, + ) + .await?; + if res.status().is_success() { + Ok(Message::from_vec(res.body())? + .answers() + .iter() + .filter_map(|answer| match answer.data() { + Some(RData::TXT(txt)) => Some(txt.to_string()), + _ => None, + }) + .collect()) + } else { + Err(Box::new(Error::HttpStatus(res.status()))) + } + } +} diff --git a/atrium-oauth/identity/src/handle/well_known_resolver.rs b/atrium-oauth/identity/src/handle/well_known_resolver.rs new file mode 100644 index 00000000..9f04b2b7 --- /dev/null +++ b/atrium-oauth/identity/src/handle/well_known_resolver.rs @@ -0,0 +1,50 @@ +use super::HandleResolver; +use crate::error::{Error, Result}; +use crate::Resolver; +use atrium_api::types::string::{Did, Handle}; +use atrium_xrpc::http::Request; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +const WELL_KNWON_PATH: &str = "/.well-known/atproto-did"; + +#[derive(Clone, Debug)] +pub struct WellKnownHandleResolverConfig { + pub http_client: Arc, +} + +pub struct WellKnownHandleResolver { + http_client: Arc, +} + +impl WellKnownHandleResolver { + pub fn new(config: WellKnownHandleResolverConfig) -> Self { + Self { http_client: config.http_client } + } +} + +impl Resolver for WellKnownHandleResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = Handle; + type Output = Did; + + async fn resolve(&self, handle: &Self::Input) -> Result { + let url = format!("https://{}{WELL_KNWON_PATH}", handle.as_str()); + // TODO: no-cache? + let res = self + .http_client + .send_http(Request::builder().uri(url).body(Vec::new())?) + .await + .map_err(Error::HttpClient)?; + if res.status().is_success() { + let text = String::from_utf8_lossy(res.body()).to_string(); + text.parse::().map_err(|e| Error::Did(e.to_string())) + } else { + Err(Error::HttpStatus(res.status())) + } + } +} + +impl HandleResolver for WellKnownHandleResolver where T: HttpClient + Send + Sync + 'static {} diff --git a/atrium-oauth/identity/src/identity_resolver.rs b/atrium-oauth/identity/src/identity_resolver.rs new file mode 100644 index 00000000..e8244bce --- /dev/null +++ b/atrium-oauth/identity/src/identity_resolver.rs @@ -0,0 +1,64 @@ +use crate::error::{Error, Result}; +use crate::{did::DidResolver, handle::HandleResolver, Resolver}; +use atrium_api::types::string::AtIdentifier; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedIdentity { + pub did: String, + pub pds: String, +} + +#[derive(Clone, Debug)] +pub struct IdentityResolverConfig { + pub did_resolver: D, + pub handle_resolver: H, +} + +pub struct IdentityResolver { + did_resolver: D, + handle_resolver: H, +} + +impl IdentityResolver { + pub fn new(config: IdentityResolverConfig) -> Self { + Self { did_resolver: config.did_resolver, handle_resolver: config.handle_resolver } + } +} + +impl Resolver for IdentityResolver +where + D: DidResolver + Send + Sync + 'static, + H: HandleResolver + Send + Sync + 'static, +{ + type Input = str; + type Output = ResolvedIdentity; + + async fn resolve(&self, input: &Self::Input) -> Result { + let document = + match input.parse::().map_err(|e| Error::AtIdentifier(e.to_string()))? { + AtIdentifier::Did(did) => self.did_resolver.resolve(&did).await?, + AtIdentifier::Handle(handle) => { + let did = self.handle_resolver.resolve(&handle).await?; + let document = self.did_resolver.resolve(&did).await?; + if let Some(aka) = &document.also_known_as { + if !aka.contains(&format!("at://{}", handle.as_str())) { + return Err(Error::DidDocument(format!( + "did document for `{}` does not include the handle `{}`", + did.as_str(), + handle.as_str() + ))); + } + } + document + } + }; + let Some(service) = document.get_pds_endpoint() else { + return Err(Error::DidDocument(format!( + "no valid `AtprotoPersonalDataServer` service found in `{}`", + document.id + ))); + }; + Ok(ResolvedIdentity { did: document.id, pds: service }) + } +} diff --git a/atrium-oauth/identity/src/lib.rs b/atrium-oauth/identity/src/lib.rs new file mode 100644 index 00000000..d64a61cf --- /dev/null +++ b/atrium-oauth/identity/src/lib.rs @@ -0,0 +1,8 @@ +pub mod did; +mod error; +pub mod handle; +pub mod identity_resolver; +pub mod resolver; + +pub use self::error::{Error, Result}; +pub use self::resolver::Resolver; diff --git a/atrium-oauth/identity/src/resolver.rs b/atrium-oauth/identity/src/resolver.rs new file mode 100644 index 00000000..5cfdff90 --- /dev/null +++ b/atrium-oauth/identity/src/resolver.rs @@ -0,0 +1,247 @@ +mod cache_impl; +mod cached_resolver; +mod throttled_resolver; + +pub use self::cached_resolver::{CachedResolver, CachedResolverConfig}; +pub use self::throttled_resolver::ThrottledResolver; +pub use crate::error::Result; +use std::future::Future; +use std::hash::Hash; + +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait Resolver { + type Input: ?Sized; + type Output; + + fn resolve(&self, input: &Self::Input) -> impl Future>; +} + +pub trait Cacheable +where + Self: Sized + Resolver, + Self::Input: Sized, +{ + fn cached(self, config: CachedResolverConfig) -> CachedResolver; +} + +impl Cacheable for R +where + R: Sized + Resolver, + R::Input: Sized + Hash + Eq + Send + Sync + 'static, + R::Output: Clone + Send + Sync + 'static, +{ + fn cached(self, config: CachedResolverConfig) -> CachedResolver { + CachedResolver::new(self, config) + } +} + +pub trait Throttleable +where + Self: Sized + Resolver, + Self::Input: Sized, +{ + fn throttled(self) -> ThrottledResolver; +} + +impl Throttleable for R +where + R: Sized + Resolver, + R::Input: Clone + Hash + Eq + Send + Sync + 'static, + R::Output: Clone + Send + Sync + 'static, +{ + fn throttled(self) -> ThrottledResolver { + ThrottledResolver::new(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use std::collections::HashMap; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::RwLock; + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::wasm_bindgen_test; + + #[cfg(not(target_arch = "wasm32"))] + async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; + } + + #[cfg(target_arch = "wasm32")] + async fn sleep(duration: Duration) { + gloo_timers::future::sleep(duration).await; + } + + struct MockResolver { + data: HashMap, + counts: Arc>>, + } + + impl Resolver for MockResolver { + type Input = String; + type Output = String; + + async fn resolve(&self, input: &Self::Input) -> Result { + sleep(Duration::from_millis(10)).await; + *self.counts.write().await.entry(input.clone()).or_default() += 1; + if let Some(value) = self.data.get(input) { + Ok(value.clone()) + } else { + Err(Error::NotFound) + } + } + } + + fn mock_resolver(counts: Arc>>) -> MockResolver { + MockResolver { + data: [ + (String::from("k1"), String::from("v1")), + (String::from("k2"), String::from("v2")), + ] + .into_iter() + .collect(), + counts, + } + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + async fn test_no_cached() { + let counts = Arc::new(RwLock::new(HashMap::new())); + let resolver = mock_resolver(counts.clone()); + for (input, expected) in [ + ("k1", Some("v1")), + ("k2", Some("v2")), + ("k2", Some("v2")), + ("k1", Some("v1")), + ("k3", None), + ("k1", Some("v1")), + ("k3", None), + ] { + let result = resolver.resolve(&input.to_string()).await; + match expected { + Some(value) => assert_eq!(result.expect("failed to resolve"), value), + None => assert!(result.is_err()), + } + } + assert_eq!( + *counts.read().await, + [(String::from("k1"), 3), (String::from("k2"), 2), (String::from("k3"), 2),] + .into_iter() + .collect() + ); + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + async fn test_cached() { + let counts = Arc::new(RwLock::new(HashMap::new())); + let resolver = mock_resolver(counts.clone()).cached(Default::default()); + for (input, expected) in [ + ("k1", Some("v1")), + ("k2", Some("v2")), + ("k2", Some("v2")), + ("k1", Some("v1")), + ("k3", None), + ("k1", Some("v1")), + ("k3", None), + ] { + let result = resolver.resolve(&input.to_string()).await; + match expected { + Some(value) => assert_eq!(result.expect("failed to resolve"), value), + None => assert!(result.is_err()), + } + } + assert_eq!( + *counts.read().await, + [(String::from("k1"), 1), (String::from("k2"), 1), (String::from("k3"), 2),] + .into_iter() + .collect() + ); + } + + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + async fn test_cached_with_max_capacity() { + let counts = Arc::new(RwLock::new(HashMap::new())); + let resolver = mock_resolver(counts.clone()) + .cached(CachedResolverConfig { max_capacity: Some(1), ..Default::default() }); + for (input, expected) in [ + ("k1", Some("v1")), + ("k2", Some("v2")), + ("k2", Some("v2")), + ("k1", Some("v1")), + ("k3", None), + ("k1", Some("v1")), + ("k3", None), + ] { + let result = resolver.resolve(&input.to_string()).await; + match expected { + Some(value) => assert_eq!(result.expect("failed to resolve"), value), + None => assert!(result.is_err()), + } + } + assert_eq!( + *counts.read().await, + [(String::from("k1"), 2), (String::from("k2"), 1), (String::from("k3"), 2),] + .into_iter() + .collect() + ); + } + + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + async fn test_cached_with_time_to_live() { + let counts = Arc::new(RwLock::new(HashMap::new())); + let resolver = mock_resolver(counts.clone()).cached(CachedResolverConfig { + time_to_live: Some(Duration::from_millis(10)), + ..Default::default() + }); + for _ in 0..10 { + let result = resolver.resolve(&String::from("k1")).await; + assert_eq!(result.expect("failed to resolve"), "v1"); + } + sleep(Duration::from_millis(10)).await; + for _ in 0..10 { + let result = resolver.resolve(&String::from("k1")).await; + assert_eq!(result.expect("failed to resolve"), "v1"); + } + assert_eq!(*counts.read().await, [(String::from("k1"), 2)].into_iter().collect()); + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] + async fn test_throttled() { + let counts = Arc::new(RwLock::new(HashMap::new())); + let resolver = Arc::new(mock_resolver(counts.clone()).throttled()); + + let mut handles = Vec::new(); + for (input, expected) in [ + ("k1", Some("v1")), + ("k2", Some("v2")), + ("k2", Some("v2")), + ("k1", Some("v1")), + ("k3", None), + ("k1", Some("v1")), + ("k3", None), + ] { + let resolver = resolver.clone(); + handles.push(async move { (resolver.resolve(&input.to_string()).await, expected) }); + } + for (result, expected) in futures::future::join_all(handles).await { + match expected { + Some(value) => assert_eq!(result.expect("failed to resolve"), value), + None => assert!(result.is_err()), + } + } + assert_eq!( + *counts.read().await, + [(String::from("k1"), 1), (String::from("k2"), 1), (String::from("k3"), 1),] + .into_iter() + .collect() + ); + } +} diff --git a/atrium-oauth/identity/src/resolver/cache_impl.rs b/atrium-oauth/identity/src/resolver/cache_impl.rs new file mode 100644 index 00000000..c1b72c9c --- /dev/null +++ b/atrium-oauth/identity/src/resolver/cache_impl.rs @@ -0,0 +1,9 @@ +#[cfg(not(target_arch = "wasm32"))] +mod moka; +#[cfg(target_arch = "wasm32")] +mod wasm; + +#[cfg(not(target_arch = "wasm32"))] +pub use self::moka::MokaCache as CacheImpl; +#[cfg(target_arch = "wasm32")] +pub use self::wasm::WasmCache as CacheImpl; diff --git a/atrium-oauth/identity/src/resolver/cache_impl/moka.rs b/atrium-oauth/identity/src/resolver/cache_impl/moka.rs new file mode 100644 index 00000000..f35fa3a8 --- /dev/null +++ b/atrium-oauth/identity/src/resolver/cache_impl/moka.rs @@ -0,0 +1,35 @@ +use super::super::cached_resolver::{Cache as CacheTrait, CachedResolverConfig}; +use moka::{future::Cache, policy::EvictionPolicy}; +use std::collections::hash_map::RandomState; +use std::hash::Hash; + +pub struct MokaCache { + inner: Cache, +} + +impl CacheTrait for MokaCache +where + I: Hash + Eq + Send + Sync + 'static, + O: Clone + Send + Sync + 'static, +{ + type Input = I; + type Output = O; + + fn new(config: CachedResolverConfig) -> Self { + let mut builder = Cache::::builder().eviction_policy(EvictionPolicy::lru()); + if let Some(max_capacity) = config.max_capacity { + builder = builder.max_capacity(max_capacity); + } + if let Some(time_to_live) = config.time_to_live { + builder = builder.time_to_live(time_to_live); + } + Self { inner: builder.build() } + } + async fn get(&self, key: &Self::Input) -> Option { + self.inner.run_pending_tasks().await; + self.inner.get(key).await + } + async fn set(&self, key: Self::Input, value: Self::Output) { + self.inner.insert(key, value).await; + } +} diff --git a/atrium-oauth/identity/src/resolver/cache_impl/wasm.rs b/atrium-oauth/identity/src/resolver/cache_impl/wasm.rs new file mode 100644 index 00000000..8af03932 --- /dev/null +++ b/atrium-oauth/identity/src/resolver/cache_impl/wasm.rs @@ -0,0 +1,95 @@ +use super::super::cached_resolver::{Cache as CacheTrait, CachedResolverConfig}; +use lru::LruCache; +use std::collections::HashMap; +use std::hash::Hash; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tokio::sync::Mutex; +use web_time::{Duration, Instant}; + +enum Store { + Lru(LruCache), + HashMap(HashMap), +} + +impl Store +where + I: Hash + Eq + Send + Sync + 'static, + O: Clone + Send + Sync + 'static, +{ + fn get(&mut self, key: &I) -> Option { + match self { + Self::Lru(cache) => cache.get(key).cloned(), + Self::HashMap(map) => map.get(key).cloned(), + } + } + fn set(&mut self, key: I, value: O) { + match self { + Self::Lru(cache) => { + cache.put(key, value); + } + Self::HashMap(map) => { + map.insert(key, value); + } + } + } + fn del(&mut self, key: &I) { + match self { + Self::Lru(cache) => { + cache.pop(key); + } + Self::HashMap(map) => { + map.remove(key); + } + } + } +} + +#[derive(Clone, Debug)] +struct ValueWithInstant { + value: O, + instant: Instant, +} + +pub struct WasmCache { + inner: Arc>>>, + expiration: Option, +} + +impl CacheTrait for WasmCache +where + I: Hash + Eq + Send + Sync + 'static, + O: Clone + Send + Sync + 'static, +{ + type Input = I; + type Output = O; + + fn new(config: CachedResolverConfig) -> Self { + let store = if let Some(max_capacity) = config.max_capacity { + Store::Lru(LruCache::new( + NonZeroUsize::new(max_capacity as usize) + .expect("max_capacity must be greater than 0"), + )) + } else { + Store::HashMap(HashMap::new()) + }; + Self { inner: Arc::new(Mutex::new(store)), expiration: config.time_to_live } + } + async fn get(&self, key: &Self::Input) -> Option { + let mut cache = self.inner.lock().await; + if let Some(ValueWithInstant { value, instant }) = cache.get(key) { + if let Some(expiration) = self.expiration { + if instant.elapsed() > expiration { + cache.del(key); + return None; + } + } + Some(value) + } else { + None + } + } + async fn set(&self, key: Self::Input, value: Self::Output) { + self.inner.lock().await.set(key, ValueWithInstant { value, instant: Instant::now() }); + } +} diff --git a/atrium-oauth/identity/src/resolver/cached_resolver.rs b/atrium-oauth/identity/src/resolver/cached_resolver.rs new file mode 100644 index 00000000..79a38295 --- /dev/null +++ b/atrium-oauth/identity/src/resolver/cached_resolver.rs @@ -0,0 +1,61 @@ +use super::cache_impl::CacheImpl; +use crate::error::Result; +use crate::Resolver; +use std::fmt::Debug; +use std::hash::Hash; +use std::time::Duration; + +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub(crate) trait Cache { + type Input: Hash + Eq + Sync + 'static; + type Output: Clone + Sync + 'static; + + fn new(config: CachedResolverConfig) -> Self; + async fn get(&self, key: &Self::Input) -> Option; + async fn set(&self, key: Self::Input, value: Self::Output); +} + +#[derive(Clone, Debug, Default)] +pub struct CachedResolverConfig { + pub max_capacity: Option, + pub time_to_live: Option, +} + +pub struct CachedResolver +where + R: Resolver, + R::Input: Sized, +{ + resolver: R, + cache: CacheImpl, +} + +impl CachedResolver +where + R: Resolver, + R::Input: Sized + Hash + Eq + Send + Sync + 'static, + R::Output: Clone + Send + Sync + 'static, +{ + pub fn new(resolver: R, config: CachedResolverConfig) -> Self { + Self { resolver, cache: CacheImpl::new(config) } + } +} + +impl Resolver for CachedResolver +where + R: Resolver + Send + Sync + 'static, + R::Input: Clone + Hash + Eq + Send + Sync + 'static + Debug, + R::Output: Clone + Send + Sync + 'static, +{ + type Input = R::Input; + type Output = R::Output; + + async fn resolve(&self, input: &Self::Input) -> Result { + if let Some(output) = self.cache.get(input).await { + return Ok(output); + } + let output = self.resolver.resolve(input).await?; + self.cache.set(input.clone(), output.clone()).await; + Ok(output) + } +} diff --git a/atrium-oauth/identity/src/resolver/throttled_resolver.rs b/atrium-oauth/identity/src/resolver/throttled_resolver.rs new file mode 100644 index 00000000..195473f0 --- /dev/null +++ b/atrium-oauth/identity/src/resolver/throttled_resolver.rs @@ -0,0 +1,59 @@ +use super::Resolver; +use crate::error::{Error, Result}; +use dashmap::{DashMap, Entry}; +use std::hash::Hash; +use std::sync::Arc; +use tokio::sync::broadcast::{channel, Sender}; +use tokio::sync::Mutex; + +type SharedSender = Arc>>>; + +pub struct ThrottledResolver +where + R: Resolver, + R::Input: Sized, +{ + resolver: R, + senders: Arc>>, +} + +impl ThrottledResolver +where + R: Resolver, + R::Input: Clone + Hash + Eq + Send + Sync + 'static, +{ + pub fn new(resolver: R) -> Self { + Self { resolver, senders: Arc::new(DashMap::new()) } + } +} + +impl Resolver for ThrottledResolver +where + R: Resolver + Send + Sync + 'static, + R::Input: Clone + Hash + Eq + Send + Sync + 'static, + R::Output: Clone + Send + Sync + 'static, +{ + type Input = R::Input; + type Output = R::Output; + + async fn resolve(&self, input: &Self::Input) -> Result { + match self.senders.entry(input.clone()) { + Entry::Occupied(occupied) => { + let tx = occupied.get().lock().await.clone(); + drop(occupied); + match tx.subscribe().recv().await.expect("recv") { + Some(result) => Ok(result), + None => Err(Error::NotFound), + } + } + Entry::Vacant(vacant) => { + let (tx, _) = channel(1); + vacant.insert(Arc::new(Mutex::new(tx.clone()))); + let result = self.resolver.resolve(input).await; + tx.send(result.as_ref().ok().cloned()).ok(); + self.senders.remove(input); + result + } + } + } +} diff --git a/atrium-oauth/oauth-client/Cargo.toml b/atrium-oauth/oauth-client/Cargo.toml new file mode 100644 index 00000000..99a0f3db --- /dev/null +++ b/atrium-oauth/oauth-client/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "atrium-oauth-client" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "Core library for implementing AT Protocol OAuth clients" +documentation = "https://docs.rs/atrium-oauth-client" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords = ["atproto", "bluesky", "oauth"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +atrium-api = { workspace = true, default-features = false } +atrium-identity.workspace = true +atrium-xrpc.workspace = true +base64.workspace = true +chrono.workspace = true +ecdsa = { workspace = true, features = ["signing"] } +elliptic-curve.workspace = true +jose-jwa.workspace = true +jose-jwk = { workspace = true, features = ["p256"] } +p256 = { workspace = true, features = ["ecdsa"] } +rand = { workspace = true, features = ["small_rng"] } +reqwest = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +serde_html_form.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +trait-variant.workspace = true + +[dev-dependencies] +hickory-resolver.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[features] +default = ["default-client"] +default-client = ["reqwest/default-tls"] diff --git a/atrium-oauth/oauth-client/examples/main.rs b/atrium-oauth/oauth-client/examples/main.rs new file mode 100644 index 00000000..40a91a9e --- /dev/null +++ b/atrium-oauth/oauth-client/examples/main.rs @@ -0,0 +1,84 @@ +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver}; +use atrium_oauth_client::store::state::MemoryStateStore; +use atrium_oauth_client::{ + AtprotoLocalhostClientMetadata, AuthorizeOptions, DefaultHttpClient, OAuthClient, + OAuthClientConfig, OAuthResolverConfig, +}; +use atrium_xrpc::http::Uri; +use hickory_resolver::TokioAsyncResolver; +use std::io::{stdin, stdout, BufRead, Write}; +use std::sync::Arc; + +struct HickoryDnsTxtResolver { + resolver: TokioAsyncResolver, +} + +impl Default for HickoryDnsTxtResolver { + fn default() -> Self { + Self { + resolver: TokioAsyncResolver::tokio_from_system_conf() + .expect("failed to create resolver"), + } + } +} + +impl DnsTxtResolver for HickoryDnsTxtResolver { + async fn resolve( + &self, + query: &str, + ) -> core::result::Result, Box> { + Ok(self.resolver.txt_lookup(query).await?.iter().map(|txt| txt.to_string()).collect()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let http_client = Arc::new(DefaultHttpClient::default()); + let config = OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: vec!["http://127.0.0.1".to_string()], + }, + keys: None, + resolver: OAuthResolverConfig { + did_resolver: CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), + http_client: http_client.clone(), + }), + handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: HickoryDnsTxtResolver::default(), + http_client: http_client.clone(), + }), + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + state_store: MemoryStateStore::default(), + }; + let client = OAuthClient::new(config)?; + println!( + "Authorization url: {}", + client + .authorize( + std::env::var("HANDLE").unwrap_or(String::from("https://bsky.social")), + AuthorizeOptions { + scopes: Some(vec![String::from("atproto")]), + ..Default::default() + } + ) + .await? + ); + + // Click the URL and sign in, + // then copy and paste the URL like “http://127.0.0.1/?iss=...&code=...” after it is redirected. + + print!("Redirected url: "); + stdout().lock().flush()?; + let mut url = String::new(); + stdin().lock().read_line(&mut url)?; + + let uri = url.trim().parse::()?; + let params = serde_html_form::from_str(uri.query().unwrap())?; + println!("{}", serde_json::to_string_pretty(&client.callback(params).await?)?); + + Ok(()) +} diff --git a/atrium-oauth/oauth-client/src/atproto.rs b/atrium-oauth/oauth-client/src/atproto.rs new file mode 100644 index 00000000..94bf4c56 --- /dev/null +++ b/atrium-oauth/oauth-client/src/atproto.rs @@ -0,0 +1,162 @@ +use crate::keyset::Keyset; +use crate::types::{OAuthClientMetadata, TryIntoOAuthClientMetadata}; +use atrium_xrpc::http::Uri; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[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, +} + +pub type Result = core::result::Result; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMethod { + None, + // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication + PrivateKeyJwt, +} + +impl From for String { + fn from(value: AuthMethod) -> Self { + match value { + AuthMethod::None => String::from("none"), + AuthMethod::PrivateKeyJwt => String::from("private_key_jwt"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrantType { + AuthorizationCode, + RefreshToken, +} + +impl From for String { + fn from(value: GrantType) -> Self { + match value { + GrantType::AuthorizationCode => String::from("authorization_code"), + GrantType::RefreshToken => String::from("refresh_token"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Scope { + Atproto, +} + +impl From for String { + fn from(value: Scope) -> Self { + match value { + Scope::Atproto => String::from("atproto"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AtprotoLocalhostClientMetadata { + pub redirect_uris: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AtprotoClientMetadata { + pub client_id: String, + pub client_uri: String, + pub redirect_uris: Vec, + pub token_endpoint_auth_method: AuthMethod, + pub grant_types: Vec, + pub scopes: Vec, + pub jwks_uri: Option, + pub token_endpoint_auth_signing_alg: Option, +} + +impl TryIntoOAuthClientMetadata for AtprotoLocalhostClientMetadata { + type Error = Error; + + fn try_into_client_metadata(self, _: &Option) -> Result { + if self.redirect_uris.is_empty() { + return Err(Error::EmptyRedirectUris); + } + Ok(OAuthClientMetadata { + client_id: String::from("http://localhost"), + client_uri: None, + redirect_uris: self.redirect_uris, + scope: None, // will be set to `atproto` + grant_types: None, // will be set to `authorization_code` and `refresh_token` + token_endpoint_auth_method: Some(String::from("none")), + dpop_bound_access_tokens: None, // will be set to `true` + jwks_uri: None, + jwks: None, + token_endpoint_auth_signing_alg: None, + }) + } +} + +impl TryIntoOAuthClientMetadata for AtprotoClientMetadata { + type Error = Error; + + fn try_into_client_metadata(self, keyset: &Option) -> Result { + if self.client_id.parse::().is_err() { + return Err(Error::InvalidClientId); + } + if self.redirect_uris.is_empty() { + return Err(Error::EmptyRedirectUris); + } + if !self.grant_types.contains(&GrantType::AuthorizationCode) { + return Err(Error::InvalidGrantTypes); + } + if !self.scopes.contains(&Scope::Atproto) { + return Err(Error::InvalidScope); + } + let (jwks_uri, mut jwks) = (self.jwks_uri, None); + match self.token_endpoint_auth_method { + AuthMethod::None => { + if self.token_endpoint_auth_signing_alg.is_some() { + return Err(Error::AuthSigningAlg); + } + } + AuthMethod::PrivateKeyJwt => { + if let Some(keyset) = keyset { + if self.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: self.client_id, + client_uri: Some(self.client_uri), + redirect_uris: self.redirect_uris, + token_endpoint_auth_method: Some(self.token_endpoint_auth_method.into()), + grant_types: Some(self.grant_types.into_iter().map(|v| v.into()).collect()), + scope: Some( + self.scopes.into_iter().map(|v| v.into()).collect::>().join(" "), + ), + dpop_bound_access_tokens: Some(true), + jwks_uri, + jwks, + token_endpoint_auth_signing_alg: self.token_endpoint_auth_signing_alg, + }) + } +} diff --git a/atrium-oauth/oauth-client/src/constants.rs b/atrium-oauth/oauth-client/src/constants.rs new file mode 100644 index 00000000..ff973365 --- /dev/null +++ b/atrium-oauth/oauth-client/src/constants.rs @@ -0,0 +1 @@ +pub const FALLBACK_ALG: &str = "ES256"; diff --git a/atrium-oauth/oauth-client/src/error.rs b/atrium-oauth/oauth-client/src/error.rs new file mode 100644 index 00000000..16f87001 --- /dev/null +++ b/atrium-oauth/oauth-client/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + ClientMetadata(#[from] crate::atproto::Error), + #[error(transparent)] + Keyset(#[from] crate::keyset::Error), + #[error(transparent)] + Identity(#[from] atrium_identity::Error), + #[error(transparent)] + ServerAgent(#[from] crate::server_agent::Error), + #[error("authorize error: {0}")] + Authorize(String), + #[error("callback error: {0}")] + Callback(String), + #[error("state store error: {0:?}")] + StateStore(Box), +} + +pub type Result = core::result::Result; diff --git a/atrium-oauth/oauth-client/src/http_client.rs b/atrium-oauth/oauth-client/src/http_client.rs new file mode 100644 index 00000000..01698a00 --- /dev/null +++ b/atrium-oauth/oauth-client/src/http_client.rs @@ -0,0 +1,3 @@ +#[cfg(feature = "default-client")] +pub mod default; +pub mod dpop; diff --git a/atrium-oauth/oauth-client/src/http_client/default.rs b/atrium-oauth/oauth-client/src/http_client/default.rs new file mode 100644 index 00000000..8408d5e5 --- /dev/null +++ b/atrium-oauth/oauth-client/src/http_client/default.rs @@ -0,0 +1,29 @@ +use atrium_xrpc::HttpClient; +use reqwest::Client; + +pub struct DefaultHttpClient { + client: Client, +} + +impl HttpClient for DefaultHttpClient { + async fn send_http( + &self, + request: atrium_xrpc::http::Request>, + ) -> core::result::Result< + atrium_xrpc::http::Response>, + Box, + > { + let response = self.client.execute(request.try_into()?).await?; + let mut builder = atrium_xrpc::http::Response::builder().status(response.status()); + for (k, v) in response.headers() { + builder = builder.header(k, v); + } + builder.body(response.bytes().await?.to_vec()).map_err(Into::into) + } +} + +impl Default for DefaultHttpClient { + fn default() -> Self { + Self { client: reqwest::Client::new() } + } +} diff --git a/atrium-oauth/oauth-client/src/http_client/dpop.rs b/atrium-oauth/oauth-client/src/http_client/dpop.rs new file mode 100644 index 00000000..489fc3e8 --- /dev/null +++ b/atrium-oauth/oauth-client/src/http_client/dpop.rs @@ -0,0 +1,159 @@ +use crate::jose::create_signed_jwt; +use crate::jose::jws::RegisteredHeader; +use crate::jose::jwt::{Claims, PublicClaims, RegisteredClaims}; +use crate::store::memory::MemorySimpleStore; +use crate::store::SimpleStore; +use atrium_xrpc::http::{Request, Response}; +use atrium_xrpc::HttpClient; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chrono::Utc; +use jose_jwa::{Algorithm, Signing}; +use jose_jwk::{crypto, EcCurves, Jwk, Key}; +use rand::rngs::SmallRng; +use rand::{RngCore, SeedableRng}; +use serde::Deserialize; +use std::sync::Arc; +use thiserror::Error; + +const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, +} + +#[derive(Error, Debug)] +pub enum Error { + #[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), +} + +type Result = core::result::Result; + +pub struct DpopClient> +where + S: SimpleStore, +{ + inner: Arc, + key: Key, + #[allow(dead_code)] + iss: String, + nonces: S, +} + +impl DpopClient { + pub fn new( + key: Key, + iss: String, + http_client: Arc, + supported_algs: &Option>, + ) -> Result { + if let Some(algs) = supported_algs { + let alg = String::from(match &key { + Key::Ec(ec) => match &ec.crv { + EcCurves::P256 => "ES256", + _ => unimplemented!(), + }, + _ => unimplemented!(), + }); + if !algs.contains(&alg) { + return Err(Error::UnsupportedKey); + } + } + let nonces = MemorySimpleStore::::default(); + Ok(Self { inner: http_client, key, iss, nonces }) + } + fn build_proof(&self, htm: String, htu: String, nonce: Option) -> Result { + match crypto::Key::try_from(&self.key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { + 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_key.public_key())), + prm: Default::default(), + }); + let claims = Claims { + registered: RegisteredClaims { + jti: Some(Self::generate_jti()), + iat: Some(Utc::now().timestamp()), + ..Default::default() + }, + public: PublicClaims { + htm: Some(htm), + htu: Some(htu), + nonce, + ..Default::default() + }, + }; + Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) + } + _ => unimplemented!(), + } + } + fn is_use_dpop_nonce_error(&self, response: &Response>) -> bool { + // is auth server? + if response.status() == 400 { + if let Ok(res) = serde_json::from_slice::(response.body()) { + return res.error == "use_dpop_nonce"; + }; + } + // is resource server? + + false + } + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 + fn generate_jti() -> String { + let mut rng = SmallRng::from_entropy(); + let mut bytes = [0u8; 12]; + rng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) + } +} + +impl HttpClient for DpopClient +where + T: HttpClient + Send + Sync + 'static, +{ + async fn send_http( + &self, + mut request: Request>, + ) -> core::result::Result>, Box> + { + let uri = request.uri(); + let nonce_key = uri.authority().unwrap().to_string(); + let htm = request.method().to_string(); + let htu = uri.to_string(); + + let init_nonce = self.nonces.get(&nonce_key).await?; + let init_proof = self.build_proof(htm.clone(), htu.clone(), init_nonce.clone())?; + request.headers_mut().insert("DPoP", init_proof.parse()?); + let response = self.inner.send_http(request.clone()).await?; + + let next_nonce = + response.headers().get("DPoP-Nonce").and_then(|v| v.to_str().ok()).map(String::from); + 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(htm, htu, next_nonce)?; + request.headers_mut().insert("DPoP", next_proof.parse()?); + let response = self.inner.send_http(request).await?; + Ok(response) + } +} diff --git a/atrium-oauth/oauth-client/src/jose.rs b/atrium-oauth/oauth-client/src/jose.rs new file mode 100644 index 00000000..ba285ca1 --- /dev/null +++ b/atrium-oauth/oauth-client/src/jose.rs @@ -0,0 +1,28 @@ +pub mod jws; +pub mod jwt; +pub mod signing; + +pub use self::signing::create_signed_jwt; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Header { + Jws(jws::Header), + // TODO: JWE? +} + +#[cfg(test)] +mod tests { + use jose_jwa::{Algorithm, Signing}; + use jws::RegisteredHeader; + + use super::*; + + #[test] + fn test_serialize_claims() { + let header = Header::from(RegisteredHeader::from(Algorithm::Signing(Signing::Es256))); + let json = serde_json::to_string(&header).expect("failed to serialize header"); + assert_eq!(json, r#"{"alg":"ES256"}"#); + } +} diff --git a/atrium-oauth/oauth-client/src/jose/jws.rs b/atrium-oauth/oauth-client/src/jose/jws.rs new file mode 100644 index 00000000..8f6dc846 --- /dev/null +++ b/atrium-oauth/oauth-client/src/jose/jws.rs @@ -0,0 +1,64 @@ +use jose_jwa::Algorithm; +use jose_jwk::Jwk; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Header { + #[serde(flatten)] + pub registered: RegisteredHeader, +} + +impl From
for super::Header { + fn from(header: Header) -> Self { + Self::Jws(header) + } +} + +// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisteredHeader { + pub alg: Algorithm, + #[serde(skip_serializing_if = "Option::is_none")] + pub jku: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x5t: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "x5t#S256")] + pub x5ts256: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub typ: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cty: Option, +} + +impl From for RegisteredHeader { + fn from(alg: Algorithm) -> Self { + Self { + alg, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5ts256: None, + typ: None, + cty: None, + } + } +} + +impl From for super::Header { + fn from(registered: RegisteredHeader) -> Self { + Self::Jws(Header { registered }) + } +} diff --git a/atrium-oauth/oauth-client/src/jose/jwt.rs b/atrium-oauth/oauth-client/src/jose/jwt.rs new file mode 100644 index 00000000..87621942 --- /dev/null +++ b/atrium-oauth/oauth-client/src/jose/jwt.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Claims { + #[serde(flatten)] + pub registered: RegisteredClaims, + #[serde(flatten)] + pub public: PublicClaims, +} + +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RegisteredClaims { + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, +} + +// https://www.iana.org/assignments/jwt/jwt.xhtml +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PublicClaims { + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 + #[serde(skip_serializing_if = "Option::is_none")] + pub htm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub htu: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ath: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +impl From for Claims { + fn from(registered: RegisteredClaims) -> Self { + Self { registered, public: PublicClaims::default() } + } +} + +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RegisteredClaimsAud { + Single(String), + #[allow(dead_code)] + Multiple(Vec), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serialize_claims() { + // empty + { + let claims = Claims::default(); + let json = serde_json::to_string(&claims).expect("failed to serialize claims"); + assert_eq!(json, "{}"); + } + // single aud + { + let claims = Claims { + registered: RegisteredClaims { + aud: Some(RegisteredClaimsAud::Single(String::from("client"))), + ..Default::default() + }, + public: PublicClaims::default(), + }; + let json = serde_json::to_string(&claims).expect("failed to serialize claims"); + assert_eq!(json, r#"{"aud":"client"}"#); + } + // multiple auds + { + let claims = Claims { + registered: RegisteredClaims { + aud: Some(RegisteredClaimsAud::Multiple(vec![ + String::from("client1"), + String::from("client2"), + ])), + ..Default::default() + }, + public: PublicClaims::default(), + }; + let json = serde_json::to_string(&claims).expect("failed to serialize claims"); + assert_eq!(json, r#"{"aud":["client1","client2"]}"#); + } + } +} diff --git a/atrium-oauth/oauth-client/src/jose/signing.rs b/atrium-oauth/oauth-client/src/jose/signing.rs new file mode 100644 index 00000000..22a98166 --- /dev/null +++ b/atrium-oauth/oauth-client/src/jose/signing.rs @@ -0,0 +1,28 @@ +use super::jwt::Claims; +use super::Header; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use ecdsa::{ + hazmat::{DigestPrimitive, SignPrimitive}, + signature::Signer, + Signature, SignatureSize, SigningKey, +}; +use elliptic_curve::{ + generic_array::ArrayLength, ops::Invert, subtle::CtOption, CurveArithmetic, PrimeCurve, Scalar, +}; + +pub fn create_signed_jwt( + key: SigningKey, + header: Header, + claims: Claims, +) -> serde_json::Result +where + C: PrimeCurve + CurveArithmetic + DigestPrimitive, + Scalar: Invert>> + SignPrimitive, + SignatureSize: ArrayLength, +{ + 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()))) +} diff --git a/atrium-oauth/oauth-client/src/keyset.rs b/atrium-oauth/oauth-client/src/keyset.rs new file mode 100644 index 00000000..b6728f9e --- /dev/null +++ b/atrium-oauth/oauth-client/src/keyset.rs @@ -0,0 +1,125 @@ +use crate::jose::create_signed_jwt; +use crate::jose::jws::RegisteredHeader; +use crate::jose::jwt::Claims; +use jose_jwa::{Algorithm, Signing}; +use jose_jwk::{crypto, Class, EcCurves}; +use jose_jwk::{Jwk, JwkSet, Key}; +use std::collections::HashSet; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("duplicate kid: {0}")] + DuplicateKid(String), + #[error("keys must not be empty")] + EmptyKeys, + #[error("key must have a `kid`")] + EmptyKid, + #[error("no signing key found for algorithms: {0:?}")] + NotFound(Vec), + #[error("key for signing must be a secret key")] + PublicKey, + #[error("crypto error: {0:?}")] + JwkCrypto(crypto::Error), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), +} + +pub type Result = core::result::Result; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Keyset(Vec); + +impl Keyset { + const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = + ["EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512"]; + pub fn public_jwks(&self) -> JwkSet { + let mut keys = Vec::with_capacity(self.0.len()); + for mut key in self.0.clone() { + match key.key { + Key::Ec(ref mut ec) => { + ec.d = None; + } + _ => unimplemented!(), + } + keys.push(key); + } + JwkSet { keys } + } + pub fn create_jwt(&self, algs: &[String], claims: Claims) -> Result { + let Some(jwk) = self.find_key(algs, Class::Signing) else { + return Err(Error::NotFound(algs.to_vec())); + }; + self.create_jwt_with_key(jwk, claims) + } + fn find_key(&self, algs: &[String], cls: Class) -> Option<&Jwk> { + let candidates = self + .0 + .iter() + .filter_map(|key| { + if key.prm.cls.map_or(false, |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_string())) + }) + .collect::>(); + for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { + for (alg, key) in &candidates { + if alg == &pref_alg { + return Some(key); + } + } + } + None + } + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result { + let kid = key.prm.kid.clone().unwrap(); + match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); + header.kid = Some(kid); + Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) + } + _ => unimplemented!(), + } + } +} + +impl TryFrom> for Keyset { + type Error = Error; + + fn try_from(keys: Vec) -> Result { + if keys.is_empty() { + return Err(Error::EmptyKeys); + } + let mut v = Vec::with_capacity(keys.len()); + let mut hs = HashSet::with_capacity(keys.len()); + for key in keys { + if let Some(kid) = key.prm.kid.clone() { + if hs.contains(&kid) { + return Err(Error::DuplicateKid(kid)); + } + hs.insert(kid); + // ensure that the key is a secret key + if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { + crypto::Key::P256(crypto::Kind::Public(_)) => true, + crypto::Key::P256(crypto::Kind::Secret(_)) => false, + _ => unimplemented!(), + } { + return Err(Error::PublicKey); + } + v.push(key); + } else { + return Err(Error::EmptyKid); + } + } + Ok(Self(v)) + } +} diff --git a/atrium-oauth/oauth-client/src/lib.rs b/atrium-oauth/oauth-client/src/lib.rs new file mode 100644 index 00000000..d9a7f071 --- /dev/null +++ b/atrium-oauth/oauth-client/src/lib.rs @@ -0,0 +1,25 @@ +mod atproto; +mod constants; +mod error; +mod http_client; +mod jose; +mod keyset; +mod oauth_client; +mod resolver; +mod server_agent; +pub mod store; +mod types; +mod utils; + +pub use atproto::{ + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, GrantType, Scope, +}; +pub use error::{Error, Result}; +#[cfg(feature = "default-client")] +pub use http_client::default::DefaultHttpClient; +pub use http_client::dpop::DpopClient; +pub use oauth_client::{OAuthClient, OAuthClientConfig}; +pub use resolver::OAuthResolverConfig; +pub use types::{ + AuthorizeOptionPrompt, AuthorizeOptions, CallbackParams, OAuthClientMetadata, TokenSet, +}; diff --git a/atrium-oauth/oauth-client/src/oauth_client.rs b/atrium-oauth/oauth-client/src/oauth_client.rs new file mode 100644 index 00000000..ca1534a3 --- /dev/null +++ b/atrium-oauth/oauth-client/src/oauth_client.rs @@ -0,0 +1,262 @@ +use crate::constants::FALLBACK_ALG; +use crate::error::{Error, Result}; +use crate::keyset::Keyset; +use crate::resolver::{OAuthResolver, OAuthResolverConfig}; +use crate::server_agent::{OAuthRequest, OAuthServerAgent}; +use crate::store::state::{InternalStateData, StateStore}; +use crate::types::{ + AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptions, CallbackParams, + OAuthAuthorizationServerMetadata, OAuthClientMetadata, + OAuthPusehedAuthorizationRequestResponse, PushedAuthorizationRequestParameters, TokenSet, + TryIntoOAuthClientMetadata, +}; +use crate::utils::{compare_algos, generate_key, generate_nonce, get_random_values}; +use atrium_identity::{did::DidResolver, handle::HandleResolver, Resolver}; +use atrium_xrpc::HttpClient; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use jose_jwk::{Jwk, JwkSet, Key}; +use rand::rngs::ThreadRng; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +#[cfg(feature = "default-client")] +pub struct OAuthClientConfig +where + M: TryIntoOAuthClientMetadata, +{ + // Config + pub client_metadata: M, + pub keys: Option>, + // Stores + pub state_store: S, + // Services + pub resolver: OAuthResolverConfig, +} + +#[cfg(not(feature = "default-client"))] +pub struct OAuthClientConfig +where + M: TryIntoOAuthClientMetadata, +{ + // Config + pub client_metadata: M, + pub keys: Option>, + // Stores + pub state_store: S, + // Services + pub resolver: OAuthResolverConfig, + // Others + pub http_client: T, +} + +#[cfg(feature = "default-client")] +pub struct OAuthClient +where + S: StateStore, + T: HttpClient + Send + Sync + 'static, +{ + pub client_metadata: OAuthClientMetadata, + keyset: Option, + resolver: Arc>, + state_store: S, + http_client: Arc, +} + +#[cfg(not(feature = "default-client"))] +pub struct OAuthClient +where + S: StateStore, + T: HttpClient + Send + Sync + 'static, +{ + pub client_metadata: OAuthClientMetadata, + keyset: Option, + resolver: Arc>, + state_store: S, + http_client: Arc, +} + +#[cfg(feature = "default-client")] +impl OAuthClient +where + S: StateStore, +{ + pub fn new(config: OAuthClientConfig) -> Result + where + M: TryIntoOAuthClientMetadata, + { + let keyset = if let Some(keys) = config.keys { Some(keys.try_into()?) } else { None }; + let client_metadata = config.client_metadata.try_into_client_metadata(&keyset)?; + let http_client = Arc::new(crate::http_client::default::DefaultHttpClient::default()); + Ok(Self { + client_metadata, + keyset, + resolver: Arc::new(OAuthResolver::new(config.resolver, http_client.clone())), + state_store: config.state_store, + http_client, + }) + } +} + +#[cfg(not(feature = "default-client"))] +impl OAuthClient +where + S: StateStore, + T: HttpClient + Send + Sync + 'static, +{ + pub fn new(config: OAuthClientConfig) -> Result + where + M: TryIntoOAuthClientMetadata, + { + let keyset = if let Some(keys) = config.keys { Some(keys.try_into()?) } else { None }; + let client_metadata = config.client_metadata.try_into_client_metadata(&keyset)?; + let http_client = Arc::new(config.http_client); + Ok(Self { + client_metadata, + keyset, + resolver: Arc::new(OAuthResolver::new(config.resolver, http_client.clone())), + state_store: config.state_store, + http_client, + }) + } +} + +impl OAuthClient +where + S: StateStore, + D: DidResolver + Send + Sync + 'static, + H: HandleResolver + Send + Sync + 'static, + T: HttpClient + Send + Sync + 'static, +{ + pub fn jwks(&self) -> JwkSet { + self.keyset.as_ref().map(|keyset| keyset.public_jwks()).unwrap_or_default() + } + pub async fn authorize( + &self, + input: impl AsRef, + options: AuthorizeOptions, + ) -> Result { + let redirect_uri = if let Some(uri) = options.redirect_uri { + if !self.client_metadata.redirect_uris.contains(&uri) { + return Err(Error::Authorize("invalid redirect_uri".into())); + } + uri + } else { + self.client_metadata.redirect_uris[0].clone() + }; + let (metadata, identity) = self.resolver.resolve(input.as_ref()).await?; + let Some(dpop_key) = Self::generate_dpop_key(&metadata) else { + return Err(Error::Authorize("none of the algorithms worked".into())); + }; + let (code_challenge, verifier) = Self::generate_pkce(); + let state = generate_nonce(); + let state_data = InternalStateData { + iss: metadata.issuer.clone(), + dpop_key: dpop_key.clone(), + verifier, + }; + self.state_store + .set(state.clone(), state_data) + .await + .map_err(|e| Error::StateStore(Box::new(e)))?; + let login_hint = if identity.is_some() { Some(input.as_ref().into()) } else { None }; + let parameters = PushedAuthorizationRequestParameters { + response_type: AuthorizationResponseType::Code, + redirect_uri, + state, + scope: options.scopes.map(|v| v.join(" ")), + response_mode: None, + code_challenge, + code_challenge_method: AuthorizationCodeChallengeMethod::S256, + login_hint, + prompt: options.prompt.map(String::from), + }; + if metadata.pushed_authorization_request_endpoint.is_some() { + let server = OAuthServerAgent::new( + dpop_key, + metadata.clone(), + self.client_metadata.clone(), + self.resolver.clone(), + self.http_client.clone(), + self.keyset.clone(), + )?; + let par_response = server + .request::( + OAuthRequest::PushedAuthorizationRequest(parameters), + ) + .await?; + + #[derive(Serialize)] + struct Parameters { + client_id: String, + request_uri: String, + } + Ok(metadata.authorization_endpoint + + "?" + + &serde_html_form::to_string(Parameters { + client_id: self.client_metadata.client_id.clone(), + request_uri: par_response.request_uri, + }) + .unwrap()) + } else if metadata.require_pushed_authorization_requests == Some(true) { + Err(Error::Authorize("server requires PAR but no endpoint is available".into())) + } else { + // now "the use of PAR is *mandatory* for all clients" + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#framework + todo!() + } + } + pub async fn callback(&self, params: CallbackParams) -> Result { + let Some(state_key) = params.state else { + return Err(Error::Callback("missing `state` parameter".into())); + }; + + let Some(state) = + self.state_store.get(&state_key).await.map_err(|e| Error::StateStore(Box::new(e)))? + else { + return Err(Error::Callback(format!("unknown authorization state: {state_key}"))); + }; + // Prevent any kind of replay + self.state_store.del(&state_key).await.map_err(|e| Error::StateStore(Box::new(e)))?; + + let metadata = self.resolver.get_authorization_server_metadata(&state.iss).await?; + // https://datatracker.ietf.org/doc/html/rfc9207#section-2.4 + if let Some(iss) = params.iss { + if iss != metadata.issuer { + return Err(Error::Callback(format!( + "issuer mismatch: expected {}, got {iss}", + metadata.issuer + ))); + } + } else if metadata.authorization_response_iss_parameter_supported == Some(true) { + return Err(Error::Callback("missing `iss` parameter".into())); + } + let server = OAuthServerAgent::new( + state.dpop_key.clone(), + metadata.clone(), + self.client_metadata.clone(), + self.resolver.clone(), + self.http_client.clone(), + self.keyset.clone(), + )?; + let token_set = server.exchange_code(¶ms.code, &state.verifier).await?; + + // TODO: create session? + Ok(token_set) + } + fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option { + let mut algs = + metadata.dpop_signing_alg_values_supported.clone().unwrap_or(vec![FALLBACK_ALG.into()]); + algs.sort_by(compare_algos); + generate_key(&algs) + } + fn generate_pkce() -> (String, String) { + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + let verifier = + URL_SAFE_NO_PAD.encode(get_random_values::<_, 32>(&mut ThreadRng::default())); + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + (URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())), verifier) + } +} diff --git a/atrium-oauth/oauth-client/src/resolver.rs b/atrium-oauth/oauth-client/src/resolver.rs new file mode 100644 index 00000000..ad36e813 --- /dev/null +++ b/atrium-oauth/oauth-client/src/resolver.rs @@ -0,0 +1,197 @@ +mod oauth_authorization_server_resolver; +mod oauth_protected_resource_resolver; + +use self::oauth_authorization_server_resolver::DefaultOAuthAuthorizationServerResolver; +use self::oauth_protected_resource_resolver::DefaultOAuthProtectedResourceResolver; +use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; +use atrium_identity::identity_resolver::{ + IdentityResolver, IdentityResolverConfig, ResolvedIdentity, +}; +use atrium_identity::resolver::{ + Cacheable, CachedResolver, CachedResolverConfig, Throttleable, ThrottledResolver, +}; +use atrium_identity::{did::DidResolver, handle::HandleResolver, Resolver}; +use atrium_identity::{Error, Result}; +use atrium_xrpc::HttpClient; +use std::marker::PhantomData; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct OAuthAuthorizationServerMetadataResolverConfig { + pub cache: CachedResolverConfig, +} + +impl Default for OAuthAuthorizationServerMetadataResolverConfig { + fn default() -> Self { + Self { + cache: CachedResolverConfig { + max_capacity: Some(100), + time_to_live: Some(Duration::from_secs(60)), + }, + } + } +} + +#[derive(Clone, Debug)] +pub struct OAuthProtectedResourceMetadataResolverConfig { + pub cache: CachedResolverConfig, +} + +impl Default for OAuthProtectedResourceMetadataResolverConfig { + fn default() -> Self { + Self { + cache: CachedResolverConfig { + max_capacity: Some(100), + time_to_live: Some(Duration::from_secs(60)), + }, + } + } +} + +#[derive(Clone, Debug)] +pub struct OAuthResolverConfig { + pub did_resolver: D, + pub handle_resolver: H, + pub authorization_server_metadata: OAuthAuthorizationServerMetadataResolverConfig, + pub protected_resource_metadata: OAuthProtectedResourceMetadataResolverConfig, +} + +pub struct OAuthResolver< + T, + D, + H, + PR = DefaultOAuthProtectedResourceResolver, + AS = DefaultOAuthAuthorizationServerResolver, +> where + PR: Resolver + Send + Sync + 'static, + AS: Resolver + Send + Sync + 'static, +{ + identity_resolver: IdentityResolver, + protected_resource_resolver: CachedResolver>, + authorization_server_resolver: CachedResolver>, + _phantom: PhantomData, +} + +impl OAuthResolver +where + T: HttpClient + Send + Sync + 'static, +{ + pub fn new(config: OAuthResolverConfig, http_client: Arc) -> Self { + let protected_resource_resolver = + DefaultOAuthProtectedResourceResolver::new(http_client.clone()) + .throttled() + .cached(config.authorization_server_metadata.cache); + let authorization_server_resolver = + DefaultOAuthAuthorizationServerResolver::new(http_client.clone()) + .throttled() + .cached(config.protected_resource_metadata.cache); + Self { + identity_resolver: IdentityResolver::new(IdentityResolverConfig { + did_resolver: config.did_resolver, + handle_resolver: config.handle_resolver, + }), + protected_resource_resolver, + authorization_server_resolver, + _phantom: PhantomData, + } + } +} + +impl OAuthResolver +where + T: HttpClient + Send + Sync + 'static, + D: DidResolver + Send + Sync + 'static, + H: HandleResolver + Send + Sync + 'static, +{ + pub async fn get_authorization_server_metadata( + &self, + issuer: impl AsRef, + ) -> Result { + self.authorization_server_resolver.resolve(&issuer.as_ref().to_string()).await + } + async fn resolve_from_service(&self, input: &str) -> Result { + // 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 + } + pub(crate) async fn resolve_from_identity( + &self, + input: &str, + ) -> Result<(OAuthAuthorizationServerMetadata, ResolvedIdentity)> { + let identity = self.identity_resolver.resolve(input).await?; + let metadata = self.get_resource_server_metadata(&identity.pds).await?; + Ok((metadata, identity)) + } + async fn get_resource_server_metadata( + &self, + pds: &str, + ) -> Result { + let rs_metadata = self.protected_resource_resolver.resolve(&pds.to_string()).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(Error::ProtectedResourceMetadata(format!( + "unable to determine authorization server for PDS: {pds}" + ))); + } + &servers[0] + } + _ => { + return Err(Error::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(Error::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) + } +} + +impl Resolver for OAuthResolver +where + T: HttpClient + Send + Sync + 'static, + D: DidResolver + Send + Sync + 'static, + H: HandleResolver + Send + Sync + 'static, +{ + type Input = str; + type Output = (OAuthAuthorizationServerMetadata, Option); + + async fn resolve(&self, input: &Self::Input) -> Result { + // 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://") { + (self.resolve_from_service(input.as_ref()).await?, None) + } else { + let (metadata, identity) = self.resolve_from_identity(input).await?; + (metadata, Some(identity)) + }) + } +} diff --git a/atrium-oauth/oauth-client/src/resolver/oauth_authorization_server_resolver.rs b/atrium-oauth/oauth-client/src/resolver/oauth_authorization_server_resolver.rs new file mode 100644 index 00000000..e38428fe --- /dev/null +++ b/atrium-oauth/oauth-client/src/resolver/oauth_authorization_server_resolver.rs @@ -0,0 +1,50 @@ +use crate::types::OAuthAuthorizationServerMetadata; +use atrium_identity::{Error, Resolver, Result}; +use atrium_xrpc::http::uri::Builder; +use atrium_xrpc::http::{Request, StatusCode, Uri}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +pub struct DefaultOAuthAuthorizationServerResolver { + http_client: Arc, +} + +impl DefaultOAuthAuthorizationServerResolver { + pub fn new(http_client: Arc) -> Self { + Self { http_client } + } +} + +impl Resolver for DefaultOAuthAuthorizationServerResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = String; + type Output = OAuthAuthorizationServerMetadata; + + async fn resolve(&self, issuer: &Self::Input) -> Result { + let uri = Builder::from(issuer.parse::()?) + .path_and_query("/.well-known/oauth-authorization-server") + .build()?; + let res = self + .http_client + .send_http(Request::builder().uri(uri).body(Vec::new())?) + .await + .map_err(Error::HttpClient)?; + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.2 + if res.status() == StatusCode::OK { + let metadata = serde_json::from_slice::(res.body())?; + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 + if &metadata.issuer == issuer { + Ok(metadata) + } else { + Err(Error::AuthorizationServerMetadata(format!( + "invalid issuer: {}", + metadata.issuer + ))) + } + } else { + Err(Error::HttpStatus(res.status())) + } + } +} diff --git a/atrium-oauth/oauth-client/src/resolver/oauth_protected_resource_resolver.rs b/atrium-oauth/oauth-client/src/resolver/oauth_protected_resource_resolver.rs new file mode 100644 index 00000000..98c2ea7a --- /dev/null +++ b/atrium-oauth/oauth-client/src/resolver/oauth_protected_resource_resolver.rs @@ -0,0 +1,50 @@ +use crate::types::OAuthProtectedResourceMetadata; +use atrium_identity::{Error, Resolver, Result}; +use atrium_xrpc::http::uri::Builder; +use atrium_xrpc::http::{Request, StatusCode, Uri}; +use atrium_xrpc::HttpClient; +use std::sync::Arc; + +pub struct DefaultOAuthProtectedResourceResolver { + http_client: Arc, +} + +impl DefaultOAuthProtectedResourceResolver { + pub fn new(http_client: Arc) -> Self { + Self { http_client } + } +} + +impl Resolver for DefaultOAuthProtectedResourceResolver +where + T: HttpClient + Send + Sync + 'static, +{ + type Input = String; + type Output = OAuthProtectedResourceMetadata; + + async fn resolve(&self, resource: &Self::Input) -> Result { + let uri = Builder::from(resource.parse::()?) + .path_and_query("/.well-known/oauth-protected-resource") + .build()?; + let res = self + .http_client + .send_http(Request::builder().uri(uri).body(Vec::new())?) + .await + .map_err(Error::HttpClient)?; + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-3.2 + if res.status() == StatusCode::OK { + let metadata = serde_json::from_slice::(res.body())?; + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-3.3 + if &metadata.resource == resource { + Ok(metadata) + } else { + Err(Error::ProtectedResourceMetadata(format!( + "invalid resource: {}", + metadata.resource + ))) + } + } else { + Err(Error::HttpStatus(res.status())) + } + } +} diff --git a/atrium-oauth/oauth-client/src/server_agent.rs b/atrium-oauth/oauth-client/src/server_agent.rs new file mode 100644 index 00000000..2a05beff --- /dev/null +++ b/atrium-oauth/oauth-client/src/server_agent.rs @@ -0,0 +1,277 @@ +use crate::constants::FALLBACK_ALG; +use crate::http_client::dpop::DpopClient; +use crate::jose::jwt::{RegisteredClaims, RegisteredClaimsAud}; +use crate::keyset::Keyset; +use crate::resolver::OAuthResolver; +use crate::types::{ + OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthTokenResponse, + PushedAuthorizationRequestParameters, TokenGrantType, TokenRequestParameters, TokenSet, +}; +use crate::utils::{compare_algos, generate_nonce}; +use atrium_api::types::string::Datetime; +use atrium_identity::{did::DidResolver, handle::HandleResolver}; +use atrium_xrpc::http::{Method, Request, StatusCode}; +use atrium_xrpc::HttpClient; +use chrono::{TimeDelta, Utc}; +use jose_jwk::Key; +use serde::Serialize; +use serde_json::Value; +use std::sync::Arc; +use thiserror::Error; + +// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 +const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +#[derive(Error, Debug)] +pub enum Error { + #[error("no {0} endpoint available")] + NoEndpoint(String), + #[error("token response verification failed")] + Token(String), + #[error("unsupported authentication method")] + UnsupportedAuthMethod, + #[error(transparent)] + DpopClient(#[from] crate::http_client::dpop::Error), + #[error(transparent)] + Http(#[from] atrium_xrpc::http::Error), + #[error("http client error: {0}")] + HttpClient(Box), + #[error("http status: {0}")] + HttpStatus(StatusCode), + #[error("http status: {0}, body: {1:?}")] + HttpStatusWithBody(StatusCode, Value), + #[error(transparent)] + Identity(#[from] atrium_identity::Error), + #[error(transparent)] + Keyset(#[from] crate::keyset::Error), + #[error(transparent)] + SerdeHtmlForm(#[from] serde_html_form::ser::Error), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), +} + +pub type Result = core::result::Result; + +#[allow(dead_code)] +pub enum OAuthRequest { + Token(TokenRequestParameters), + Revocation, + Introspection, + PushedAuthorizationRequest(PushedAuthorizationRequestParameters), +} + +impl OAuthRequest { + fn name(&self) -> String { + String::from(match self { + Self::Token(_) => "token", + Self::Revocation => "revocation", + Self::Introspection => "introspection", + Self::PushedAuthorizationRequest(_) => "pushed_authorization_request", + }) + } + fn expected_status(&self) -> StatusCode { + match self { + Self::Token(_) => StatusCode::OK, + Self::PushedAuthorizationRequest(_) => StatusCode::CREATED, + _ => unimplemented!(), + } + } +} + +#[derive(Debug, Serialize)] +struct RequestPayload +where + T: Serialize, +{ + client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_assertion_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + client_assertion: Option, + #[serde(flatten)] + parameters: T, +} + +pub struct OAuthServerAgent +where + T: HttpClient + Send + Sync + 'static, +{ + server_metadata: OAuthAuthorizationServerMetadata, + client_metadata: OAuthClientMetadata, + dpop_client: DpopClient, + resolver: Arc>, + keyset: Option, +} + +impl OAuthServerAgent +where + T: HttpClient + Send + Sync + 'static, + D: DidResolver + Send + Sync + 'static, + H: HandleResolver + Send + Sync + 'static, +{ + pub fn new( + dpop_key: Key, + server_metadata: OAuthAuthorizationServerMetadata, + client_metadata: OAuthClientMetadata, + resolver: Arc>, + http_client: Arc, + keyset: Option, + ) -> Result { + let dpop_client = DpopClient::new( + dpop_key, + client_metadata.client_id.clone(), + http_client, + &server_metadata.token_endpoint_auth_signing_alg_values_supported, + )?; + Ok(Self { server_metadata, client_metadata, dpop_client, resolver, keyset }) + } + /** + * VERY IMPORTANT ! Always call this to process token responses. + * + * Whenever an OAuth token response is received, we **MUST** verify that the + * "sub" is a DID, whose issuer authority is indeed the server we just + * obtained credentials from. This check is a critical step to actually be + * able to use the "sub" (DID) as being the actual user's identifier. + */ + async fn verify_token_response(&self, token_response: OAuthTokenResponse) -> Result { + // ATPROTO requires that the "sub" is always present in the token response. + let Some(sub) = &token_response.sub else { + return Err(Error::Token("missing `sub` in token response".into())); + }; + let (metadata, identity) = self.resolver.resolve_from_identity(sub).await?; + if metadata.issuer != self.server_metadata.issuer { + return Err(Error::Token("issuer mismatch".into())); + } + let expires_at = token_response.expires_in.and_then(|expires_in| { + Datetime::now() + .as_ref() + .checked_add_signed(TimeDelta::seconds(expires_in)) + .map(Datetime::new) + }); + Ok(TokenSet { + sub: sub.clone(), + aud: identity.pds, + iss: metadata.issuer, + scope: token_response.scope, + access_token: token_response.access_token, + refresh_token: token_response.refresh_token, + token_type: token_response.token_type, + expires_at, + }) + } + pub async fn exchange_code(&self, code: &str, verifier: &str) -> Result { + self.verify_token_response( + self.request(OAuthRequest::Token(TokenRequestParameters { + grant_type: TokenGrantType::AuthorizationCode, + code: code.into(), + redirect_uri: self.client_metadata.redirect_uris[0].clone(), // ? + code_verifier: verifier.into(), + })) + .await?, + ) + .await + } + pub async fn request(&self, request: OAuthRequest) -> Result + where + O: serde::de::DeserializeOwned, + { + let Some(url) = self.endpoint(&request) else { + return Err(Error::NoEndpoint(request.name())); + }; + let body = match &request { + OAuthRequest::Token(params) => self.build_body(params)?, + OAuthRequest::PushedAuthorizationRequest(params) => self.build_body(params)?, + _ => unimplemented!(), + }; + let req = Request::builder() + .uri(url) + .method(Method::POST) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body.into_bytes())?; + let res = self.dpop_client.send_http(req).await.map_err(Error::HttpClient)?; + if res.status() == request.expected_status() { + Ok(serde_json::from_slice(res.body())?) + } else if res.status().is_client_error() { + Err(Error::HttpStatusWithBody(res.status(), serde_json::from_slice(res.body())?)) + } else { + Err(Error::HttpStatus(res.status())) + } + } + fn build_body(&self, parameters: S) -> Result + where + S: Serialize, + { + let (client_assertion_type, client_assertion) = self.build_auth()?; + Ok(serde_html_form::to_string(RequestPayload { + client_id: self.client_metadata.client_id.clone(), + client_assertion_type, + client_assertion, + parameters, + })?) + } + fn build_auth(&self) -> Result<(Option, Option)> { + let method_supported = &self.server_metadata.token_endpoint_auth_methods_supported; + let method = &self.client_metadata.token_endpoint_auth_method; + match method.as_deref() { + Some("private_key_jwt") + if method_supported + .as_ref() + .map_or(false, |v| v.contains(&String::from("private_key_jwt"))) => + { + if let Some(keyset) = &self.keyset { + let mut algs = self + .server_metadata + .token_endpoint_auth_signing_alg_values_supported + .clone() + .unwrap_or(vec![FALLBACK_ALG.into()]); + algs.sort_by(compare_algos); + let iat = Utc::now().timestamp(); + return Ok(( + Some(String::from(CLIENT_ASSERTION_TYPE_JWT_BEARER)), + Some( + keyset.create_jwt( + &algs, + // https://datatracker.ietf.org/doc/html/rfc7523#section-3 + RegisteredClaims { + iss: Some(self.client_metadata.client_id.clone()), + sub: Some(self.client_metadata.client_id.clone()), + aud: Some(RegisteredClaimsAud::Single( + self.server_metadata.issuer.clone(), + )), + exp: Some(iat + 60), + // "iat" is required and **MUST** be less than one minute + // https://datatracker.ietf.org/doc/html/rfc9101 + iat: Some(iat), + // atproto oauth-provider requires "jti" to be present + jti: Some(generate_nonce()), + ..Default::default() + } + .into(), + )?, + ), + )); + } + } + Some("none") + if method_supported + .as_ref() + .map_or(false, |v| v.contains(&String::from("none"))) => + { + return Ok((None, None)) + } + _ => {} + } + Err(Error::UnsupportedAuthMethod) + } + fn endpoint(&self, request: &OAuthRequest) -> Option<&String> { + match request { + OAuthRequest::Token(_) => Some(&self.server_metadata.token_endpoint), + OAuthRequest::Revocation => self.server_metadata.revocation_endpoint.as_ref(), + OAuthRequest::Introspection => self.server_metadata.introspection_endpoint.as_ref(), + OAuthRequest::PushedAuthorizationRequest(_) => { + self.server_metadata.pushed_authorization_request_endpoint.as_ref() + } + } + } +} diff --git a/atrium-oauth/oauth-client/src/store.rs b/atrium-oauth/oauth-client/src/store.rs new file mode 100644 index 00000000..0850617c --- /dev/null +++ b/atrium-oauth/oauth-client/src/store.rs @@ -0,0 +1,20 @@ +pub mod memory; +pub mod state; + +use std::error::Error; +use std::future::Future; +use std::hash::Hash; + +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait SimpleStore +where + K: Eq + Hash, + V: Clone, +{ + type Error: Error + Send + Sync + 'static; + + fn get(&self, key: &K) -> impl Future, Self::Error>>; + fn set(&self, key: K, value: V) -> impl Future>; + fn del(&self, key: &K) -> impl Future>; + fn clear(&self) -> impl Future>; +} diff --git a/atrium-oauth/oauth-client/src/store/memory.rs b/atrium-oauth/oauth-client/src/store/memory.rs new file mode 100644 index 00000000..c43c557d --- /dev/null +++ b/atrium-oauth/oauth-client/src/store/memory.rs @@ -0,0 +1,45 @@ +use super::SimpleStore; +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; +use std::sync::{Arc, Mutex}; +use thiserror::Error; + +#[derive(Error, Debug)] +#[error("memory store error")] +pub struct Error; + +// TODO: LRU cache? +pub struct MemorySimpleStore { + store: Arc>>, +} + +impl Default for MemorySimpleStore { + fn default() -> Self { + Self { store: Arc::new(Mutex::new(HashMap::new())) } + } +} + +impl SimpleStore for MemorySimpleStore +where + K: Debug + Eq + Hash + Send + Sync + 'static, + V: Debug + Clone + Send + Sync + 'static, +{ + type Error = Error; + + async fn get(&self, key: &K) -> Result, Self::Error> { + Ok(self.store.lock().unwrap().get(key).cloned()) + } + async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { + self.store.lock().unwrap().insert(key, value); + Ok(()) + } + async fn del(&self, key: &K) -> Result<(), Self::Error> { + self.store.lock().unwrap().remove(key); + Ok(()) + } + async fn clear(&self) -> Result<(), Self::Error> { + self.store.lock().unwrap().clear(); + Ok(()) + } +} diff --git a/atrium-oauth/oauth-client/src/store/state.rs b/atrium-oauth/oauth-client/src/store/state.rs new file mode 100644 index 00000000..d55e3234 --- /dev/null +++ b/atrium-oauth/oauth-client/src/store/state.rs @@ -0,0 +1,17 @@ +use super::memory::MemorySimpleStore; +use super::SimpleStore; +use jose_jwk::Key; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct InternalStateData { + pub iss: String, + pub dpop_key: Key, + pub verifier: String, +} + +pub trait StateStore: SimpleStore {} + +pub type MemoryStateStore = MemorySimpleStore; + +impl StateStore for MemoryStateStore {} diff --git a/atrium-oauth/oauth-client/src/types.rs b/atrium-oauth/oauth-client/src/types.rs new file mode 100644 index 00000000..45ef9bdb --- /dev/null +++ b/atrium-oauth/oauth-client/src/types.rs @@ -0,0 +1,54 @@ +mod client_metadata; +mod metadata; +mod request; +mod response; +mod token; + +pub use client_metadata::{OAuthClientMetadata, TryIntoOAuthClientMetadata}; +pub use metadata::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; +pub use request::{ + AuthorizationCodeChallengeMethod, AuthorizationResponseType, + PushedAuthorizationRequestParameters, TokenGrantType, TokenRequestParameters, +}; +pub use response::{OAuthPusehedAuthorizationRequestResponse, OAuthTokenResponse}; +use serde::Deserialize; +pub use token::TokenSet; + +#[derive(Debug, Deserialize)] +pub enum AuthorizeOptionPrompt { + Login, + None, + Consent, + SelectAccount, +} + +impl From for String { + fn from(value: AuthorizeOptionPrompt) -> Self { + match value { + AuthorizeOptionPrompt::Login => String::from("login"), + AuthorizeOptionPrompt::None => String::from("none"), + AuthorizeOptionPrompt::Consent => String::from("consent"), + AuthorizeOptionPrompt::SelectAccount => String::from("select_account"), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeOptions { + pub redirect_uri: Option, + pub scopes: Option>, // TODO: enum? + pub prompt: Option, +} + +impl Default for AuthorizeOptions { + fn default() -> Self { + Self { redirect_uri: None, scopes: Some(vec![String::from("atproto")]), prompt: None } + } +} + +#[derive(Debug, Deserialize)] +pub struct CallbackParams { + pub code: String, + pub state: Option, + pub iss: Option, +} diff --git a/atrium-oauth/oauth-client/src/types/client_metadata.rs b/atrium-oauth/oauth-client/src/types/client_metadata.rs new file mode 100644 index 00000000..04f2f2bf --- /dev/null +++ b/atrium-oauth/oauth-client/src/types/client_metadata.rs @@ -0,0 +1,37 @@ +use crate::keyset::Keyset; +use jose_jwk::JwkSet; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthClientMetadata { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + pub redirect_uris: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_types: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option, + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 + #[serde(skip_serializing_if = "Option::is_none")] + pub dpop_bound_access_tokens: Option, + // https://datatracker.ietf.org/doc/html/rfc7591#section-2 + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks: Option, + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_signing_alg: Option, +} + +pub trait TryIntoOAuthClientMetadata { + type Error; + + fn try_into_client_metadata( + self, + keyset: &Option, + ) -> core::result::Result; +} diff --git a/atrium-oauth/oauth-client/src/types/metadata.rs b/atrium-oauth/oauth-client/src/types/metadata.rs new file mode 100644 index 00000000..0e40c649 --- /dev/null +++ b/atrium-oauth/oauth-client/src/types/metadata.rs @@ -0,0 +1,64 @@ +use atrium_api::types::string::Language; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthAuthorizationServerMetadata { + // https://datatracker.ietf.org/doc/html/rfc8414#section-2 + pub issuer: String, + pub authorization_endpoint: String, // optional? + pub token_endpoint: String, // optional? + pub jwks_uri: Option, + pub registration_endpoint: Option, + pub scopes_supported: Vec, + pub response_types_supported: Vec, + pub response_modes_supported: Option>, + pub grant_types_supported: Option>, + pub token_endpoint_auth_methods_supported: Option>, + pub token_endpoint_auth_signing_alg_values_supported: Option>, + pub service_documentation: Option, + pub ui_locales_supported: Option>, + pub op_policy_uri: Option, + pub op_tos_uri: Option, + pub revocation_endpoint: Option, + pub revocation_endpoint_auth_methods_supported: Option>, + pub revocation_endpoint_auth_signing_alg_values_supported: Option>, + pub introspection_endpoint: Option, + pub introspection_endpoint_auth_methods_supported: Option>, + pub introspection_endpoint_auth_signing_alg_values_supported: Option>, + pub code_challenge_methods_supported: Option>, + + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + pub subject_types_supported: Option>, + pub require_request_uri_registration: Option, + + // https://datatracker.ietf.org/doc/html/rfc9126#section-5 + pub pushed_authorization_request_endpoint: Option, + pub require_pushed_authorization_requests: Option, + + // https://datatracker.ietf.org/doc/html/rfc9207#section-3 + pub authorization_response_iss_parameter_supported: Option, + + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 + pub dpop_signing_alg_values_supported: Option>, + + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5 + pub client_id_metadata_document_supported: Option, + + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada + pub protected_resources: Option>, +} + +// https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/ +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#section-2 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Option>, + pub jwks_uri: Option, + pub scopes_supported: Vec, + pub bearer_methods_supported: Option>, + pub resource_signing_alg_values_supported: Option>, + pub resource_documentation: Option, + pub resource_policy_uri: Option, + pub resource_tos_uri: Option, +} diff --git a/atrium-oauth/oauth-client/src/types/request.rs b/atrium-oauth/oauth-client/src/types/request.rs new file mode 100644 index 00000000..a5b71474 --- /dev/null +++ b/atrium-oauth/oauth-client/src/types/request.rs @@ -0,0 +1,62 @@ +use serde::Serialize; + +#[allow(dead_code)] +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorizationResponseType { + Code, + Token, + // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) + IdToken, +} + +#[allow(dead_code)] +#[derive(Serialize)] +#[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, +} + +#[allow(dead_code)] +#[derive(Serialize)] +pub enum AuthorizationCodeChallengeMethod { + S256, + #[serde(rename = "plain")] + Plain, +} + +#[derive(Serialize)] +pub struct PushedAuthorizationRequestParameters { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + pub response_type: AuthorizationResponseType, + pub redirect_uri: String, + pub state: String, + pub scope: Option, + // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes + pub response_mode: Option, + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 + pub code_challenge: String, + pub code_challenge_method: AuthorizationCodeChallengeMethod, + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + pub login_hint: Option, + pub prompt: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenGrantType { + AuthorizationCode, +} + +#[derive(Serialize)] +pub struct TokenRequestParameters { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + pub grant_type: TokenGrantType, + pub code: String, + pub redirect_uri: String, + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 + pub code_verifier: String, +} diff --git a/atrium-oauth/oauth-client/src/types/response.rs b/atrium-oauth/oauth-client/src/types/response.rs new file mode 100644 index 00000000..dbc6bc65 --- /dev/null +++ b/atrium-oauth/oauth-client/src/types/response.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthPusehedAuthorizationRequestResponse { + pub request_uri: String, + pub expires_in: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum OAuthTokenType { + DPoP, + Bearer, +} + +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OAuthTokenResponse { + pub access_token: String, + pub token_type: OAuthTokenType, + pub expires_in: Option, + pub refresh_token: Option, + pub scope: Option, + // ATPROTO extension: add the sub claim to the token response to allow + // clients to resolve the PDS url (audience) using the did resolution + // mechanism. + pub sub: Option, +} diff --git a/atrium-oauth/oauth-client/src/types/token.rs b/atrium-oauth/oauth-client/src/types/token.rs new file mode 100644 index 00000000..069e9fef --- /dev/null +++ b/atrium-oauth/oauth-client/src/types/token.rs @@ -0,0 +1,17 @@ +use super::response::OAuthTokenType; +use atrium_api::types::string::Datetime; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct TokenSet { + pub iss: String, + pub sub: String, + pub aud: String, + pub scope: Option, + + pub refresh_token: Option, + pub access_token: String, + pub token_type: OAuthTokenType, + + pub expires_at: Option, +} diff --git a/atrium-oauth/oauth-client/src/utils.rs b/atrium-oauth/oauth-client/src/utils.rs new file mode 100644 index 00000000..951fcf64 --- /dev/null +++ b/atrium-oauth/oauth-client/src/utils.rs @@ -0,0 +1,62 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use elliptic_curve::SecretKey; +use jose_jwk::{crypto, Key}; +use rand::{rngs::ThreadRng, CryptoRng, RngCore}; +use std::cmp::Ordering; + +pub fn generate_key(allowed_algos: &[String]) -> Option { + for alg in allowed_algos { + #[allow(clippy::single_match)] + match alg.as_str() { + "ES256" => { + return Some(Key::from(&crypto::Key::from(SecretKey::::random( + &mut ThreadRng::default(), + )))); + } + _ => { + // TODO: Implement other algorithms? + } + } + } + None +} + +pub fn generate_nonce() -> String { + URL_SAFE_NO_PAD.encode(get_random_values::<_, 16>(&mut ThreadRng::default())) +} + +pub fn get_random_values(rng: &mut R) -> [u8; LEN] +where + R: RngCore + CryptoRng, +{ + let mut bytes = [0u8; LEN]; + rng.fill_bytes(&mut bytes); + bytes +} + +// 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) +pub fn compare_algos(a: &String, b: &String) -> Ordering { + if a == "ES256K" { + return Ordering::Less; + } + if b == "ES256K" { + return Ordering::Greater; + } + for prefix in ["ES", "PS", "RS"] { + if let Some(stripped_a) = a.strip_prefix(prefix) { + if let Some(stripped_b) = b.strip_prefix(prefix) { + if let (Ok(len_a), Ok(len_b)) = + (stripped_a.parse::(), stripped_b.parse::()) + { + return len_a.cmp(&len_b); + } + } else { + return Ordering::Less; + } + } else if b.starts_with(prefix) { + return Ordering::Greater; + } + } + Ordering::Equal +} diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index 2c920b87..27040280 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["atproto", "bluesky", "atrium", "sdk"] [dependencies] anyhow.workspace = true -atrium-api.workspace = true +atrium-api = { workspace = true, features = ["agent", "bluesky"] } atrium-xrpc-client = { workspace = true, optional = true } chrono.workspace = true psl = { version = "2.1.42", optional = true }