diff --git a/.gitignore b/.gitignore index ef32cb0..1929fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ scripts/pds-dev/pds-data/ scripts/pds-dev/pds.env tests/.testenv result +.venv +__pycache__ +testuser1.toml +testuser.toml diff --git a/Cargo.lock b/Cargo.lock index 24ac756..c8f3c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -67,24 +82,235 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atproto-dasl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d63f8fb837f3bfa4440e3f38da8a21c827abea8819577893a56ac3e21d5d7ae2" +dependencies = [ + "blake3", + "cid", + "futures", + "multihash", + "serde", + "serde_bytes", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "atproto-identity" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13837fe8e5e93b9b842bfe2aaedd539f724021d52e5bedfb9b9fed52a5e0f46f" +dependencies = [ + "anyhow", + "async-trait", + "atproto-dasl", + "base64", + "chrono", + "data-encoding", + "ecdsa", + "elliptic-curve", + "hickory-resolver", + "k256", + "lru", + "multibase", + "p256", + "p384", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "atproto-oauth" +version = "0.14.0" +dependencies = [ + "anyhow", + "async-trait", + "atproto-identity", + "base64", + "chrono", + "ecdsa", + "elliptic-curve", + "k256", + "multibase", + "p256", + "p384", + "rand 0.8.5", + "reqwest", + "reqwest-chain", + "reqwest-middleware", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -119,6 +345,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cid" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" +dependencies = [ + "core2", + "multibase", + "multihash", + "unsigned-varint", +] + [[package]] name = "clap" version = "4.5.60" @@ -165,6 +416,159 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -176,6 +580,66 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +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", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -198,12 +662,28 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -219,6 +699,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -226,6 +721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -234,6 +730,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -246,12 +776,28 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -292,12 +838,44 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -313,6 +891,70 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -352,6 +994,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -362,9 +1010,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -407,10 +1057,36 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] @@ -533,6 +1209,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -571,6 +1259,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -616,12 +1318,32 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.2.0" @@ -631,12 +1353,34 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.1.1" @@ -648,6 +1392,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +dependencies = [ + "core2", + "unsigned-varint", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -657,11 +1440,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -669,6 +1465,32 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "serdect", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "serdect", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -696,7 +1518,15 @@ dependencies = [ name = "pds-git-remote" version = "0.1.0" dependencies = [ + "anyhow", + "atproto-identity", + "atproto-oauth", + "axum", + "base64", + "chrono", "clap", + "elliptic-curve", + "p256", "reqwest", "serde", "serde_json", @@ -704,6 +1534,16 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ulid", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", ] [[package]] @@ -724,6 +1564,22 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -752,6 +1608,16 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", + "serdect", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -774,8 +1640,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", - "thiserror", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -790,13 +1656,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -811,7 +1677,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -832,13 +1698,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.9.2" +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ - "rand_chacha", - "rand_core", + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -848,7 +1735,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -894,7 +1790,10 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -903,6 +1802,8 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -924,6 +1825,49 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest-chain" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5c014fb79a8227db44a0433d748107750d2550b7fca55c59a3d7ee7d2ee2b2" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest-middleware", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1010,6 +1954,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -1026,6 +1985,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1059,6 +2028,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1071,6 +2051,27 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1096,6 +2097,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -1108,6 +2119,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -1118,6 +2139,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1167,6 +2198,33 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.25.0" @@ -1180,13 +2238,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1246,7 +2324,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -1272,6 +2350,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1285,6 +2376,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1385,6 +2477,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1397,6 +2511,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + [[package]] name = "untrusted" version = "0.9.0" @@ -1427,12 +2547,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1588,12 +2725,91 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1621,6 +2837,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1654,6 +2885,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1666,6 +2903,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1678,6 +2921,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1702,6 +2951,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1714,6 +2969,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1726,6 +2987,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1738,6 +3005,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1750,6 +3023,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 1f1c8dc..e3fd90a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,12 @@ name = "git-remote-pds" path = "src/main.rs" [dependencies] +atproto-identity = "0.14.0" +atproto-oauth = { path = "../atproto-oauth", default-features = false } +axum = "0.8" +chrono = "0.4" clap = { version = "4.5", features = ["derive"] } +elliptic-curve = { version = "0.13", features = ["jwk"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -18,9 +23,13 @@ tempfile = "3.13.0" tokio = { version = "1", features = ["full"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +ulid = "1" [features] e2e = [] [dev-dependencies] +anyhow = "1.0" +base64 = "0.22" +p256 = { version = "0.13", features = ["jwk"] } tempfile = "3.13.0" diff --git a/playwright-test/conftest.py b/playwright-test/conftest.py new file mode 100644 index 0000000..da9e084 --- /dev/null +++ b/playwright-test/conftest.py @@ -0,0 +1,85 @@ +"""Pytest fixtures for pds-git-remote OAuth playwright tests. + +Provides test configuration, binary location, PDS session tokens, +and a shared Playwright browser context. +""" + +import json +import os +import subprocess +import tempfile + +import pytest +import requests +import toml + + +OAUTH_CALLBACK_PORT = 8271 + + +@pytest.fixture(scope="session") +def test_config(): + """Load test user configuration from testuser.toml.""" + config_path = os.path.join(os.path.dirname(__file__), "testuser.toml") + with open(config_path) as f: + return toml.load(f) + + +@pytest.fixture(scope="session") +def cargo_binary(): + """Locate the built git-remote-pds binary.""" + repo_root = os.path.dirname(os.path.dirname(__file__)) + result = subprocess.run( + ["cargo", "metadata", "--format-version", "1", "--no-deps"], + cwd=repo_root, + capture_output=True, + text=True, + ) + metadata = json.loads(result.stdout) + target_dir = metadata["target_directory"] + binary_path = os.path.join(target_dir, "debug", "git-remote-pds") + + assert os.path.isfile(binary_path), ( + f"git-remote-pds binary not found at {binary_path}. Run `cargo build` first." + ) + + return {"binary": binary_path, "debug_dir": os.path.join(target_dir, "debug")} + + +@pytest.fixture(scope="session") +def pds_tokens(test_config): + """Get createSession tokens for read-back verification (clone).""" + resp = requests.post( + f"{test_config['pds']}/xrpc/com.atproto.server.createSession", + json={ + "identifier": test_config["handle"], + "password": test_config["password"], + }, + ) + assert resp.status_code == 200, f"createSession failed: {resp.text}" + data = resp.json() + return {"access_jwt": data["accessJwt"], "did": data["did"]} + + +@pytest.fixture(scope="session") +def auth_config_dir(): + """Create a temporary config directory for auth.json during tests.""" + tmpdir = tempfile.mkdtemp(prefix="pds-git-remote-test-config-") + yield tmpdir + # cleanup + import shutil + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture(scope="session") +def browser_context(): + """Launch a headless Chromium browser context (shared across tests).""" + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + context = browser.new_context() + yield context + context.close() + browser.close() + pw.stop() diff --git a/playwright-test/requirements.txt b/playwright-test/requirements.txt new file mode 100644 index 0000000..9aa09eb --- /dev/null +++ b/playwright-test/requirements.txt @@ -0,0 +1,4 @@ +playwright +pytest +requests +toml diff --git a/playwright-test/run.sh b/playwright-test/run.sh new file mode 100755 index 0000000..6311460 --- /dev/null +++ b/playwright-test/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Installing Python dependencies ===" +pip install -q -r "$SCRIPT_DIR/requirements.txt" +python -m playwright install chromium + +echo "=== Building git-remote-pds ===" +(cd "$REPO_DIR" && cargo build --quiet) + +echo "=== Running OAuth playwright tests ===" +(cd "$SCRIPT_DIR" && pytest -v test_oauth_push.py "$@") diff --git a/playwright-test/test_oauth_push.py b/playwright-test/test_oauth_push.py new file mode 100644 index 0000000..c146617 --- /dev/null +++ b/playwright-test/test_oauth_push.py @@ -0,0 +1,333 @@ +"""End-to-end tests for pds-git-remote OAuth login and push. + +Tests: +1. OAuth login via browser (Playwright automates PDS consent) +2. Push a repo using the OAuth/DPoP credentials +3. Incremental push with a second commit +""" + +import os +import re +import subprocess +import tempfile +import time +import threading + +import pytest + + +def read_stderr_lines(proc, lines, stop_event): + """Read stderr lines from a subprocess into a shared list.""" + while not stop_event.is_set(): + line = proc.stderr.readline() + if line: + lines.append(line) + elif proc.poll() is not None: + break + + +def test_oauth_login(test_config, cargo_binary, browser_context, auth_config_dir): + """Test browser-based OAuth login stores DPoP credentials.""" + binary = cargo_binary["binary"] + handle = test_config["handle"] + pds_url = test_config["pds"] + password = test_config["password"] + + # use a custom HOME so auth.json goes to our temp dir + env = os.environ.copy() + env["HOME"] = auth_config_dir + env["RUST_LOG"] = "debug" + + # start the oauth-login subprocess + proc = subprocess.Popen( + [ + binary, + "auth", "oauth-login", + "--handle", handle, + "--pds-url", pds_url, + "--port", "8271", + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # collect stderr lines in a background thread + stderr_lines = [] + stop_event = threading.Event() + reader = threading.Thread(target=read_stderr_lines, args=(proc, stderr_lines, stop_event)) + reader.daemon = True + reader.start() + + # wait for the authorization URL to appear in stderr + # the tool prints it on a line by itself (indented) after "Open this URL" + auth_url = None + deadline = time.time() + 30 + seen_open_prompt = False + while time.time() < deadline: + for line in stderr_lines: + stripped = line.strip() + # detect the "Open this URL" prompt + if "open this url" in stripped.lower(): + seen_open_prompt = True + continue + # the next line with an https URL after the prompt is the auth URL + if seen_open_prompt and stripped.startswith("http") and "request_uri=" in stripped: + auth_url = stripped + break + if auth_url: + break + time.sleep(0.3) + + assert auth_url is not None, ( + f"Did not find authorization URL in stderr within 30s.\n" + f"Stderr so far: {''.join(stderr_lines)}" + ) + + print(f"Authorization URL: {auth_url}") + + # use playwright to complete the OAuth flow + page = browser_context.new_page() + + try: + # navigate directly to the PDS authorization page + page.goto(auth_url, wait_until="networkidle", timeout=15_000) + + # enter password on the PDS login form + password_input = page.locator("input[type='password']") + password_input.wait_for(state="visible", timeout=15_000) + password_input.fill(password) + page.get_by_role("button", name="Sign in").click() + + # handle consent page — click accept/authorize buttons until redirected + consent_deadline = time.time() + 30 + redirected = False + while time.time() < consent_deadline: + page.wait_for_load_state("networkidle", timeout=10_000) + + # check if we've been redirected to the localhost callback + if "127.0.0.1:8271" in page.url and "code=" in page.url: + redirected = True + break + + # click any consent/authorize buttons + for btn_name in ["Accept", "Authorize", "Allow", "Continue", "Approve"]: + btn = page.get_by_role("button", name=btn_name) + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click() + time.sleep(1) + break + else: + time.sleep(0.5) + + assert redirected, ( + f"Browser did not redirect to callback, stuck at: {page.url}" + ) + + finally: + page.close() + + # wait for the subprocess to finish (it should exit after receiving callback) + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + stop_event.set() + reader.join(timeout=5) + + full_stderr = "".join(stderr_lines) + assert proc.returncode == 0, ( + f"oauth-login exited with code {proc.returncode}.\nStderr: {full_stderr}" + ) + + # verify credentials were stored + auth_json_path = os.path.join( + auth_config_dir, ".config", "pds-git-remote", "auth.json" + ) + assert os.path.isfile(auth_json_path), ( + f"auth.json not found at {auth_json_path}" + ) + + import json + with open(auth_json_path) as f: + auth_config = json.load(f) + + cred = auth_config["credentials"].get(handle) + assert cred is not None, f"No credential stored for {handle}" + assert cred.get("dpop_key") is not None, "Credential missing dpop_key" + assert cred.get("did") == test_config["did"], ( + f"DID mismatch: {cred.get('did')} != {test_config['did']}" + ) + print(f"OAuth login successful: {cred['did']} @ {cred['pds_url']}") + + +def test_oauth_push(test_config, cargo_binary, auth_config_dir, pds_tokens): + """Test pushing a repo using OAuth/DPoP credentials from oauth-login.""" + handle = test_config["handle"] + debug_dir = cargo_binary["debug_dir"] + + # create a unique repo name + repo_name = f"playwright-test-{int(time.time())}" + + # create a temp git repo with a test file + repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-push-test-") + env = os.environ.copy() + env["HOME"] = auth_config_dir + env["PATH"] = debug_dir + ":" + env.get("PATH", "") + env["PDS_URL"] = test_config["pds"] + + try: + # init repo + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, + capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir, + check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, + check=True, capture_output=True) + + # create test file + test_file = os.path.join(repo_dir, "hello.txt") + with open(test_file, "w") as f: + f.write(f"Hello from playwright test {repo_name}\n") + + subprocess.run(["git", "add", "hello.txt"], cwd=repo_dir, check=True, + capture_output=True) + subprocess.run(["git", "commit", "-m", "initial commit"], cwd=repo_dir, + check=True, capture_output=True) + + # add PDS remote + pds_remote = f"pds://{handle}/{repo_name}" + subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir, + check=True, capture_output=True) + + # push using OAuth/DPoP credentials (from auth.json) + result = subprocess.run( + ["git", "push", "origin", "main"], + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, ( + f"git push failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + print(f"Push successful to {pds_remote}") + + # verify by cloning back with createSession token + clone_dir = tempfile.mkdtemp(prefix="pds-git-remote-clone-test-") + clone_env = os.environ.copy() + clone_env["PATH"] = debug_dir + ":" + clone_env.get("PATH", "") + clone_env["PDS_ACCESS_TOKEN"] = pds_tokens["access_jwt"] + clone_env["PDS_DID"] = pds_tokens["did"] + clone_env["PDS_URL"] = test_config["pds"] + + clone_result = subprocess.run( + ["git", "clone", pds_remote, clone_dir], + env=clone_env, capture_output=True, text=True, timeout=60, + ) + assert clone_result.returncode == 0, ( + f"git clone failed:\nstdout: {clone_result.stdout}\nstderr: {clone_result.stderr}" + ) + + # verify file content + cloned_file = os.path.join(clone_dir, "hello.txt") + assert os.path.isfile(cloned_file), "hello.txt not found in clone" + with open(cloned_file) as f: + content = f.read() + assert f"Hello from playwright test {repo_name}" in content, ( + f"File content mismatch: {content}" + ) + print("Clone verification successful") + + finally: + import shutil + shutil.rmtree(repo_dir, ignore_errors=True) + + +def test_oauth_incremental_push(test_config, cargo_binary, auth_config_dir, pds_tokens): + """Test incremental push (second commit) using OAuth/DPoP credentials.""" + handle = test_config["handle"] + debug_dir = cargo_binary["debug_dir"] + + # create a unique repo name + repo_name = f"playwright-incr-{int(time.time())}" + + repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-incr-test-") + env = os.environ.copy() + env["HOME"] = auth_config_dir + env["PATH"] = debug_dir + ":" + env.get("PATH", "") + env["PDS_URL"] = test_config["pds"] + + try: + # init repo + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, + capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir, + check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, + check=True, capture_output=True) + + pds_remote = f"pds://{handle}/{repo_name}" + subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir, + check=True, capture_output=True) + + # first commit + push + file1 = os.path.join(repo_dir, "file1.txt") + with open(file1, "w") as f: + f.write("First file\n") + subprocess.run(["git", "add", "file1.txt"], cwd=repo_dir, check=True, + capture_output=True) + subprocess.run(["git", "commit", "-m", "first commit"], cwd=repo_dir, + check=True, capture_output=True) + + result = subprocess.run( + ["git", "push", "origin", "main"], + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, ( + f"First push failed:\nstderr: {result.stderr}" + ) + + # second commit + push (incremental) + file2 = os.path.join(repo_dir, "file2.txt") + with open(file2, "w") as f: + f.write("Second file\n") + subprocess.run(["git", "add", "file2.txt"], cwd=repo_dir, check=True, + capture_output=True) + subprocess.run(["git", "commit", "-m", "second commit"], cwd=repo_dir, + check=True, capture_output=True) + + result = subprocess.run( + ["git", "push", "origin", "main"], + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, ( + f"Incremental push failed:\nstderr: {result.stderr}" + ) + print("Incremental push successful") + + # verify by cloning back + clone_dir = tempfile.mkdtemp(prefix="pds-git-remote-incr-clone-") + clone_env = os.environ.copy() + clone_env["PATH"] = debug_dir + ":" + clone_env.get("PATH", "") + clone_env["PDS_ACCESS_TOKEN"] = pds_tokens["access_jwt"] + clone_env["PDS_DID"] = pds_tokens["did"] + clone_env["PDS_URL"] = test_config["pds"] + + clone_result = subprocess.run( + ["git", "clone", pds_remote, clone_dir], + env=clone_env, capture_output=True, text=True, timeout=60, + ) + assert clone_result.returncode == 0, ( + f"Clone failed:\nstderr: {clone_result.stderr}" + ) + + assert os.path.isfile(os.path.join(clone_dir, "file1.txt")) + assert os.path.isfile(os.path.join(clone_dir, "file2.txt")) + with open(os.path.join(clone_dir, "file2.txt")) as f: + assert "Second file" in f.read() + print("Incremental clone verification successful") + + finally: + import shutil + shutil.rmtree(repo_dir, ignore_errors=True) diff --git a/scripts/remote-test/run-dpop.sh b/scripts/remote-test/run-dpop.sh new file mode 100755 index 0000000..1f12273 --- /dev/null +++ b/scripts/remote-test/run-dpop.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Tests git-remote-pds with DPoP-bound OAuth tokens. +# +# Unlike run.sh (which uses createSession/Bearer auth), this tests the +# DPoP auth path used when tokens come from ATProto OAuth. +# +# Prerequisites: obtain DPoP credentials from an OAuth login (e.g., via +# lichen-server) and export them as env vars. +# +# Usage: +# PDS_URL=https://my-pds.example.com \ +# PDS_ACCESS_TOKEN= \ +# PDS_DID=did:plc:xxx \ +# PDS_DPOP_KEY='{"kid":"...","alg":"ES256","kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."}' \ +# ./run-dpop.sh +# +# Environment variables: +# PDS_URL — full URL of the PDS (required) +# PDS_ACCESS_TOKEN — DPoP-bound access token from OAuth (required) +# PDS_DID — DID of the authenticated user (required) +# PDS_DPOP_KEY — serialized WrappedJsonWebKey JSON with private key (required) +# PDS_HANDLE — AT Protocol handle (optional, defaults to extracting from DID) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CRATE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# ── validate required env vars ─────────────────────────────────────── +for var in PDS_URL PDS_ACCESS_TOKEN PDS_DID PDS_DPOP_KEY; do + if [ -z "${!var:-}" ]; then + echo "ERROR: ${var} is not set" + echo "" + echo "Usage: PDS_URL=... PDS_ACCESS_TOKEN=... PDS_DID=... PDS_DPOP_KEY=... $0" + echo "" + echo "Obtain these values from an OAuth login (e.g., lichen-server debug endpoint)." + exit 1 + fi +done + +# Default handle from DID if not provided +PDS_HANDLE="${PDS_HANDLE:-${PDS_DID}}" + +echo "PDS_URL = ${PDS_URL}" +echo "PDS_DID = ${PDS_DID}" +echo "PDS_HANDLE = ${PDS_HANDLE}" +echo "Auth mode = DPoP" +echo "" + +# ── unique rkey to avoid collisions across runs ────────────────────── +RKEY="test-dpop-$(date +%s%N)" +REPO_NAME="${RKEY}" + +# ── temp dirs, cleaned up on exit ──────────────────────────────────── +TMPDIR_BASE="$(mktemp -d)" +cleanup() { + echo "" + echo "Cleaning up temp dirs..." + rm -rf "${TMPDIR_BASE}" +} +trap cleanup EXIT + +PUSH_REPO="${TMPDIR_BASE}/push-repo" +CLONE_REPO="${TMPDIR_BASE}/clone-repo" + +# ── build the binary ───────────────────────────────────────────────── +echo "=== Building git-remote-pds ===" +cargo build -p pds-git-remote --quiet +BINARY="$(cargo metadata --format-version 1 --no-deps \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["target_directory"])')/debug/git-remote-pds" + +if [ ! -x "${BINARY}" ]; then + echo "ERROR: binary not found at ${BINARY}" + exit 1 +fi +echo "Binary: ${BINARY}" +echo "" + +# put the binary on PATH so git can find it as a remote helper +export PATH="$(dirname "${BINARY}"):${PATH}" + +# ── export DPoP env vars for the remote helper ─────────────────────── +export PDS_URL PDS_ACCESS_TOKEN PDS_DID PDS_DPOP_KEY + +# ── verify PDS is reachable ────────────────────────────────────────── +echo "=== Checking PDS health ===" +HEALTH=$(curl -sf "${PDS_URL}/xrpc/_health" 2>&1) || { + echo "ERROR: PDS not reachable at ${PDS_URL}/xrpc/_health" + echo "Response: ${HEALTH}" + exit 1 +} +echo "PDS health: ${HEALTH}" +echo "" + +# ── test push (DPoP auth) ─────────────────────────────────────────── +echo "=== Test: initial push (DPoP auth) ===" +mkdir -p "${PUSH_REPO}" +cd "${PUSH_REPO}" +git init --quiet +git checkout -b main + +echo "hello from DPoP test" > file.txt +mkdir -p subdir +echo "nested dpop content" > subdir/nested.txt +git add . +git commit --quiet -m "initial commit (DPoP)" + +REMOTE_URL="pds://${PDS_HANDLE}/${REPO_NAME}" +git remote add pds "${REMOTE_URL}" +git push pds main + +echo "Push OK (DPoP auth verified!)" +echo "" + +# ── test clone ─────────────────────────────────────────────────────── +echo "=== Test: clone ===" +cd "${TMPDIR_BASE}" +git clone "${REMOTE_URL}" clone-repo + +# verify files match +diff "${PUSH_REPO}/file.txt" "${CLONE_REPO}/file.txt" +diff "${PUSH_REPO}/subdir/nested.txt" "${CLONE_REPO}/subdir/nested.txt" +echo "Clone OK — files match" +echo "" + +# ── test incremental push + fetch ──────────────────────────────────── +echo "=== Test: incremental push (DPoP auth) ===" +cd "${PUSH_REPO}" +echo "second commit dpop content" > file2.txt +git add . +git commit --quiet -m "second commit (DPoP)" +git push pds main + +echo "Incremental push OK" +echo "" + +echo "=== Test: fetch into clone ===" +cd "${CLONE_REPO}" +git fetch origin +git merge --ff-only origin/main + +diff "${PUSH_REPO}/file2.txt" "${CLONE_REPO}/file2.txt" +echo "Fetch OK — incremental content matches" +echo "" + +# ── done ───────────────────────────────────────────────────────────── +echo "=== All DPoP remote tests passed ===" diff --git a/src/auth.rs b/src/auth.rs index 6baadbf..5c5766b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -9,6 +9,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use atproto_identity::key::KeyData; + use crate::pds_client::PdsClient; /// Stored credential for a single AT Protocol account. @@ -19,6 +21,15 @@ pub struct StoredCredential { pub did: String, pub access_jwt: String, pub refresh_jwt: String, + /// Serialized DPoP private key JWK (present for OAuth-based logins). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dpop_key: Option, + /// Serialized signing key JWK (for OAuth client assertions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signing_key: Option, + /// Unix timestamp when the access token expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_expiry: Option, } /// Top-level auth config file. @@ -29,11 +40,12 @@ pub struct AuthConfig { } /// Resolved auth info needed for push operations. -#[derive(Debug, Clone)] pub struct ResolvedAuth { pub pds_url: String, pub did: String, pub access_jwt: String, + /// If set, the access token is DPoP-bound and requires proof headers. + pub dpop_key: Option, } /// Returns the path to the auth config file. @@ -94,6 +106,9 @@ pub async fn login_and_store( did: session.did.clone(), access_jwt: session.access_jwt, refresh_jwt: session.refresh_jwt, + dpop_key: None, + signing_key: None, + token_expiry: None, }; // save to config @@ -114,10 +129,16 @@ pub async fn login_and_store( pub async fn resolve_auth(handle: &str, pds_url: &str) -> Result { // 1. direct token from env if let (Ok(token), Ok(did)) = (std::env::var("PDS_ACCESS_TOKEN"), std::env::var("PDS_DID")) { + let dpop_key = std::env::var("PDS_DPOP_KEY") + .ok() + .map(|k| crate::dpop::parse_dpop_key(&k)) + .transpose()?; + return Ok(ResolvedAuth { pds_url: pds_url.to_string(), did, access_jwt: token, + dpop_key, }); } @@ -130,15 +151,24 @@ pub async fn resolve_auth(handle: &str, pds_url: &str) -> Result Result { + let wrapped: WrappedJsonWebKey = serde_json::from_str(jwk_json) + .map_err(|e| format!("failed to parse DPoP key JSON: {}", e))?; + + to_key_data(&wrapped).map_err(|e| format!("failed to convert JWK to key data: {}", e)) +} + +/// Generates a DPoP proof JWT for an authenticated XRPC request. +/// +/// Returns the signed compact JWT string (`header.claims.signature`). +/// If `nonce` is provided, it is included in the claims (required after +/// a PDS nonce challenge). +pub fn make_dpop_proof( + key_data: &KeyData, + http_method: &str, + http_uri: &str, + access_token: &str, + nonce: Option<&str>, +) -> Result { + match nonce { + None => { + // Use atproto-oauth's request_dpop directly + let (token, _header, _claims) = + request_dpop(key_data, http_method, http_uri, access_token) + .map_err(|e| format!("failed to generate DPoP proof: {}", e))?; + Ok(token) + } + Some(nonce_value) => { + // Build proof manually with nonce included + let public_key_data = atproto_identity::key::to_public(key_data) + .map_err(|e| format!("failed to derive public key: {}", e))?; + let dpop_jwk: elliptic_curve::JwkEcKey = + TryInto::::try_into(&public_key_data) + .map_err(|e| format!("failed to convert to JWK: {}", e))?; + + let header = Header { + type_: Some("dpop+jwt".to_string()), + algorithm: Some("ES256".to_string()), + json_web_key: Some(dpop_jwk), + key_id: None, + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("system time error: {}", e))? + .as_secs(); + + let claims = Claims::new(JoseClaims { + auth: Some(challenge(access_token)), + expiration: Some(now + 30), + http_method: Some(http_method.to_string()), + http_uri: Some(http_uri.to_string()), + issued_at: Some(now), + json_web_token_id: Some(Ulid::new().to_string()), + nonce: Some(nonce_value.to_string()), + ..Default::default() + }); + + mint(key_data, &header, &claims) + .map_err(|e| format!("failed to mint DPoP proof with nonce: {}", e)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atproto_identity::key::{KeyData, KeyType}; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + /// Generate a random P-256 key and serialize it as a WrappedJsonWebKey JSON string. + fn make_test_key() -> (KeyData, String) { + // Generate a random P-256 secret key + let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec()); + + // Convert to WrappedJsonWebKey JSON + let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap(); + let json = serde_json::to_string(&wrapped).unwrap(); + + (key_data, json) + } + + #[test] + fn parse_dpop_key_round_trip() { + let (original_key_data, json) = make_test_key(); + let parsed = parse_dpop_key(&json).unwrap(); + assert_eq!(parsed.key_type(), original_key_data.key_type()); + } + + #[test] + fn parse_dpop_key_invalid_json() { + assert!(parse_dpop_key("not json").is_err()); + } + + #[test] + fn parse_dpop_key_missing_private_key() { + // Public-only JWK should fail or produce a public key type + let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec()); + let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap(); + + // Serialize and strip the "d" parameter to make it public-only + let mut value: serde_json::Value = serde_json::to_value(&wrapped).unwrap(); + value.as_object_mut().unwrap().remove("d"); + let json = serde_json::to_string(&value).unwrap(); + + // Should parse but produce a public key (not useful for signing) + let result = parse_dpop_key(&json); + match result { + Ok(kd) => assert_eq!(*kd.key_type(), KeyType::P256Public), + Err(_) => {} // also acceptable + } + } + + #[test] + fn make_dpop_proof_produces_valid_jwt() { + let (key_data, _json) = make_test_key(); + let proof = make_dpop_proof( + &key_data, + "POST", + "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob", + "test-access-token", + None, + ) + .unwrap(); + + // JWT has 3 parts + let parts: Vec<&str> = proof.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); + + // Decode and verify header + let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); + assert_eq!(header["typ"], "dpop+jwt"); + assert_eq!(header["alg"], "ES256"); + assert!(header["jwk"].is_object()); + + // Decode and verify claims + let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap(); + assert_eq!(claims["htm"], "POST"); + assert_eq!( + claims["htu"], + "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob" + ); + assert!(claims["jti"].is_string()); + assert!(claims["iat"].is_number()); + assert!(claims["exp"].is_number()); + assert!(claims["ath"].is_string()); + } + + #[test] + fn make_dpop_proof_with_nonce() { + let (key_data, _json) = make_test_key(); + let proof = make_dpop_proof( + &key_data, + "GET", + "https://pds.example.com/xrpc/com.atproto.repo.getRecord", + "token", + Some("server-nonce-123"), + ) + .unwrap(); + + let parts: Vec<&str> = proof.split('.').collect(); + let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap(); + assert_eq!(claims["nonce"], "server-nonce-123"); + } + + #[test] + fn make_dpop_proof_signature_verifies() { + let (key_data, _json) = make_test_key(); + let proof = make_dpop_proof( + &key_data, + "POST", + "https://pds.example.com/xrpc/test", + "token123", + None, + ) + .unwrap(); + + // Verify using atproto-oauth's jwt::verify + let public_key = atproto_identity::key::to_public(&key_data).unwrap(); + let claims = atproto_oauth::jwt::verify(&proof, &public_key).unwrap(); + assert_eq!(claims.jose.http_method.as_deref(), Some("POST")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4de7230..ece8cee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,11 @@ pub mod auth; pub mod bundle; pub mod chunk; +pub mod dpop; pub mod fetch; pub mod identity; +pub mod oauth_login; +pub mod oauth_server; pub mod pds_client; pub mod push; pub mod types; diff --git a/src/main.rs b/src/main.rs index 8bf20ae..34167bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ enum CliCommand { #[derive(Subcommand)] enum AuthAction { - /// Log in to a PDS and store credentials + /// Log in to a PDS using handle + password (createSession) Login { /// PDS server URL #[arg(long)] @@ -39,6 +39,18 @@ enum AuthAction { #[arg(long)] handle: String, }, + /// Log in via browser-based AT Protocol OAuth (DPoP-bound tokens) + OauthLogin { + /// AT Protocol handle + #[arg(long)] + handle: String, + /// PDS server URL (optional, auto-discovered from handle) + #[arg(long)] + pds_url: Option, + /// Port for the localhost OAuth callback server + #[arg(long, default_value = "8271")] + port: u16, + }, /// Show stored credentials Status, /// Remove stored credentials for a handle @@ -80,6 +92,13 @@ async fn main() { AuthAction::Login { pds_url, handle } => { handle_login(&pds_url, &handle).await; } + AuthAction::OauthLogin { + handle, + pds_url, + port, + } => { + handle_oauth_login(&handle, pds_url.as_deref(), port).await; + } AuthAction::Status => { handle_status(); } @@ -90,6 +109,14 @@ async fn main() { } } +/// Handles `auth oauth-login` — browser-based OAuth login with DPoP. +async fn handle_oauth_login(handle: &str, pds_url: Option<&str>, port: u16) { + if let Err(e) = pds_git_remote::oauth_login::oauth_login(handle, pds_url, port).await { + eprintln!("OAuth login failed: {}", e); + std::process::exit(1); + } +} + /// Handles `auth login` — reads password from env or stdin, logs in, stores credential. async fn handle_login(pds_url: &str, handle: &str) { // read password from PDS_PASSWORD env var or stdin diff --git a/src/oauth_login.rs b/src/oauth_login.rs new file mode 100644 index 0000000..6c0657d --- /dev/null +++ b/src/oauth_login.rs @@ -0,0 +1,254 @@ +//! Browser-based AT Protocol OAuth login flow. +//! +//! Orchestrates the full OAuth 2.0 + DPoP login: +//! 1. Resolve handle → DID → PDS +//! 2. Discover authorization server +//! 3. Generate keys (signing, DPoP, PKCE) +//! 4. Start localhost callback server +//! 5. Make PAR request +//! 6. Open browser to authorization URL +//! 7. Wait for callback with authorization code +//! 8. Exchange code for DPoP-bound tokens +//! 9. Store credential with DPoP key + +use crate::auth; +use crate::identity; +use crate::oauth_server; + +use atproto_identity::key::{KeyType, generate_key}; +use atproto_oauth::jwk; +use atproto_oauth::pkce; +use atproto_oauth::resources; +use atproto_oauth::workflow::{self, OAuthClient, OAuthRequest, OAuthRequestState}; + +/// Runs the full OAuth login flow for a handle. +/// +/// Starts a localhost server, opens the browser, waits for the user to +/// authorize, then stores the resulting DPoP-bound credential. +pub async fn oauth_login(handle: &str, pds_url: Option<&str>, port: u16) -> Result<(), String> { + // 1. resolve identity + eprintln!("Resolving identity for {}...", handle); + let (pds_url, did) = resolve_identity(handle, pds_url).await?; + eprintln!("Resolved: {} @ {}", did, pds_url); + + // 2. discover authorization server + eprintln!("Discovering authorization server..."); + let http = reqwest::Client::new(); + let (_resource, auth_server) = resources::pds_resources(&http, &pds_url) + .await + .map_err(|e| format!("failed to discover authorization server: {}", e))?; + + // 3. generate DPoP key (no signing key needed for public/loopback client) + let dpop_key = generate_key(KeyType::P256Private) + .map_err(|e| format!("failed to generate DPoP key: {}", e))?; + + // 4. build loopback client config + // AT Protocol uses http://localhost as a special loopback client_id. + // The PDS generates virtual client metadata for it — no need to serve our own. + // Scopes are specified via query parameter on the client_id URL. + // Redirect URI must be http://127.0.0.1/ (root path, any port is accepted). + let scope = "atproto transition:generic"; + let client_id = format!( + "http://localhost?scope={}&redirect_uri={}", + percent_encode(scope), + percent_encode(&format!("http://127.0.0.1:{}/", port)), + ); + let redirect_uri = format!("http://127.0.0.1:{}/", port); + + let oauth_client = OAuthClient { + redirect_uri: redirect_uri.clone(), + client_id: client_id.clone(), + // public client — no signing key, no client_assertion + private_signing_key_data: None, + }; + + // 5. generate PKCE challenge + let (code_verifier, code_challenge) = pkce::generate(); + + let state = generate_random_state(); + let nonce = generate_random_state(); + let oauth_request_state = OAuthRequestState { + state: state.clone(), + nonce: nonce.clone(), + code_challenge, + scope: scope.to_string(), + }; + + // 6. make PAR request + eprintln!("Starting OAuth flow..."); + let par_response = workflow::oauth_init( + &http, + &oauth_client, + &dpop_key, + Some(handle), + &auth_server, + &oauth_request_state, + ) + .await + .map_err(|e| format!("OAuth PAR request failed: {}", e))?; + + // build the authorization URL + let auth_url = format!( + "{}?client_id={}&request_uri={}", + auth_server.authorization_endpoint, + percent_encode(&client_id), + percent_encode(&par_response.request_uri), + ); + + // serialize DPoP key for storage in the OAuthRequest + let dpop_jwk = + jwk::generate(&dpop_key).map_err(|e| format!("failed to generate DPoP JWK: {}", e))?; + let dpop_private_key_json = serde_json::to_string(&dpop_jwk) + .map_err(|e| format!("failed to serialize DPoP key: {}", e))?; + + let now = chrono::Utc::now(); + let oauth_request = OAuthRequest { + oauth_state: state.clone(), + issuer: auth_server.issuer.clone(), + authorization_server: auth_server.issuer.clone(), + nonce, + pkce_verifier: code_verifier, + // no signing key for public client + signing_public_key: String::new(), + dpop_private_key: dpop_private_key_json.clone(), + created_at: now, + expires_at: now + chrono::Duration::seconds(par_response.expires_in as i64), + }; + + // 7. print URL and try to open browser + eprintln!("\nOpen this URL in your browser to authorize:\n"); + eprintln!(" {}\n", auth_url); + let _ = open_browser(&auth_url); + + // 8. start callback server and wait for the redirect + let callback = oauth_server::run_oauth_server(port).await?; + + // verify state matches + if callback.state != state { + return Err("OAuth state mismatch — possible CSRF attack".to_string()); + } + + // 9. exchange code for tokens + eprintln!("Exchanging authorization code for tokens..."); + let token_response = workflow::oauth_complete( + &http, + &oauth_client, + &dpop_key, + &callback.code, + &oauth_request, + &auth_server, + ) + .await + .map_err(|e| format!("OAuth token exchange failed: {}", e))?; + + let token_did = token_response + .sub + .ok_or_else(|| "no sub (DID) in token response".to_string())?; + + // compute token expiry + let token_expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64 + token_response.expires_in as i64) + .ok(); + + // 10. store credential + let cred = auth::StoredCredential { + pds_url: pds_url.clone(), + handle: handle.to_string(), + did: token_did.clone(), + access_jwt: token_response.access_token, + refresh_jwt: token_response.refresh_token.unwrap_or_default(), + dpop_key: Some(dpop_private_key_json), + signing_key: None, + token_expiry, + }; + + let mut config = auth::load_config()?; + config.credentials.insert(handle.to_string(), cred); + auth::save_config(&config)?; + + eprintln!("Logged in as {} ({}) via OAuth", handle, token_did); + eprintln!("Credentials stored with DPoP key for authenticated pushes."); + + Ok(()) +} + +/// Resolves a handle to its PDS URL and DID. +async fn resolve_identity(handle: &str, pds_url: Option<&str>) -> Result<(String, String), String> { + if let Some(url) = pds_url { + let did = identity::resolve_handle(handle, Some(url)).await?; + Ok((url.to_string(), did)) + } else { + let resolved = identity::resolve_identity(handle, None, None).await?; + Ok((resolved.pds_url, resolved.did)) + } +} + +/// Tries to open a URL in the default browser. +fn open_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let cmd = std::process::Command::new("open").arg(url).spawn(); + + #[cfg(target_os = "linux")] + let cmd = std::process::Command::new("xdg-open").arg(url).spawn(); + + #[cfg(target_os = "windows")] + let cmd = std::process::Command::new("cmd") + .args(["/c", "start", url]) + .spawn(); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let cmd: Result = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "unsupported platform", + )); + + match cmd { + Ok(_) => Ok(()), + Err(e) => { + eprintln!("Could not open browser automatically: {}", e); + eprintln!("Please open the URL above manually."); + Ok(()) + } + } +} + +/// Simple percent-encoding for URL parameters. +fn percent_encode(s: &str) -> String { + let mut result = String::with_capacity(s.len() * 3); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push('%'); + result.push_str(&format!("{:02X}", byte)); + } + } + } + result +} + +/// Generates a random state string. +fn generate_random_state() -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use std::time::SystemTime; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + + let mut hasher = DefaultHasher::new(); + now.as_nanos().hash(&mut hasher); + std::thread::current().id().hash(&mut hasher); + let stack_var = 0u8; + (&stack_var as *const u8 as usize).hash(&mut hasher); + let h1 = hasher.finish(); + + h1.hash(&mut hasher); + let h2 = hasher.finish(); + + format!("{:016x}{:016x}", h1, h2) +} diff --git a/src/oauth_server.rs b/src/oauth_server.rs new file mode 100644 index 0000000..7275fa2 --- /dev/null +++ b/src/oauth_server.rs @@ -0,0 +1,92 @@ +//! Temporary localhost HTTP server for OAuth callback. +//! +//! Listens on a loopback port for the OAuth redirect from the PDS. +//! Shuts down as soon as the callback is received. + +use std::sync::Arc; + +use axum::response::IntoResponse; +use tokio::sync::oneshot; + +/// Data received from the OAuth callback redirect. +pub struct CallbackResult { + pub code: String, + pub state: String, + pub iss: Option, +} + +/// Shared state for the OAuth callback server. +struct ServerState { + callback_tx: std::sync::Mutex>>, +} + +/// Starts a temporary HTTP server for the OAuth login flow. +/// +/// Listens on `127.0.0.1:{port}` for the OAuth redirect. The PDS redirects +/// to `http://127.0.0.1:{port}/?code=...&state=...` after authorization. +/// +/// Returns the authorization code once the callback is received. +pub async fn run_oauth_server(port: u16) -> Result { + let (tx, rx) = oneshot::channel::(); + + let state = Arc::new(ServerState { + callback_tx: std::sync::Mutex::new(Some(tx)), + }); + + // the PDS redirects to the root path with query params + let app = axum::Router::new() + .route("/", axum::routing::get(callback_handler)) + .with_state(state); + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("failed to bind to port {}: {}", port, e))?; + + eprintln!( + "OAuth callback server listening on http://127.0.0.1:{}", + port + ); + + // run the server until the callback is received + let server = axum::serve(listener, app); + tokio::select! { + result = rx => { + result.map_err(|_| "callback channel closed without result".to_string()) + } + err = server.into_future() => { + Err(format!("server exited unexpectedly: {:?}", err)) + } + } +} + +/// Query parameters on the OAuth callback. +#[derive(serde::Deserialize)] +struct CallbackParams { + code: String, + state: String, + iss: Option, +} + +/// GET /?code=...&state=...&iss=... +async fn callback_handler( + axum::extract::State(state): axum::extract::State>, + axum::extract::Query(params): axum::extract::Query, +) -> axum::response::Response { + let result = CallbackResult { + code: params.code, + state: params.state, + iss: params.iss, + }; + + // send the result through the channel + if let Some(tx) = state.callback_tx.lock().unwrap().take() { + let _ = tx.send(result); + } + + ( + [(axum::http::header::CONTENT_TYPE, "text/html")], + "

Login successful!

You can close this tab and return to the terminal.

", + ) + .into_response() +} diff --git a/src/pds_client.rs b/src/pds_client.rs index 709890b..70efa1f 100644 --- a/src/pds_client.rs +++ b/src/pds_client.rs @@ -3,20 +3,39 @@ //! Wraps the AT Protocol XRPC endpoints needed for git backup: //! record CRUD, blob upload/download, and session creation. +use std::cell::RefCell; + +use atproto_identity::key::KeyData; + +use crate::dpop; use crate::types::{BlobRef, CidLink}; use serde::{Deserialize, Serialize}; +/// Authentication mode for PDS requests. +enum AuthMode { + /// No authentication (unauthenticated reads). + None, + /// Classic Bearer token (from createSession). + Bearer(String), + /// DPoP-bound OAuth token (requires proof on every request). + DPoP { + access_token: String, + key_data: KeyData, + /// Cached nonce from the PDS (updated after each nonce challenge). + last_nonce: RefCell>, + }, +} + /// Client for interacting with a PDS server over XRPC. /// -/// Supports both authenticated (push) and unauthenticated (clone/fetch) -/// operations. Use `PdsClient::new` for unauthenticated access and -/// `PdsClient::with_auth` when a bearer token is available. -#[derive(Debug, Clone)] +/// Supports three auth modes: +/// - Unauthenticated (clone/fetch) +/// - Bearer token (createSession-based push) +/// - DPoP (OAuth-based push with proof-of-possession) pub struct PdsClient { /// base URL of the PDS, e.g. "https://bsky.social" base_url: String, - /// bearer token for authenticated requests (access JWT) - auth_token: Option, + auth: AuthMode, http: reqwest::Client, } @@ -95,7 +114,7 @@ impl PdsClient { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_string(), - auth_token: None, + auth: AuthMode::None, http: reqwest::Client::new(), } } @@ -104,14 +123,31 @@ impl PdsClient { pub fn with_auth(base_url: impl Into, token: impl Into) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_string(), - auth_token: Some(token.into()), + auth: AuthMode::Bearer(token.into()), http: reqwest::Client::new(), } } - /// Sets or replaces the auth token. + /// Creates an authenticated client with a DPoP-bound OAuth token. + pub fn with_dpop_auth( + base_url: impl Into, + access_token: impl Into, + key_data: KeyData, + ) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + auth: AuthMode::DPoP { + access_token: access_token.into(), + key_data, + last_nonce: RefCell::new(None), + }, + http: reqwest::Client::new(), + } + } + + /// Sets or replaces the auth token (Bearer mode). pub fn set_auth(&mut self, token: impl Into) { - self.auth_token = Some(token.into()); + self.auth = AuthMode::Bearer(token.into()); } /// Returns the base URL of the PDS. @@ -152,7 +188,7 @@ impl PdsClient { .await .map_err(|e| format!("failed to parse createSession response: {}", e))?; - self.auth_token = Some(session.access_jwt.clone()); + self.auth = AuthMode::Bearer(session.access_jwt.clone()); Ok(session) } @@ -211,11 +247,6 @@ impl PdsClient { record: serde_json::Value, swap_record: Option, ) -> Result { - let token = self - .auth_token - .as_ref() - .ok_or("putRecord requires authentication")?; - let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url); let body = PutRecordRequest { @@ -226,14 +257,12 @@ impl PdsClient { swap_record, }; + let json_body = serde_json::to_vec(&body) + .map_err(|e| format!("failed to serialize putRecord body: {}", e))?; + let resp = self - .http - .post(&url) - .bearer_auth(token) - .json(&body) - .send() - .await - .map_err(|e| format!("putRecord request failed: {}", e))?; + .send_authenticated("POST", &url, json_body.clone(), Some("application/json")) + .await?; if !resp.status().is_success() { let err = parse_xrpc_error(resp).await; @@ -250,22 +279,11 @@ impl PdsClient { /// Calls `com.atproto.repo.uploadBlob`. Requires authentication. /// Returns a `BlobRef` that can be embedded in a record. pub async fn upload_blob(&self, data: Vec) -> Result { - let token = self - .auth_token - .as_ref() - .ok_or("uploadBlob requires authentication")?; - let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url); let resp = self - .http - .post(&url) - .bearer_auth(token) - .header("Content-Type", "application/octet-stream") - .body(data) - .send() - .await - .map_err(|e| format!("uploadBlob request failed: {}", e))?; + .send_authenticated("POST", &url, data.clone(), Some("application/octet-stream")) + .await?; if !resp.status().is_success() { let err = parse_xrpc_error(resp).await; @@ -312,6 +330,111 @@ impl PdsClient { .map(|b| b.to_vec()) .map_err(|e| format!("failed to read getBlob response body: {}", e)) } + + /// Sends an authenticated request, handling both Bearer and DPoP auth modes. + /// + /// For DPoP auth, generates proof headers and automatically retries once + /// if the PDS responds with a nonce challenge (400/401 with DPoP-Nonce header). + async fn send_authenticated( + &self, + method: &str, + url: &str, + body: Vec, + content_type: Option<&str>, + ) -> Result { + match &self.auth { + AuthMode::None => Err("authenticated request requires auth".to_string()), + AuthMode::Bearer(token) => { + let mut req = self.http.post(url).bearer_auth(token); + if let Some(ct) = content_type { + req = req.header("Content-Type", ct); + } + req.body(body) + .send() + .await + .map_err(|e| format!("request failed: {}", e)) + } + AuthMode::DPoP { + access_token, + key_data, + last_nonce, + } => { + let nonce = last_nonce.borrow().clone(); + let proof = + dpop::make_dpop_proof(key_data, method, url, access_token, nonce.as_deref())?; + + let mut req = self + .http + .post(url) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &proof); + if let Some(ct) = content_type { + req = req.header("Content-Type", ct); + } + + let resp = req + .body(body.clone()) + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + + // Check for nonce challenge: 400/401 with DPoP-Nonce header + let status = resp.status(); + if (status == reqwest::StatusCode::BAD_REQUEST + || status == reqwest::StatusCode::UNAUTHORIZED) + && resp.headers().contains_key("dpop-nonce") + { + let new_nonce = resp + .headers() + .get("dpop-nonce") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if let Some(ref nonce_val) = new_nonce { + tracing::debug!("DPoP nonce challenge received, retrying with nonce"); + + // Cache the nonce for subsequent requests + *last_nonce.borrow_mut() = new_nonce.clone(); + + // Regenerate proof with nonce and retry + let retry_proof = dpop::make_dpop_proof( + key_data, + method, + url, + access_token, + Some(nonce_val), + )?; + + let mut retry_req = self + .http + .post(url) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &retry_proof); + if let Some(ct) = content_type { + retry_req = retry_req.header("Content-Type", ct); + } + + return retry_req + .body(body) + .send() + .await + .map_err(|e| format!("request failed (retry): {}", e)); + } + } + + // Cache any nonce from successful responses too + if let Some(nonce_val) = resp + .headers() + .get("dpop-nonce") + .and_then(|v| v.to_str().ok()) + { + *last_nonce.borrow_mut() = Some(nonce_val.to_string()); + } + + Ok(resp) + } + } + } } /// Extracts an error message from a non-success XRPC response. diff --git a/src/remote_helper.rs b/src/remote_helper.rs index e6565c9..456d04d 100644 --- a/src/remote_helper.rs +++ b/src/remote_helper.rs @@ -219,7 +219,10 @@ async fn handle_push(handle: &str, repo_name: &str, push_lines: &[String]) -> Re // resolve authentication let auth = auth::resolve_auth(handle, &pds_url).await?; - let client = PdsClient::with_auth(&pds_url, &auth.access_jwt); + let client = match auth.dpop_key { + Some(key) => PdsClient::with_dpop_auth(&pds_url, &auth.access_jwt, key), + None => PdsClient::with_auth(&pds_url, &auth.access_jwt), + }; // determine repo path from cwd let repo_path =