⚘ use your pds as a git remote if you want to ⚘
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

playwright oauth tests

authored by

notplants and committed by
notplants
ddc2c19d 8ba02a4b

+2669 -50
+4
.gitignore
··· 3 3 scripts/pds-dev/pds.env 4 4 tests/.testenv 5 5 result 6 + .venv 7 + __pycache__ 8 + testuser1.toml 9 + testuser.toml
+1294 -11
Cargo.lock
··· 12 12 ] 13 13 14 14 [[package]] 15 + name = "allocator-api2" 16 + version = "0.2.21" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 + 20 + [[package]] 21 + name = "android_system_properties" 22 + version = "0.1.5" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 + dependencies = [ 26 + "libc", 27 + ] 28 + 29 + [[package]] 15 30 name = "anstream" 16 31 version = "0.6.21" 17 32 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 68 83 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 69 84 70 85 [[package]] 86 + name = "arrayref" 87 + version = "0.3.9" 88 + source = "registry+https://github.com/rust-lang/crates.io-index" 89 + checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 90 + 91 + [[package]] 92 + name = "arrayvec" 93 + version = "0.7.6" 94 + source = "registry+https://github.com/rust-lang/crates.io-index" 95 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 96 + 97 + [[package]] 98 + name = "async-trait" 99 + version = "0.1.89" 100 + source = "registry+https://github.com/rust-lang/crates.io-index" 101 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 102 + dependencies = [ 103 + "proc-macro2", 104 + "quote", 105 + "syn", 106 + ] 107 + 108 + [[package]] 71 109 name = "atomic-waker" 72 110 version = "1.1.2" 73 111 source = "registry+https://github.com/rust-lang/crates.io-index" 74 112 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 75 113 76 114 [[package]] 115 + name = "atproto-dasl" 116 + version = "0.14.0" 117 + source = "registry+https://github.com/rust-lang/crates.io-index" 118 + checksum = "d63f8fb837f3bfa4440e3f38da8a21c827abea8819577893a56ac3e21d5d7ae2" 119 + dependencies = [ 120 + "blake3", 121 + "cid", 122 + "futures", 123 + "multihash", 124 + "serde", 125 + "serde_bytes", 126 + "sha2", 127 + "tempfile", 128 + "thiserror 2.0.18", 129 + "tokio", 130 + "tracing", 131 + "url", 132 + ] 133 + 134 + [[package]] 135 + name = "atproto-identity" 136 + version = "0.14.0" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "13837fe8e5e93b9b842bfe2aaedd539f724021d52e5bedfb9b9fed52a5e0f46f" 139 + dependencies = [ 140 + "anyhow", 141 + "async-trait", 142 + "atproto-dasl", 143 + "base64", 144 + "chrono", 145 + "data-encoding", 146 + "ecdsa", 147 + "elliptic-curve", 148 + "hickory-resolver", 149 + "k256", 150 + "lru", 151 + "multibase", 152 + "p256", 153 + "p384", 154 + "rand 0.8.5", 155 + "reqwest", 156 + "serde", 157 + "serde_json", 158 + "sha2", 159 + "thiserror 2.0.18", 160 + "tokio", 161 + "tracing", 162 + "url", 163 + ] 164 + 165 + [[package]] 166 + name = "atproto-oauth" 167 + version = "0.14.0" 168 + dependencies = [ 169 + "anyhow", 170 + "async-trait", 171 + "atproto-identity", 172 + "base64", 173 + "chrono", 174 + "ecdsa", 175 + "elliptic-curve", 176 + "k256", 177 + "multibase", 178 + "p256", 179 + "p384", 180 + "rand 0.8.5", 181 + "reqwest", 182 + "reqwest-chain", 183 + "reqwest-middleware", 184 + "serde", 185 + "serde_json", 186 + "sha2", 187 + "thiserror 2.0.18", 188 + "tokio", 189 + "tracing", 190 + "ulid", 191 + ] 192 + 193 + [[package]] 194 + name = "autocfg" 195 + version = "1.5.0" 196 + source = "registry+https://github.com/rust-lang/crates.io-index" 197 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 198 + 199 + [[package]] 200 + name = "axum" 201 + version = "0.8.8" 202 + source = "registry+https://github.com/rust-lang/crates.io-index" 203 + checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" 204 + dependencies = [ 205 + "axum-core", 206 + "bytes", 207 + "form_urlencoded", 208 + "futures-util", 209 + "http", 210 + "http-body", 211 + "http-body-util", 212 + "hyper", 213 + "hyper-util", 214 + "itoa", 215 + "matchit", 216 + "memchr", 217 + "mime", 218 + "percent-encoding", 219 + "pin-project-lite", 220 + "serde_core", 221 + "serde_json", 222 + "serde_path_to_error", 223 + "serde_urlencoded", 224 + "sync_wrapper", 225 + "tokio", 226 + "tower", 227 + "tower-layer", 228 + "tower-service", 229 + "tracing", 230 + ] 231 + 232 + [[package]] 233 + name = "axum-core" 234 + version = "0.5.6" 235 + source = "registry+https://github.com/rust-lang/crates.io-index" 236 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" 237 + dependencies = [ 238 + "bytes", 239 + "futures-core", 240 + "http", 241 + "http-body", 242 + "http-body-util", 243 + "mime", 244 + "pin-project-lite", 245 + "sync_wrapper", 246 + "tower-layer", 247 + "tower-service", 248 + "tracing", 249 + ] 250 + 251 + [[package]] 252 + name = "base-x" 253 + version = "0.2.11" 254 + source = "registry+https://github.com/rust-lang/crates.io-index" 255 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 256 + 257 + [[package]] 258 + name = "base16ct" 259 + version = "0.2.0" 260 + source = "registry+https://github.com/rust-lang/crates.io-index" 261 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 262 + 263 + [[package]] 264 + name = "base256emoji" 265 + version = "1.0.2" 266 + source = "registry+https://github.com/rust-lang/crates.io-index" 267 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 268 + dependencies = [ 269 + "const-str", 270 + "match-lookup", 271 + ] 272 + 273 + [[package]] 77 274 name = "base64" 78 275 version = "0.22.1" 79 276 source = "registry+https://github.com/rust-lang/crates.io-index" 80 277 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 81 278 82 279 [[package]] 280 + name = "base64ct" 281 + version = "1.8.3" 282 + source = "registry+https://github.com/rust-lang/crates.io-index" 283 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 284 + 285 + [[package]] 83 286 name = "bitflags" 84 287 version = "2.11.0" 85 288 source = "registry+https://github.com/rust-lang/crates.io-index" 86 289 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 87 290 88 291 [[package]] 292 + name = "blake3" 293 + version = "1.8.3" 294 + source = "registry+https://github.com/rust-lang/crates.io-index" 295 + checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" 296 + dependencies = [ 297 + "arrayref", 298 + "arrayvec", 299 + "cc", 300 + "cfg-if", 301 + "constant_time_eq", 302 + "cpufeatures", 303 + ] 304 + 305 + [[package]] 306 + name = "block-buffer" 307 + version = "0.10.4" 308 + source = "registry+https://github.com/rust-lang/crates.io-index" 309 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 310 + dependencies = [ 311 + "generic-array", 312 + ] 313 + 314 + [[package]] 89 315 name = "bumpalo" 90 316 version = "3.20.2" 91 317 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 120 346 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 121 347 122 348 [[package]] 349 + name = "chrono" 350 + version = "0.4.44" 351 + source = "registry+https://github.com/rust-lang/crates.io-index" 352 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 353 + dependencies = [ 354 + "iana-time-zone", 355 + "js-sys", 356 + "num-traits", 357 + "wasm-bindgen", 358 + "windows-link", 359 + ] 360 + 361 + [[package]] 362 + name = "cid" 363 + version = "0.11.1" 364 + source = "registry+https://github.com/rust-lang/crates.io-index" 365 + checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 366 + dependencies = [ 367 + "core2", 368 + "multibase", 369 + "multihash", 370 + "unsigned-varint", 371 + ] 372 + 373 + [[package]] 123 374 name = "clap" 124 375 version = "4.5.60" 125 376 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 166 417 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 167 418 168 419 [[package]] 420 + name = "const-oid" 421 + version = "0.9.6" 422 + source = "registry+https://github.com/rust-lang/crates.io-index" 423 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 424 + 425 + [[package]] 426 + name = "const-str" 427 + version = "0.4.3" 428 + source = "registry+https://github.com/rust-lang/crates.io-index" 429 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 430 + 431 + [[package]] 432 + name = "constant_time_eq" 433 + version = "0.4.2" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" 436 + 437 + [[package]] 438 + name = "core-foundation" 439 + version = "0.9.4" 440 + source = "registry+https://github.com/rust-lang/crates.io-index" 441 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 442 + dependencies = [ 443 + "core-foundation-sys", 444 + "libc", 445 + ] 446 + 447 + [[package]] 448 + name = "core-foundation-sys" 449 + version = "0.8.7" 450 + source = "registry+https://github.com/rust-lang/crates.io-index" 451 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 452 + 453 + [[package]] 454 + name = "core2" 455 + version = "0.4.0" 456 + source = "registry+https://github.com/rust-lang/crates.io-index" 457 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 458 + dependencies = [ 459 + "memchr", 460 + ] 461 + 462 + [[package]] 463 + name = "cpufeatures" 464 + version = "0.2.17" 465 + source = "registry+https://github.com/rust-lang/crates.io-index" 466 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 467 + dependencies = [ 468 + "libc", 469 + ] 470 + 471 + [[package]] 472 + name = "critical-section" 473 + version = "1.2.0" 474 + source = "registry+https://github.com/rust-lang/crates.io-index" 475 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 476 + 477 + [[package]] 478 + name = "crossbeam-channel" 479 + version = "0.5.15" 480 + source = "registry+https://github.com/rust-lang/crates.io-index" 481 + checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 482 + dependencies = [ 483 + "crossbeam-utils", 484 + ] 485 + 486 + [[package]] 487 + name = "crossbeam-epoch" 488 + version = "0.9.18" 489 + source = "registry+https://github.com/rust-lang/crates.io-index" 490 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 491 + dependencies = [ 492 + "crossbeam-utils", 493 + ] 494 + 495 + [[package]] 496 + name = "crossbeam-utils" 497 + version = "0.8.21" 498 + source = "registry+https://github.com/rust-lang/crates.io-index" 499 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 500 + 501 + [[package]] 502 + name = "crypto-bigint" 503 + version = "0.5.5" 504 + source = "registry+https://github.com/rust-lang/crates.io-index" 505 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 506 + dependencies = [ 507 + "generic-array", 508 + "rand_core 0.6.4", 509 + "subtle", 510 + "zeroize", 511 + ] 512 + 513 + [[package]] 514 + name = "crypto-common" 515 + version = "0.1.6" 516 + source = "registry+https://github.com/rust-lang/crates.io-index" 517 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 518 + dependencies = [ 519 + "generic-array", 520 + "typenum", 521 + ] 522 + 523 + [[package]] 524 + name = "data-encoding" 525 + version = "2.10.0" 526 + source = "registry+https://github.com/rust-lang/crates.io-index" 527 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 528 + 529 + [[package]] 530 + name = "data-encoding-macro" 531 + version = "0.1.19" 532 + source = "registry+https://github.com/rust-lang/crates.io-index" 533 + checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" 534 + dependencies = [ 535 + "data-encoding", 536 + "data-encoding-macro-internal", 537 + ] 538 + 539 + [[package]] 540 + name = "data-encoding-macro-internal" 541 + version = "0.1.17" 542 + source = "registry+https://github.com/rust-lang/crates.io-index" 543 + checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" 544 + dependencies = [ 545 + "data-encoding", 546 + "syn", 547 + ] 548 + 549 + [[package]] 550 + name = "der" 551 + version = "0.7.10" 552 + source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 554 + dependencies = [ 555 + "const-oid", 556 + "pem-rfc7468", 557 + "zeroize", 558 + ] 559 + 560 + [[package]] 561 + name = "digest" 562 + version = "0.10.7" 563 + source = "registry+https://github.com/rust-lang/crates.io-index" 564 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 565 + dependencies = [ 566 + "block-buffer", 567 + "const-oid", 568 + "crypto-common", 569 + "subtle", 570 + ] 571 + 572 + [[package]] 169 573 name = "displaydoc" 170 574 version = "0.2.5" 171 575 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 177 581 ] 178 582 179 583 [[package]] 584 + name = "ecdsa" 585 + version = "0.16.9" 586 + source = "registry+https://github.com/rust-lang/crates.io-index" 587 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 588 + dependencies = [ 589 + "der", 590 + "digest", 591 + "elliptic-curve", 592 + "rfc6979", 593 + "serdect", 594 + "signature", 595 + "spki", 596 + ] 597 + 598 + [[package]] 599 + name = "elliptic-curve" 600 + version = "0.13.8" 601 + source = "registry+https://github.com/rust-lang/crates.io-index" 602 + checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 603 + dependencies = [ 604 + "base16ct", 605 + "base64ct", 606 + "crypto-bigint", 607 + "digest", 608 + "ff", 609 + "generic-array", 610 + "group", 611 + "hkdf", 612 + "pem-rfc7468", 613 + "pkcs8", 614 + "rand_core 0.6.4", 615 + "sec1", 616 + "serde_json", 617 + "serdect", 618 + "subtle", 619 + "zeroize", 620 + ] 621 + 622 + [[package]] 623 + name = "encoding_rs" 624 + version = "0.8.35" 625 + source = "registry+https://github.com/rust-lang/crates.io-index" 626 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 627 + dependencies = [ 628 + "cfg-if", 629 + ] 630 + 631 + [[package]] 632 + name = "enum-as-inner" 633 + version = "0.6.1" 634 + source = "registry+https://github.com/rust-lang/crates.io-index" 635 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 636 + dependencies = [ 637 + "heck", 638 + "proc-macro2", 639 + "quote", 640 + "syn", 641 + ] 642 + 643 + [[package]] 180 644 name = "equivalent" 181 645 version = "1.0.2" 182 646 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 199 663 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 200 664 201 665 [[package]] 666 + name = "ff" 667 + version = "0.13.1" 668 + source = "registry+https://github.com/rust-lang/crates.io-index" 669 + checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 670 + dependencies = [ 671 + "rand_core 0.6.4", 672 + "subtle", 673 + ] 674 + 675 + [[package]] 202 676 name = "find-msvc-tools" 203 677 version = "0.1.9" 204 678 source = "registry+https://github.com/rust-lang/crates.io-index" 205 679 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 680 + 681 + [[package]] 682 + name = "fnv" 683 + version = "1.0.7" 684 + source = "registry+https://github.com/rust-lang/crates.io-index" 685 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 206 686 207 687 [[package]] 208 688 name = "foldhash" ··· 220 700 ] 221 701 222 702 [[package]] 703 + name = "futures" 704 + version = "0.3.32" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 707 + dependencies = [ 708 + "futures-channel", 709 + "futures-core", 710 + "futures-executor", 711 + "futures-io", 712 + "futures-sink", 713 + "futures-task", 714 + "futures-util", 715 + ] 716 + 717 + [[package]] 223 718 name = "futures-channel" 224 719 version = "0.3.32" 225 720 source = "registry+https://github.com/rust-lang/crates.io-index" 226 721 checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 227 722 dependencies = [ 228 723 "futures-core", 724 + "futures-sink", 229 725 ] 230 726 231 727 [[package]] ··· 235 731 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 236 732 237 733 [[package]] 734 + name = "futures-executor" 735 + version = "0.3.32" 736 + source = "registry+https://github.com/rust-lang/crates.io-index" 737 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" 738 + dependencies = [ 739 + "futures-core", 740 + "futures-task", 741 + "futures-util", 742 + ] 743 + 744 + [[package]] 745 + name = "futures-io" 746 + version = "0.3.32" 747 + source = "registry+https://github.com/rust-lang/crates.io-index" 748 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 749 + 750 + [[package]] 751 + name = "futures-macro" 752 + version = "0.3.32" 753 + source = "registry+https://github.com/rust-lang/crates.io-index" 754 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 755 + dependencies = [ 756 + "proc-macro2", 757 + "quote", 758 + "syn", 759 + ] 760 + 761 + [[package]] 762 + name = "futures-sink" 763 + version = "0.3.32" 764 + source = "registry+https://github.com/rust-lang/crates.io-index" 765 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 766 + 767 + [[package]] 238 768 name = "futures-task" 239 769 version = "0.3.32" 240 770 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 246 776 source = "registry+https://github.com/rust-lang/crates.io-index" 247 777 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 248 778 dependencies = [ 779 + "futures-channel", 249 780 "futures-core", 781 + "futures-io", 782 + "futures-macro", 783 + "futures-sink", 250 784 "futures-task", 785 + "memchr", 251 786 "pin-project-lite", 252 787 "slab", 788 + ] 789 + 790 + [[package]] 791 + name = "generic-array" 792 + version = "0.14.9" 793 + source = "registry+https://github.com/rust-lang/crates.io-index" 794 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 795 + dependencies = [ 796 + "typenum", 797 + "version_check", 798 + "zeroize", 253 799 ] 254 800 255 801 [[package]] ··· 293 839 ] 294 840 295 841 [[package]] 842 + name = "group" 843 + version = "0.13.0" 844 + source = "registry+https://github.com/rust-lang/crates.io-index" 845 + checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 846 + dependencies = [ 847 + "ff", 848 + "rand_core 0.6.4", 849 + "subtle", 850 + ] 851 + 852 + [[package]] 853 + name = "h2" 854 + version = "0.4.13" 855 + source = "registry+https://github.com/rust-lang/crates.io-index" 856 + checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" 857 + dependencies = [ 858 + "atomic-waker", 859 + "bytes", 860 + "fnv", 861 + "futures-core", 862 + "futures-sink", 863 + "http", 864 + "indexmap", 865 + "slab", 866 + "tokio", 867 + "tokio-util", 868 + "tracing", 869 + ] 870 + 871 + [[package]] 296 872 name = "hashbrown" 297 873 version = "0.15.5" 298 874 source = "registry+https://github.com/rust-lang/crates.io-index" 299 875 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 300 876 dependencies = [ 877 + "allocator-api2", 878 + "equivalent", 301 879 "foldhash", 302 880 ] 303 881 ··· 314 892 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 315 893 316 894 [[package]] 895 + name = "hickory-proto" 896 + version = "0.25.2" 897 + source = "registry+https://github.com/rust-lang/crates.io-index" 898 + checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" 899 + dependencies = [ 900 + "async-trait", 901 + "cfg-if", 902 + "data-encoding", 903 + "enum-as-inner", 904 + "futures-channel", 905 + "futures-io", 906 + "futures-util", 907 + "idna", 908 + "ipnet", 909 + "once_cell", 910 + "rand 0.9.2", 911 + "ring", 912 + "thiserror 2.0.18", 913 + "tinyvec", 914 + "tokio", 915 + "tracing", 916 + "url", 917 + ] 918 + 919 + [[package]] 920 + name = "hickory-resolver" 921 + version = "0.25.2" 922 + source = "registry+https://github.com/rust-lang/crates.io-index" 923 + checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" 924 + dependencies = [ 925 + "cfg-if", 926 + "futures-util", 927 + "hickory-proto", 928 + "ipconfig", 929 + "moka", 930 + "once_cell", 931 + "parking_lot", 932 + "rand 0.9.2", 933 + "resolv-conf", 934 + "smallvec", 935 + "thiserror 2.0.18", 936 + "tokio", 937 + "tracing", 938 + ] 939 + 940 + [[package]] 941 + name = "hkdf" 942 + version = "0.12.4" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 945 + dependencies = [ 946 + "hmac", 947 + ] 948 + 949 + [[package]] 950 + name = "hmac" 951 + version = "0.12.1" 952 + source = "registry+https://github.com/rust-lang/crates.io-index" 953 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 954 + dependencies = [ 955 + "digest", 956 + ] 957 + 958 + [[package]] 317 959 name = "http" 318 960 version = "1.4.0" 319 961 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 353 995 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 354 996 355 997 [[package]] 998 + name = "httpdate" 999 + version = "1.0.3" 1000 + source = "registry+https://github.com/rust-lang/crates.io-index" 1001 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1002 + 1003 + [[package]] 356 1004 name = "hyper" 357 1005 version = "1.8.1" 358 1006 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 362 1010 "bytes", 363 1011 "futures-channel", 364 1012 "futures-core", 1013 + "h2", 365 1014 "http", 366 1015 "http-body", 367 1016 "httparse", 1017 + "httpdate", 368 1018 "itoa", 369 1019 "pin-project-lite", 370 1020 "pin-utils", ··· 407 1057 "libc", 408 1058 "percent-encoding", 409 1059 "pin-project-lite", 410 - "socket2", 1060 + "socket2 0.6.2", 1061 + "system-configuration", 411 1062 "tokio", 412 1063 "tower-service", 413 1064 "tracing", 1065 + "windows-registry", 1066 + ] 1067 + 1068 + [[package]] 1069 + name = "iana-time-zone" 1070 + version = "0.1.65" 1071 + source = "registry+https://github.com/rust-lang/crates.io-index" 1072 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 1073 + dependencies = [ 1074 + "android_system_properties", 1075 + "core-foundation-sys", 1076 + "iana-time-zone-haiku", 1077 + "js-sys", 1078 + "log", 1079 + "wasm-bindgen", 1080 + "windows-core", 1081 + ] 1082 + 1083 + [[package]] 1084 + name = "iana-time-zone-haiku" 1085 + version = "0.1.2" 1086 + source = "registry+https://github.com/rust-lang/crates.io-index" 1087 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1088 + dependencies = [ 1089 + "cc", 414 1090 ] 415 1091 416 1092 [[package]] ··· 534 1210 ] 535 1211 536 1212 [[package]] 1213 + name = "ipconfig" 1214 + version = "0.3.2" 1215 + source = "registry+https://github.com/rust-lang/crates.io-index" 1216 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1217 + dependencies = [ 1218 + "socket2 0.5.10", 1219 + "widestring", 1220 + "windows-sys 0.48.0", 1221 + "winreg", 1222 + ] 1223 + 1224 + [[package]] 537 1225 name = "ipnet" 538 1226 version = "2.11.0" 539 1227 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 572 1260 ] 573 1261 574 1262 [[package]] 1263 + name = "k256" 1264 + version = "0.13.4" 1265 + source = "registry+https://github.com/rust-lang/crates.io-index" 1266 + checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" 1267 + dependencies = [ 1268 + "cfg-if", 1269 + "ecdsa", 1270 + "elliptic-curve", 1271 + "once_cell", 1272 + "sha2", 1273 + "signature", 1274 + ] 1275 + 1276 + [[package]] 575 1277 name = "lazy_static" 576 1278 version = "1.5.0" 577 1279 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 617 1319 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 618 1320 619 1321 [[package]] 1322 + name = "lru" 1323 + version = "0.12.5" 1324 + source = "registry+https://github.com/rust-lang/crates.io-index" 1325 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1326 + dependencies = [ 1327 + "hashbrown 0.15.5", 1328 + ] 1329 + 1330 + [[package]] 620 1331 name = "lru-slab" 621 1332 version = "0.1.2" 622 1333 source = "registry+https://github.com/rust-lang/crates.io-index" 623 1334 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 624 1335 625 1336 [[package]] 1337 + name = "match-lookup" 1338 + version = "0.1.2" 1339 + source = "registry+https://github.com/rust-lang/crates.io-index" 1340 + checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" 1341 + dependencies = [ 1342 + "proc-macro2", 1343 + "quote", 1344 + "syn", 1345 + ] 1346 + 1347 + [[package]] 626 1348 name = "matchers" 627 1349 version = "0.2.0" 628 1350 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 632 1354 ] 633 1355 634 1356 [[package]] 1357 + name = "matchit" 1358 + version = "0.8.4" 1359 + source = "registry+https://github.com/rust-lang/crates.io-index" 1360 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 1361 + 1362 + [[package]] 635 1363 name = "memchr" 636 1364 version = "2.8.0" 637 1365 source = "registry+https://github.com/rust-lang/crates.io-index" 638 1366 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 639 1367 640 1368 [[package]] 1369 + name = "mime" 1370 + version = "0.3.17" 1371 + source = "registry+https://github.com/rust-lang/crates.io-index" 1372 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1373 + 1374 + [[package]] 1375 + name = "mime_guess" 1376 + version = "2.0.5" 1377 + source = "registry+https://github.com/rust-lang/crates.io-index" 1378 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1379 + dependencies = [ 1380 + "mime", 1381 + "unicase", 1382 + ] 1383 + 1384 + [[package]] 641 1385 name = "mio" 642 1386 version = "1.1.1" 643 1387 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 649 1393 ] 650 1394 651 1395 [[package]] 1396 + name = "moka" 1397 + version = "0.12.14" 1398 + source = "registry+https://github.com/rust-lang/crates.io-index" 1399 + checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" 1400 + dependencies = [ 1401 + "crossbeam-channel", 1402 + "crossbeam-epoch", 1403 + "crossbeam-utils", 1404 + "equivalent", 1405 + "parking_lot", 1406 + "portable-atomic", 1407 + "smallvec", 1408 + "tagptr", 1409 + "uuid", 1410 + ] 1411 + 1412 + [[package]] 1413 + name = "multibase" 1414 + version = "0.9.2" 1415 + source = "registry+https://github.com/rust-lang/crates.io-index" 1416 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 1417 + dependencies = [ 1418 + "base-x", 1419 + "base256emoji", 1420 + "data-encoding", 1421 + "data-encoding-macro", 1422 + ] 1423 + 1424 + [[package]] 1425 + name = "multihash" 1426 + version = "0.19.3" 1427 + source = "registry+https://github.com/rust-lang/crates.io-index" 1428 + checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 1429 + dependencies = [ 1430 + "core2", 1431 + "unsigned-varint", 1432 + ] 1433 + 1434 + [[package]] 652 1435 name = "nu-ansi-term" 653 1436 version = "0.50.3" 654 1437 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 658 1441 ] 659 1442 660 1443 [[package]] 1444 + name = "num-traits" 1445 + version = "0.2.19" 1446 + source = "registry+https://github.com/rust-lang/crates.io-index" 1447 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1448 + dependencies = [ 1449 + "autocfg", 1450 + ] 1451 + 1452 + [[package]] 661 1453 name = "once_cell" 662 1454 version = "1.21.3" 663 1455 source = "registry+https://github.com/rust-lang/crates.io-index" 664 1456 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1457 + dependencies = [ 1458 + "critical-section", 1459 + "portable-atomic", 1460 + ] 665 1461 666 1462 [[package]] 667 1463 name = "once_cell_polyfill" ··· 670 1466 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 671 1467 672 1468 [[package]] 1469 + name = "p256" 1470 + version = "0.13.2" 1471 + source = "registry+https://github.com/rust-lang/crates.io-index" 1472 + checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 1473 + dependencies = [ 1474 + "ecdsa", 1475 + "elliptic-curve", 1476 + "primeorder", 1477 + "serdect", 1478 + "sha2", 1479 + ] 1480 + 1481 + [[package]] 1482 + name = "p384" 1483 + version = "0.13.1" 1484 + source = "registry+https://github.com/rust-lang/crates.io-index" 1485 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 1486 + dependencies = [ 1487 + "ecdsa", 1488 + "elliptic-curve", 1489 + "primeorder", 1490 + "serdect", 1491 + "sha2", 1492 + ] 1493 + 1494 + [[package]] 673 1495 name = "parking_lot" 674 1496 version = "0.12.5" 675 1497 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 696 1518 name = "pds-git-remote" 697 1519 version = "0.1.0" 698 1520 dependencies = [ 1521 + "anyhow", 1522 + "atproto-identity", 1523 + "atproto-oauth", 1524 + "axum", 1525 + "base64", 1526 + "chrono", 699 1527 "clap", 1528 + "elliptic-curve", 1529 + "p256", 700 1530 "reqwest", 701 1531 "serde", 702 1532 "serde_json", ··· 704 1534 "tokio", 705 1535 "tracing", 706 1536 "tracing-subscriber", 1537 + "ulid", 1538 + ] 1539 + 1540 + [[package]] 1541 + name = "pem-rfc7468" 1542 + version = "0.7.0" 1543 + source = "registry+https://github.com/rust-lang/crates.io-index" 1544 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 1545 + dependencies = [ 1546 + "base64ct", 707 1547 ] 708 1548 709 1549 [[package]] ··· 725 1565 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 726 1566 727 1567 [[package]] 1568 + name = "pkcs8" 1569 + version = "0.10.2" 1570 + source = "registry+https://github.com/rust-lang/crates.io-index" 1571 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 1572 + dependencies = [ 1573 + "der", 1574 + "spki", 1575 + ] 1576 + 1577 + [[package]] 1578 + name = "portable-atomic" 1579 + version = "1.13.1" 1580 + source = "registry+https://github.com/rust-lang/crates.io-index" 1581 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 1582 + 1583 + [[package]] 728 1584 name = "potential_utf" 729 1585 version = "0.1.4" 730 1586 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 750 1606 dependencies = [ 751 1607 "proc-macro2", 752 1608 "syn", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "primeorder" 1613 + version = "0.13.6" 1614 + source = "registry+https://github.com/rust-lang/crates.io-index" 1615 + checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 1616 + dependencies = [ 1617 + "elliptic-curve", 1618 + "serdect", 753 1619 ] 754 1620 755 1621 [[package]] ··· 774 1640 "quinn-udp", 775 1641 "rustc-hash", 776 1642 "rustls", 777 - "socket2", 778 - "thiserror", 1643 + "socket2 0.6.2", 1644 + "thiserror 2.0.18", 779 1645 "tokio", 780 1646 "tracing", 781 1647 "web-time", ··· 790 1656 "bytes", 791 1657 "getrandom 0.3.4", 792 1658 "lru-slab", 793 - "rand", 1659 + "rand 0.9.2", 794 1660 "ring", 795 1661 "rustc-hash", 796 1662 "rustls", 797 1663 "rustls-pki-types", 798 1664 "slab", 799 - "thiserror", 1665 + "thiserror 2.0.18", 800 1666 "tinyvec", 801 1667 "tracing", 802 1668 "web-time", ··· 811 1677 "cfg_aliases", 812 1678 "libc", 813 1679 "once_cell", 814 - "socket2", 1680 + "socket2 0.6.2", 815 1681 "tracing", 816 1682 "windows-sys 0.60.2", 817 1683 ] ··· 833 1699 834 1700 [[package]] 835 1701 name = "rand" 1702 + version = "0.8.5" 1703 + source = "registry+https://github.com/rust-lang/crates.io-index" 1704 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1705 + dependencies = [ 1706 + "libc", 1707 + "rand_chacha 0.3.1", 1708 + "rand_core 0.6.4", 1709 + ] 1710 + 1711 + [[package]] 1712 + name = "rand" 836 1713 version = "0.9.2" 837 1714 source = "registry+https://github.com/rust-lang/crates.io-index" 838 1715 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 839 1716 dependencies = [ 840 - "rand_chacha", 841 - "rand_core", 1717 + "rand_chacha 0.9.0", 1718 + "rand_core 0.9.5", 1719 + ] 1720 + 1721 + [[package]] 1722 + name = "rand_chacha" 1723 + version = "0.3.1" 1724 + source = "registry+https://github.com/rust-lang/crates.io-index" 1725 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1726 + dependencies = [ 1727 + "ppv-lite86", 1728 + "rand_core 0.6.4", 842 1729 ] 843 1730 844 1731 [[package]] ··· 848 1735 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 849 1736 dependencies = [ 850 1737 "ppv-lite86", 851 - "rand_core", 1738 + "rand_core 0.9.5", 1739 + ] 1740 + 1741 + [[package]] 1742 + name = "rand_core" 1743 + version = "0.6.4" 1744 + source = "registry+https://github.com/rust-lang/crates.io-index" 1745 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1746 + dependencies = [ 1747 + "getrandom 0.2.17", 852 1748 ] 853 1749 854 1750 [[package]] ··· 894 1790 dependencies = [ 895 1791 "base64", 896 1792 "bytes", 1793 + "encoding_rs", 897 1794 "futures-core", 1795 + "futures-util", 1796 + "h2", 898 1797 "http", 899 1798 "http-body", 900 1799 "http-body-util", ··· 903 1802 "hyper-util", 904 1803 "js-sys", 905 1804 "log", 1805 + "mime", 1806 + "mime_guess", 906 1807 "percent-encoding", 907 1808 "pin-project-lite", 908 1809 "quinn", ··· 925 1826 ] 926 1827 927 1828 [[package]] 1829 + name = "reqwest-chain" 1830 + version = "1.0.0" 1831 + source = "registry+https://github.com/rust-lang/crates.io-index" 1832 + checksum = "da5c014fb79a8227db44a0433d748107750d2550b7fca55c59a3d7ee7d2ee2b2" 1833 + dependencies = [ 1834 + "anyhow", 1835 + "async-trait", 1836 + "http", 1837 + "reqwest-middleware", 1838 + ] 1839 + 1840 + [[package]] 1841 + name = "reqwest-middleware" 1842 + version = "0.4.2" 1843 + source = "registry+https://github.com/rust-lang/crates.io-index" 1844 + checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" 1845 + dependencies = [ 1846 + "anyhow", 1847 + "async-trait", 1848 + "http", 1849 + "reqwest", 1850 + "serde", 1851 + "thiserror 1.0.69", 1852 + "tower-service", 1853 + ] 1854 + 1855 + [[package]] 1856 + name = "resolv-conf" 1857 + version = "0.7.6" 1858 + source = "registry+https://github.com/rust-lang/crates.io-index" 1859 + checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" 1860 + 1861 + [[package]] 1862 + name = "rfc6979" 1863 + version = "0.4.0" 1864 + source = "registry+https://github.com/rust-lang/crates.io-index" 1865 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 1866 + dependencies = [ 1867 + "hmac", 1868 + "subtle", 1869 + ] 1870 + 1871 + [[package]] 928 1872 name = "ring" 929 1873 version = "0.17.14" 930 1874 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1011 1955 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1012 1956 1013 1957 [[package]] 1958 + name = "sec1" 1959 + version = "0.7.3" 1960 + source = "registry+https://github.com/rust-lang/crates.io-index" 1961 + checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 1962 + dependencies = [ 1963 + "base16ct", 1964 + "der", 1965 + "generic-array", 1966 + "pkcs8", 1967 + "serdect", 1968 + "subtle", 1969 + "zeroize", 1970 + ] 1971 + 1972 + [[package]] 1014 1973 name = "semver" 1015 1974 version = "1.0.27" 1016 1975 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1027 1986 ] 1028 1987 1029 1988 [[package]] 1989 + name = "serde_bytes" 1990 + version = "0.11.19" 1991 + source = "registry+https://github.com/rust-lang/crates.io-index" 1992 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 1993 + dependencies = [ 1994 + "serde", 1995 + "serde_core", 1996 + ] 1997 + 1998 + [[package]] 1030 1999 name = "serde_core" 1031 2000 version = "1.0.228" 1032 2001 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1060 2029 ] 1061 2030 1062 2031 [[package]] 2032 + name = "serde_path_to_error" 2033 + version = "0.1.20" 2034 + source = "registry+https://github.com/rust-lang/crates.io-index" 2035 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 2036 + dependencies = [ 2037 + "itoa", 2038 + "serde", 2039 + "serde_core", 2040 + ] 2041 + 2042 + [[package]] 1063 2043 name = "serde_urlencoded" 1064 2044 version = "0.7.1" 1065 2045 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1072 2052 ] 1073 2053 1074 2054 [[package]] 2055 + name = "serdect" 2056 + version = "0.2.0" 2057 + source = "registry+https://github.com/rust-lang/crates.io-index" 2058 + checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" 2059 + dependencies = [ 2060 + "base16ct", 2061 + "serde", 2062 + ] 2063 + 2064 + [[package]] 2065 + name = "sha2" 2066 + version = "0.10.9" 2067 + source = "registry+https://github.com/rust-lang/crates.io-index" 2068 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 2069 + dependencies = [ 2070 + "cfg-if", 2071 + "cpufeatures", 2072 + "digest", 2073 + ] 2074 + 2075 + [[package]] 1075 2076 name = "sharded-slab" 1076 2077 version = "0.1.7" 1077 2078 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1097 2098 ] 1098 2099 1099 2100 [[package]] 2101 + name = "signature" 2102 + version = "2.2.0" 2103 + source = "registry+https://github.com/rust-lang/crates.io-index" 2104 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2105 + dependencies = [ 2106 + "digest", 2107 + "rand_core 0.6.4", 2108 + ] 2109 + 2110 + [[package]] 1100 2111 name = "slab" 1101 2112 version = "0.4.12" 1102 2113 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1110 2121 1111 2122 [[package]] 1112 2123 name = "socket2" 2124 + version = "0.5.10" 2125 + source = "registry+https://github.com/rust-lang/crates.io-index" 2126 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 2127 + dependencies = [ 2128 + "libc", 2129 + "windows-sys 0.52.0", 2130 + ] 2131 + 2132 + [[package]] 2133 + name = "socket2" 1113 2134 version = "0.6.2" 1114 2135 source = "registry+https://github.com/rust-lang/crates.io-index" 1115 2136 checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" ··· 1119 2140 ] 1120 2141 1121 2142 [[package]] 2143 + name = "spki" 2144 + version = "0.7.3" 2145 + source = "registry+https://github.com/rust-lang/crates.io-index" 2146 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2147 + dependencies = [ 2148 + "base64ct", 2149 + "der", 2150 + ] 2151 + 2152 + [[package]] 1122 2153 name = "stable_deref_trait" 1123 2154 version = "1.2.1" 1124 2155 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1168 2199 ] 1169 2200 1170 2201 [[package]] 2202 + name = "system-configuration" 2203 + version = "0.7.0" 2204 + source = "registry+https://github.com/rust-lang/crates.io-index" 2205 + checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" 2206 + dependencies = [ 2207 + "bitflags", 2208 + "core-foundation", 2209 + "system-configuration-sys", 2210 + ] 2211 + 2212 + [[package]] 2213 + name = "system-configuration-sys" 2214 + version = "0.6.0" 2215 + source = "registry+https://github.com/rust-lang/crates.io-index" 2216 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 2217 + dependencies = [ 2218 + "core-foundation-sys", 2219 + "libc", 2220 + ] 2221 + 2222 + [[package]] 2223 + name = "tagptr" 2224 + version = "0.2.0" 2225 + source = "registry+https://github.com/rust-lang/crates.io-index" 2226 + checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 2227 + 2228 + [[package]] 1171 2229 name = "tempfile" 1172 2230 version = "3.25.0" 1173 2231 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1182 2240 1183 2241 [[package]] 1184 2242 name = "thiserror" 2243 + version = "1.0.69" 2244 + source = "registry+https://github.com/rust-lang/crates.io-index" 2245 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2246 + dependencies = [ 2247 + "thiserror-impl 1.0.69", 2248 + ] 2249 + 2250 + [[package]] 2251 + name = "thiserror" 1185 2252 version = "2.0.18" 1186 2253 source = "registry+https://github.com/rust-lang/crates.io-index" 1187 2254 checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1188 2255 dependencies = [ 1189 - "thiserror-impl", 2256 + "thiserror-impl 2.0.18", 2257 + ] 2258 + 2259 + [[package]] 2260 + name = "thiserror-impl" 2261 + version = "1.0.69" 2262 + source = "registry+https://github.com/rust-lang/crates.io-index" 2263 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 2264 + dependencies = [ 2265 + "proc-macro2", 2266 + "quote", 2267 + "syn", 1190 2268 ] 1191 2269 1192 2270 [[package]] ··· 1246 2324 "parking_lot", 1247 2325 "pin-project-lite", 1248 2326 "signal-hook-registry", 1249 - "socket2", 2327 + "socket2 0.6.2", 1250 2328 "tokio-macros", 1251 2329 "windows-sys 0.61.2", 1252 2330 ] ··· 1273 2351 ] 1274 2352 1275 2353 [[package]] 2354 + name = "tokio-util" 2355 + version = "0.7.18" 2356 + source = "registry+https://github.com/rust-lang/crates.io-index" 2357 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 2358 + dependencies = [ 2359 + "bytes", 2360 + "futures-core", 2361 + "futures-sink", 2362 + "pin-project-lite", 2363 + "tokio", 2364 + ] 2365 + 2366 + [[package]] 1276 2367 name = "tower" 1277 2368 version = "0.5.3" 1278 2369 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1285 2376 "tokio", 1286 2377 "tower-layer", 1287 2378 "tower-service", 2379 + "tracing", 1288 2380 ] 1289 2381 1290 2382 [[package]] ··· 1386 2478 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1387 2479 1388 2480 [[package]] 2481 + name = "typenum" 2482 + version = "1.19.0" 2483 + source = "registry+https://github.com/rust-lang/crates.io-index" 2484 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2485 + 2486 + [[package]] 2487 + name = "ulid" 2488 + version = "1.2.1" 2489 + source = "registry+https://github.com/rust-lang/crates.io-index" 2490 + checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 2491 + dependencies = [ 2492 + "rand 0.9.2", 2493 + "web-time", 2494 + ] 2495 + 2496 + [[package]] 2497 + name = "unicase" 2498 + version = "2.9.0" 2499 + source = "registry+https://github.com/rust-lang/crates.io-index" 2500 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 2501 + 2502 + [[package]] 1389 2503 name = "unicode-ident" 1390 2504 version = "1.0.24" 1391 2505 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1396 2510 version = "0.2.6" 1397 2511 source = "registry+https://github.com/rust-lang/crates.io-index" 1398 2512 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 2513 + 2514 + [[package]] 2515 + name = "unsigned-varint" 2516 + version = "0.8.0" 2517 + source = "registry+https://github.com/rust-lang/crates.io-index" 2518 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 1399 2519 1400 2520 [[package]] 1401 2521 name = "untrusted" ··· 1428 2548 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1429 2549 1430 2550 [[package]] 2551 + name = "uuid" 2552 + version = "1.22.0" 2553 + source = "registry+https://github.com/rust-lang/crates.io-index" 2554 + checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" 2555 + dependencies = [ 2556 + "getrandom 0.4.1", 2557 + "js-sys", 2558 + "wasm-bindgen", 2559 + ] 2560 + 2561 + [[package]] 1431 2562 name = "valuable" 1432 2563 version = "0.1.1" 1433 2564 source = "registry+https://github.com/rust-lang/crates.io-index" 1434 2565 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2566 + 2567 + [[package]] 2568 + name = "version_check" 2569 + version = "0.9.5" 2570 + source = "registry+https://github.com/rust-lang/crates.io-index" 2571 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1435 2572 1436 2573 [[package]] 1437 2574 name = "want" ··· 1589 2726 ] 1590 2727 1591 2728 [[package]] 2729 + name = "widestring" 2730 + version = "1.2.1" 2731 + source = "registry+https://github.com/rust-lang/crates.io-index" 2732 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 2733 + 2734 + [[package]] 2735 + name = "windows-core" 2736 + version = "0.62.2" 2737 + source = "registry+https://github.com/rust-lang/crates.io-index" 2738 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 2739 + dependencies = [ 2740 + "windows-implement", 2741 + "windows-interface", 2742 + "windows-link", 2743 + "windows-result", 2744 + "windows-strings", 2745 + ] 2746 + 2747 + [[package]] 2748 + name = "windows-implement" 2749 + version = "0.60.2" 2750 + source = "registry+https://github.com/rust-lang/crates.io-index" 2751 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 2752 + dependencies = [ 2753 + "proc-macro2", 2754 + "quote", 2755 + "syn", 2756 + ] 2757 + 2758 + [[package]] 2759 + name = "windows-interface" 2760 + version = "0.59.3" 2761 + source = "registry+https://github.com/rust-lang/crates.io-index" 2762 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 2763 + dependencies = [ 2764 + "proc-macro2", 2765 + "quote", 2766 + "syn", 2767 + ] 2768 + 2769 + [[package]] 1592 2770 name = "windows-link" 1593 2771 version = "0.2.1" 1594 2772 source = "registry+https://github.com/rust-lang/crates.io-index" 1595 2773 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1596 2774 1597 2775 [[package]] 2776 + name = "windows-registry" 2777 + version = "0.6.1" 2778 + source = "registry+https://github.com/rust-lang/crates.io-index" 2779 + checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" 2780 + dependencies = [ 2781 + "windows-link", 2782 + "windows-result", 2783 + "windows-strings", 2784 + ] 2785 + 2786 + [[package]] 2787 + name = "windows-result" 2788 + version = "0.4.1" 2789 + source = "registry+https://github.com/rust-lang/crates.io-index" 2790 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 2791 + dependencies = [ 2792 + "windows-link", 2793 + ] 2794 + 2795 + [[package]] 2796 + name = "windows-strings" 2797 + version = "0.5.1" 2798 + source = "registry+https://github.com/rust-lang/crates.io-index" 2799 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 2800 + dependencies = [ 2801 + "windows-link", 2802 + ] 2803 + 2804 + [[package]] 2805 + name = "windows-sys" 2806 + version = "0.48.0" 2807 + source = "registry+https://github.com/rust-lang/crates.io-index" 2808 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 2809 + dependencies = [ 2810 + "windows-targets 0.48.5", 2811 + ] 2812 + 2813 + [[package]] 1598 2814 name = "windows-sys" 1599 2815 version = "0.52.0" 1600 2816 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1623 2839 1624 2840 [[package]] 1625 2841 name = "windows-targets" 2842 + version = "0.48.5" 2843 + source = "registry+https://github.com/rust-lang/crates.io-index" 2844 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 2845 + dependencies = [ 2846 + "windows_aarch64_gnullvm 0.48.5", 2847 + "windows_aarch64_msvc 0.48.5", 2848 + "windows_i686_gnu 0.48.5", 2849 + "windows_i686_msvc 0.48.5", 2850 + "windows_x86_64_gnu 0.48.5", 2851 + "windows_x86_64_gnullvm 0.48.5", 2852 + "windows_x86_64_msvc 0.48.5", 2853 + ] 2854 + 2855 + [[package]] 2856 + name = "windows-targets" 1626 2857 version = "0.52.6" 1627 2858 source = "registry+https://github.com/rust-lang/crates.io-index" 1628 2859 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" ··· 1656 2887 1657 2888 [[package]] 1658 2889 name = "windows_aarch64_gnullvm" 2890 + version = "0.48.5" 2891 + source = "registry+https://github.com/rust-lang/crates.io-index" 2892 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 2893 + 2894 + [[package]] 2895 + name = "windows_aarch64_gnullvm" 1659 2896 version = "0.52.6" 1660 2897 source = "registry+https://github.com/rust-lang/crates.io-index" 1661 2898 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" ··· 1665 2902 version = "0.53.1" 1666 2903 source = "registry+https://github.com/rust-lang/crates.io-index" 1667 2904 checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 2905 + 2906 + [[package]] 2907 + name = "windows_aarch64_msvc" 2908 + version = "0.48.5" 2909 + source = "registry+https://github.com/rust-lang/crates.io-index" 2910 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1668 2911 1669 2912 [[package]] 1670 2913 name = "windows_aarch64_msvc" ··· 1680 2923 1681 2924 [[package]] 1682 2925 name = "windows_i686_gnu" 2926 + version = "0.48.5" 2927 + source = "registry+https://github.com/rust-lang/crates.io-index" 2928 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 2929 + 2930 + [[package]] 2931 + name = "windows_i686_gnu" 1683 2932 version = "0.52.6" 1684 2933 source = "registry+https://github.com/rust-lang/crates.io-index" 1685 2934 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" ··· 1704 2953 1705 2954 [[package]] 1706 2955 name = "windows_i686_msvc" 2956 + version = "0.48.5" 2957 + source = "registry+https://github.com/rust-lang/crates.io-index" 2958 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 2959 + 2960 + [[package]] 2961 + name = "windows_i686_msvc" 1707 2962 version = "0.52.6" 1708 2963 source = "registry+https://github.com/rust-lang/crates.io-index" 1709 2964 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" ··· 1716 2971 1717 2972 [[package]] 1718 2973 name = "windows_x86_64_gnu" 2974 + version = "0.48.5" 2975 + source = "registry+https://github.com/rust-lang/crates.io-index" 2976 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 2977 + 2978 + [[package]] 2979 + name = "windows_x86_64_gnu" 1719 2980 version = "0.52.6" 1720 2981 source = "registry+https://github.com/rust-lang/crates.io-index" 1721 2982 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" ··· 1728 2989 1729 2990 [[package]] 1730 2991 name = "windows_x86_64_gnullvm" 2992 + version = "0.48.5" 2993 + source = "registry+https://github.com/rust-lang/crates.io-index" 2994 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 2995 + 2996 + [[package]] 2997 + name = "windows_x86_64_gnullvm" 1731 2998 version = "0.52.6" 1732 2999 source = "registry+https://github.com/rust-lang/crates.io-index" 1733 3000 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" ··· 1737 3004 version = "0.53.1" 1738 3005 source = "registry+https://github.com/rust-lang/crates.io-index" 1739 3006 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 3007 + 3008 + [[package]] 3009 + name = "windows_x86_64_msvc" 3010 + version = "0.48.5" 3011 + source = "registry+https://github.com/rust-lang/crates.io-index" 3012 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1740 3013 1741 3014 [[package]] 1742 3015 name = "windows_x86_64_msvc" ··· 1749 3022 version = "0.53.1" 1750 3023 source = "registry+https://github.com/rust-lang/crates.io-index" 1751 3024 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 3025 + 3026 + [[package]] 3027 + name = "winreg" 3028 + version = "0.50.0" 3029 + source = "registry+https://github.com/rust-lang/crates.io-index" 3030 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 3031 + dependencies = [ 3032 + "cfg-if", 3033 + "windows-sys 0.48.0", 3034 + ] 1752 3035 1753 3036 [[package]] 1754 3037 name = "wit-bindgen"
+9
Cargo.toml
··· 10 10 path = "src/main.rs" 11 11 12 12 [dependencies] 13 + atproto-identity = "0.14.0" 14 + atproto-oauth = { path = "../atproto-oauth", default-features = false } 15 + axum = "0.8" 16 + chrono = "0.4" 13 17 clap = { version = "4.5", features = ["derive"] } 18 + elliptic-curve = { version = "0.13", features = ["jwk"] } 14 19 reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 15 20 serde = { version = "1.0", features = ["derive"] } 16 21 serde_json = "1.0" ··· 18 23 tokio = { version = "1", features = ["full"] } 19 24 tracing = { version = "0.1", features = ["log"] } 20 25 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 26 + ulid = "1" 21 27 22 28 [features] 23 29 e2e = [] 24 30 25 31 [dev-dependencies] 32 + anyhow = "1.0" 33 + base64 = "0.22" 34 + p256 = { version = "0.13", features = ["jwk"] } 26 35 tempfile = "3.13.0"
+85
playwright-test/conftest.py
··· 1 + """Pytest fixtures for pds-git-remote OAuth playwright tests. 2 + 3 + Provides test configuration, binary location, PDS session tokens, 4 + and a shared Playwright browser context. 5 + """ 6 + 7 + import json 8 + import os 9 + import subprocess 10 + import tempfile 11 + 12 + import pytest 13 + import requests 14 + import toml 15 + 16 + 17 + OAUTH_CALLBACK_PORT = 8271 18 + 19 + 20 + @pytest.fixture(scope="session") 21 + def test_config(): 22 + """Load test user configuration from testuser.toml.""" 23 + config_path = os.path.join(os.path.dirname(__file__), "testuser.toml") 24 + with open(config_path) as f: 25 + return toml.load(f) 26 + 27 + 28 + @pytest.fixture(scope="session") 29 + def cargo_binary(): 30 + """Locate the built git-remote-pds binary.""" 31 + repo_root = os.path.dirname(os.path.dirname(__file__)) 32 + result = subprocess.run( 33 + ["cargo", "metadata", "--format-version", "1", "--no-deps"], 34 + cwd=repo_root, 35 + capture_output=True, 36 + text=True, 37 + ) 38 + metadata = json.loads(result.stdout) 39 + target_dir = metadata["target_directory"] 40 + binary_path = os.path.join(target_dir, "debug", "git-remote-pds") 41 + 42 + assert os.path.isfile(binary_path), ( 43 + f"git-remote-pds binary not found at {binary_path}. Run `cargo build` first." 44 + ) 45 + 46 + return {"binary": binary_path, "debug_dir": os.path.join(target_dir, "debug")} 47 + 48 + 49 + @pytest.fixture(scope="session") 50 + def pds_tokens(test_config): 51 + """Get createSession tokens for read-back verification (clone).""" 52 + resp = requests.post( 53 + f"{test_config['pds']}/xrpc/com.atproto.server.createSession", 54 + json={ 55 + "identifier": test_config["handle"], 56 + "password": test_config["password"], 57 + }, 58 + ) 59 + assert resp.status_code == 200, f"createSession failed: {resp.text}" 60 + data = resp.json() 61 + return {"access_jwt": data["accessJwt"], "did": data["did"]} 62 + 63 + 64 + @pytest.fixture(scope="session") 65 + def auth_config_dir(): 66 + """Create a temporary config directory for auth.json during tests.""" 67 + tmpdir = tempfile.mkdtemp(prefix="pds-git-remote-test-config-") 68 + yield tmpdir 69 + # cleanup 70 + import shutil 71 + shutil.rmtree(tmpdir, ignore_errors=True) 72 + 73 + 74 + @pytest.fixture(scope="session") 75 + def browser_context(): 76 + """Launch a headless Chromium browser context (shared across tests).""" 77 + from playwright.sync_api import sync_playwright 78 + 79 + pw = sync_playwright().start() 80 + browser = pw.chromium.launch(headless=True) 81 + context = browser.new_context() 82 + yield context 83 + context.close() 84 + browser.close() 85 + pw.stop()
+4
playwright-test/requirements.txt
··· 1 + playwright 2 + pytest 3 + requests 4 + toml
+15
playwright-test/run.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 5 + REPO_DIR="$(dirname "$SCRIPT_DIR")" 6 + 7 + echo "=== Installing Python dependencies ===" 8 + pip install -q -r "$SCRIPT_DIR/requirements.txt" 9 + python -m playwright install chromium 10 + 11 + echo "=== Building git-remote-pds ===" 12 + (cd "$REPO_DIR" && cargo build --quiet) 13 + 14 + echo "=== Running OAuth playwright tests ===" 15 + (cd "$SCRIPT_DIR" && pytest -v test_oauth_push.py "$@")
+333
playwright-test/test_oauth_push.py
··· 1 + """End-to-end tests for pds-git-remote OAuth login and push. 2 + 3 + Tests: 4 + 1. OAuth login via browser (Playwright automates PDS consent) 5 + 2. Push a repo using the OAuth/DPoP credentials 6 + 3. Incremental push with a second commit 7 + """ 8 + 9 + import os 10 + import re 11 + import subprocess 12 + import tempfile 13 + import time 14 + import threading 15 + 16 + import pytest 17 + 18 + 19 + def read_stderr_lines(proc, lines, stop_event): 20 + """Read stderr lines from a subprocess into a shared list.""" 21 + while not stop_event.is_set(): 22 + line = proc.stderr.readline() 23 + if line: 24 + lines.append(line) 25 + elif proc.poll() is not None: 26 + break 27 + 28 + 29 + def test_oauth_login(test_config, cargo_binary, browser_context, auth_config_dir): 30 + """Test browser-based OAuth login stores DPoP credentials.""" 31 + binary = cargo_binary["binary"] 32 + handle = test_config["handle"] 33 + pds_url = test_config["pds"] 34 + password = test_config["password"] 35 + 36 + # use a custom HOME so auth.json goes to our temp dir 37 + env = os.environ.copy() 38 + env["HOME"] = auth_config_dir 39 + env["RUST_LOG"] = "debug" 40 + 41 + # start the oauth-login subprocess 42 + proc = subprocess.Popen( 43 + [ 44 + binary, 45 + "auth", "oauth-login", 46 + "--handle", handle, 47 + "--pds-url", pds_url, 48 + "--port", "8271", 49 + ], 50 + env=env, 51 + stdout=subprocess.PIPE, 52 + stderr=subprocess.PIPE, 53 + text=True, 54 + ) 55 + 56 + # collect stderr lines in a background thread 57 + stderr_lines = [] 58 + stop_event = threading.Event() 59 + reader = threading.Thread(target=read_stderr_lines, args=(proc, stderr_lines, stop_event)) 60 + reader.daemon = True 61 + reader.start() 62 + 63 + # wait for the authorization URL to appear in stderr 64 + # the tool prints it on a line by itself (indented) after "Open this URL" 65 + auth_url = None 66 + deadline = time.time() + 30 67 + seen_open_prompt = False 68 + while time.time() < deadline: 69 + for line in stderr_lines: 70 + stripped = line.strip() 71 + # detect the "Open this URL" prompt 72 + if "open this url" in stripped.lower(): 73 + seen_open_prompt = True 74 + continue 75 + # the next line with an https URL after the prompt is the auth URL 76 + if seen_open_prompt and stripped.startswith("http") and "request_uri=" in stripped: 77 + auth_url = stripped 78 + break 79 + if auth_url: 80 + break 81 + time.sleep(0.3) 82 + 83 + assert auth_url is not None, ( 84 + f"Did not find authorization URL in stderr within 30s.\n" 85 + f"Stderr so far: {''.join(stderr_lines)}" 86 + ) 87 + 88 + print(f"Authorization URL: {auth_url}") 89 + 90 + # use playwright to complete the OAuth flow 91 + page = browser_context.new_page() 92 + 93 + try: 94 + # navigate directly to the PDS authorization page 95 + page.goto(auth_url, wait_until="networkidle", timeout=15_000) 96 + 97 + # enter password on the PDS login form 98 + password_input = page.locator("input[type='password']") 99 + password_input.wait_for(state="visible", timeout=15_000) 100 + password_input.fill(password) 101 + page.get_by_role("button", name="Sign in").click() 102 + 103 + # handle consent page — click accept/authorize buttons until redirected 104 + consent_deadline = time.time() + 30 105 + redirected = False 106 + while time.time() < consent_deadline: 107 + page.wait_for_load_state("networkidle", timeout=10_000) 108 + 109 + # check if we've been redirected to the localhost callback 110 + if "127.0.0.1:8271" in page.url and "code=" in page.url: 111 + redirected = True 112 + break 113 + 114 + # click any consent/authorize buttons 115 + for btn_name in ["Accept", "Authorize", "Allow", "Continue", "Approve"]: 116 + btn = page.get_by_role("button", name=btn_name) 117 + if btn.count() > 0 and btn.first.is_visible(): 118 + btn.first.click() 119 + time.sleep(1) 120 + break 121 + else: 122 + time.sleep(0.5) 123 + 124 + assert redirected, ( 125 + f"Browser did not redirect to callback, stuck at: {page.url}" 126 + ) 127 + 128 + finally: 129 + page.close() 130 + 131 + # wait for the subprocess to finish (it should exit after receiving callback) 132 + try: 133 + proc.wait(timeout=15) 134 + except subprocess.TimeoutExpired: 135 + proc.kill() 136 + proc.wait() 137 + 138 + stop_event.set() 139 + reader.join(timeout=5) 140 + 141 + full_stderr = "".join(stderr_lines) 142 + assert proc.returncode == 0, ( 143 + f"oauth-login exited with code {proc.returncode}.\nStderr: {full_stderr}" 144 + ) 145 + 146 + # verify credentials were stored 147 + auth_json_path = os.path.join( 148 + auth_config_dir, ".config", "pds-git-remote", "auth.json" 149 + ) 150 + assert os.path.isfile(auth_json_path), ( 151 + f"auth.json not found at {auth_json_path}" 152 + ) 153 + 154 + import json 155 + with open(auth_json_path) as f: 156 + auth_config = json.load(f) 157 + 158 + cred = auth_config["credentials"].get(handle) 159 + assert cred is not None, f"No credential stored for {handle}" 160 + assert cred.get("dpop_key") is not None, "Credential missing dpop_key" 161 + assert cred.get("did") == test_config["did"], ( 162 + f"DID mismatch: {cred.get('did')} != {test_config['did']}" 163 + ) 164 + print(f"OAuth login successful: {cred['did']} @ {cred['pds_url']}") 165 + 166 + 167 + def test_oauth_push(test_config, cargo_binary, auth_config_dir, pds_tokens): 168 + """Test pushing a repo using OAuth/DPoP credentials from oauth-login.""" 169 + handle = test_config["handle"] 170 + debug_dir = cargo_binary["debug_dir"] 171 + 172 + # create a unique repo name 173 + repo_name = f"playwright-test-{int(time.time())}" 174 + 175 + # create a temp git repo with a test file 176 + repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-push-test-") 177 + env = os.environ.copy() 178 + env["HOME"] = auth_config_dir 179 + env["PATH"] = debug_dir + ":" + env.get("PATH", "") 180 + env["PDS_URL"] = test_config["pds"] 181 + 182 + try: 183 + # init repo 184 + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, 185 + capture_output=True) 186 + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir, 187 + check=True, capture_output=True) 188 + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, 189 + check=True, capture_output=True) 190 + 191 + # create test file 192 + test_file = os.path.join(repo_dir, "hello.txt") 193 + with open(test_file, "w") as f: 194 + f.write(f"Hello from playwright test {repo_name}\n") 195 + 196 + subprocess.run(["git", "add", "hello.txt"], cwd=repo_dir, check=True, 197 + capture_output=True) 198 + subprocess.run(["git", "commit", "-m", "initial commit"], cwd=repo_dir, 199 + check=True, capture_output=True) 200 + 201 + # add PDS remote 202 + pds_remote = f"pds://{handle}/{repo_name}" 203 + subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir, 204 + check=True, capture_output=True) 205 + 206 + # push using OAuth/DPoP credentials (from auth.json) 207 + result = subprocess.run( 208 + ["git", "push", "origin", "main"], 209 + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, 210 + ) 211 + assert result.returncode == 0, ( 212 + f"git push failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 213 + ) 214 + print(f"Push successful to {pds_remote}") 215 + 216 + # verify by cloning back with createSession token 217 + clone_dir = tempfile.mkdtemp(prefix="pds-git-remote-clone-test-") 218 + clone_env = os.environ.copy() 219 + clone_env["PATH"] = debug_dir + ":" + clone_env.get("PATH", "") 220 + clone_env["PDS_ACCESS_TOKEN"] = pds_tokens["access_jwt"] 221 + clone_env["PDS_DID"] = pds_tokens["did"] 222 + clone_env["PDS_URL"] = test_config["pds"] 223 + 224 + clone_result = subprocess.run( 225 + ["git", "clone", pds_remote, clone_dir], 226 + env=clone_env, capture_output=True, text=True, timeout=60, 227 + ) 228 + assert clone_result.returncode == 0, ( 229 + f"git clone failed:\nstdout: {clone_result.stdout}\nstderr: {clone_result.stderr}" 230 + ) 231 + 232 + # verify file content 233 + cloned_file = os.path.join(clone_dir, "hello.txt") 234 + assert os.path.isfile(cloned_file), "hello.txt not found in clone" 235 + with open(cloned_file) as f: 236 + content = f.read() 237 + assert f"Hello from playwright test {repo_name}" in content, ( 238 + f"File content mismatch: {content}" 239 + ) 240 + print("Clone verification successful") 241 + 242 + finally: 243 + import shutil 244 + shutil.rmtree(repo_dir, ignore_errors=True) 245 + 246 + 247 + def test_oauth_incremental_push(test_config, cargo_binary, auth_config_dir, pds_tokens): 248 + """Test incremental push (second commit) using OAuth/DPoP credentials.""" 249 + handle = test_config["handle"] 250 + debug_dir = cargo_binary["debug_dir"] 251 + 252 + # create a unique repo name 253 + repo_name = f"playwright-incr-{int(time.time())}" 254 + 255 + repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-incr-test-") 256 + env = os.environ.copy() 257 + env["HOME"] = auth_config_dir 258 + env["PATH"] = debug_dir + ":" + env.get("PATH", "") 259 + env["PDS_URL"] = test_config["pds"] 260 + 261 + try: 262 + # init repo 263 + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, 264 + capture_output=True) 265 + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir, 266 + check=True, capture_output=True) 267 + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, 268 + check=True, capture_output=True) 269 + 270 + pds_remote = f"pds://{handle}/{repo_name}" 271 + subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir, 272 + check=True, capture_output=True) 273 + 274 + # first commit + push 275 + file1 = os.path.join(repo_dir, "file1.txt") 276 + with open(file1, "w") as f: 277 + f.write("First file\n") 278 + subprocess.run(["git", "add", "file1.txt"], cwd=repo_dir, check=True, 279 + capture_output=True) 280 + subprocess.run(["git", "commit", "-m", "first commit"], cwd=repo_dir, 281 + check=True, capture_output=True) 282 + 283 + result = subprocess.run( 284 + ["git", "push", "origin", "main"], 285 + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, 286 + ) 287 + assert result.returncode == 0, ( 288 + f"First push failed:\nstderr: {result.stderr}" 289 + ) 290 + 291 + # second commit + push (incremental) 292 + file2 = os.path.join(repo_dir, "file2.txt") 293 + with open(file2, "w") as f: 294 + f.write("Second file\n") 295 + subprocess.run(["git", "add", "file2.txt"], cwd=repo_dir, check=True, 296 + capture_output=True) 297 + subprocess.run(["git", "commit", "-m", "second commit"], cwd=repo_dir, 298 + check=True, capture_output=True) 299 + 300 + result = subprocess.run( 301 + ["git", "push", "origin", "main"], 302 + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, 303 + ) 304 + assert result.returncode == 0, ( 305 + f"Incremental push failed:\nstderr: {result.stderr}" 306 + ) 307 + print("Incremental push successful") 308 + 309 + # verify by cloning back 310 + clone_dir = tempfile.mkdtemp(prefix="pds-git-remote-incr-clone-") 311 + clone_env = os.environ.copy() 312 + clone_env["PATH"] = debug_dir + ":" + clone_env.get("PATH", "") 313 + clone_env["PDS_ACCESS_TOKEN"] = pds_tokens["access_jwt"] 314 + clone_env["PDS_DID"] = pds_tokens["did"] 315 + clone_env["PDS_URL"] = test_config["pds"] 316 + 317 + clone_result = subprocess.run( 318 + ["git", "clone", pds_remote, clone_dir], 319 + env=clone_env, capture_output=True, text=True, timeout=60, 320 + ) 321 + assert clone_result.returncode == 0, ( 322 + f"Clone failed:\nstderr: {clone_result.stderr}" 323 + ) 324 + 325 + assert os.path.isfile(os.path.join(clone_dir, "file1.txt")) 326 + assert os.path.isfile(os.path.join(clone_dir, "file2.txt")) 327 + with open(os.path.join(clone_dir, "file2.txt")) as f: 328 + assert "Second file" in f.read() 329 + print("Incremental clone verification successful") 330 + 331 + finally: 332 + import shutil 333 + shutil.rmtree(repo_dir, ignore_errors=True)
+146
scripts/remote-test/run-dpop.sh
··· 1 + #!/usr/bin/env bash 2 + # Tests git-remote-pds with DPoP-bound OAuth tokens. 3 + # 4 + # Unlike run.sh (which uses createSession/Bearer auth), this tests the 5 + # DPoP auth path used when tokens come from ATProto OAuth. 6 + # 7 + # Prerequisites: obtain DPoP credentials from an OAuth login (e.g., via 8 + # lichen-server) and export them as env vars. 9 + # 10 + # Usage: 11 + # PDS_URL=https://my-pds.example.com \ 12 + # PDS_ACCESS_TOKEN=<dpop-bound-jwt> \ 13 + # PDS_DID=did:plc:xxx \ 14 + # PDS_DPOP_KEY='{"kid":"...","alg":"ES256","kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."}' \ 15 + # ./run-dpop.sh 16 + # 17 + # Environment variables: 18 + # PDS_URL — full URL of the PDS (required) 19 + # PDS_ACCESS_TOKEN — DPoP-bound access token from OAuth (required) 20 + # PDS_DID — DID of the authenticated user (required) 21 + # PDS_DPOP_KEY — serialized WrappedJsonWebKey JSON with private key (required) 22 + # PDS_HANDLE — AT Protocol handle (optional, defaults to extracting from DID) 23 + set -euo pipefail 24 + 25 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 26 + CRATE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" 27 + 28 + # ── validate required env vars ─────────────────────────────────────── 29 + for var in PDS_URL PDS_ACCESS_TOKEN PDS_DID PDS_DPOP_KEY; do 30 + if [ -z "${!var:-}" ]; then 31 + echo "ERROR: ${var} is not set" 32 + echo "" 33 + echo "Usage: PDS_URL=... PDS_ACCESS_TOKEN=... PDS_DID=... PDS_DPOP_KEY=... $0" 34 + echo "" 35 + echo "Obtain these values from an OAuth login (e.g., lichen-server debug endpoint)." 36 + exit 1 37 + fi 38 + done 39 + 40 + # Default handle from DID if not provided 41 + PDS_HANDLE="${PDS_HANDLE:-${PDS_DID}}" 42 + 43 + echo "PDS_URL = ${PDS_URL}" 44 + echo "PDS_DID = ${PDS_DID}" 45 + echo "PDS_HANDLE = ${PDS_HANDLE}" 46 + echo "Auth mode = DPoP" 47 + echo "" 48 + 49 + # ── unique rkey to avoid collisions across runs ────────────────────── 50 + RKEY="test-dpop-$(date +%s%N)" 51 + REPO_NAME="${RKEY}" 52 + 53 + # ── temp dirs, cleaned up on exit ──────────────────────────────────── 54 + TMPDIR_BASE="$(mktemp -d)" 55 + cleanup() { 56 + echo "" 57 + echo "Cleaning up temp dirs..." 58 + rm -rf "${TMPDIR_BASE}" 59 + } 60 + trap cleanup EXIT 61 + 62 + PUSH_REPO="${TMPDIR_BASE}/push-repo" 63 + CLONE_REPO="${TMPDIR_BASE}/clone-repo" 64 + 65 + # ── build the binary ───────────────────────────────────────────────── 66 + echo "=== Building git-remote-pds ===" 67 + cargo build -p pds-git-remote --quiet 68 + BINARY="$(cargo metadata --format-version 1 --no-deps \ 69 + | python3 -c 'import sys,json; print(json.load(sys.stdin)["target_directory"])')/debug/git-remote-pds" 70 + 71 + if [ ! -x "${BINARY}" ]; then 72 + echo "ERROR: binary not found at ${BINARY}" 73 + exit 1 74 + fi 75 + echo "Binary: ${BINARY}" 76 + echo "" 77 + 78 + # put the binary on PATH so git can find it as a remote helper 79 + export PATH="$(dirname "${BINARY}"):${PATH}" 80 + 81 + # ── export DPoP env vars for the remote helper ─────────────────────── 82 + export PDS_URL PDS_ACCESS_TOKEN PDS_DID PDS_DPOP_KEY 83 + 84 + # ── verify PDS is reachable ────────────────────────────────────────── 85 + echo "=== Checking PDS health ===" 86 + HEALTH=$(curl -sf "${PDS_URL}/xrpc/_health" 2>&1) || { 87 + echo "ERROR: PDS not reachable at ${PDS_URL}/xrpc/_health" 88 + echo "Response: ${HEALTH}" 89 + exit 1 90 + } 91 + echo "PDS health: ${HEALTH}" 92 + echo "" 93 + 94 + # ── test push (DPoP auth) ─────────────────────────────────────────── 95 + echo "=== Test: initial push (DPoP auth) ===" 96 + mkdir -p "${PUSH_REPO}" 97 + cd "${PUSH_REPO}" 98 + git init --quiet 99 + git checkout -b main 100 + 101 + echo "hello from DPoP test" > file.txt 102 + mkdir -p subdir 103 + echo "nested dpop content" > subdir/nested.txt 104 + git add . 105 + git commit --quiet -m "initial commit (DPoP)" 106 + 107 + REMOTE_URL="pds://${PDS_HANDLE}/${REPO_NAME}" 108 + git remote add pds "${REMOTE_URL}" 109 + git push pds main 110 + 111 + echo "Push OK (DPoP auth verified!)" 112 + echo "" 113 + 114 + # ── test clone ─────────────────────────────────────────────────────── 115 + echo "=== Test: clone ===" 116 + cd "${TMPDIR_BASE}" 117 + git clone "${REMOTE_URL}" clone-repo 118 + 119 + # verify files match 120 + diff "${PUSH_REPO}/file.txt" "${CLONE_REPO}/file.txt" 121 + diff "${PUSH_REPO}/subdir/nested.txt" "${CLONE_REPO}/subdir/nested.txt" 122 + echo "Clone OK — files match" 123 + echo "" 124 + 125 + # ── test incremental push + fetch ──────────────────────────────────── 126 + echo "=== Test: incremental push (DPoP auth) ===" 127 + cd "${PUSH_REPO}" 128 + echo "second commit dpop content" > file2.txt 129 + git add . 130 + git commit --quiet -m "second commit (DPoP)" 131 + git push pds main 132 + 133 + echo "Incremental push OK" 134 + echo "" 135 + 136 + echo "=== Test: fetch into clone ===" 137 + cd "${CLONE_REPO}" 138 + git fetch origin 139 + git merge --ff-only origin/main 140 + 141 + diff "${PUSH_REPO}/file2.txt" "${CLONE_REPO}/file2.txt" 142 + echo "Fetch OK — incremental content matches" 143 + echo "" 144 + 145 + # ── done ───────────────────────────────────────────────────────────── 146 + echo "=== All DPoP remote tests passed ==="
+34 -1
src/auth.rs
··· 9 9 10 10 use serde::{Deserialize, Serialize}; 11 11 12 + use atproto_identity::key::KeyData; 13 + 12 14 use crate::pds_client::PdsClient; 13 15 14 16 /// Stored credential for a single AT Protocol account. ··· 19 21 pub did: String, 20 22 pub access_jwt: String, 21 23 pub refresh_jwt: String, 24 + /// Serialized DPoP private key JWK (present for OAuth-based logins). 25 + #[serde(default, skip_serializing_if = "Option::is_none")] 26 + pub dpop_key: Option<String>, 27 + /// Serialized signing key JWK (for OAuth client assertions). 28 + #[serde(default, skip_serializing_if = "Option::is_none")] 29 + pub signing_key: Option<String>, 30 + /// Unix timestamp when the access token expires. 31 + #[serde(default, skip_serializing_if = "Option::is_none")] 32 + pub token_expiry: Option<i64>, 22 33 } 23 34 24 35 /// Top-level auth config file. ··· 29 40 } 30 41 31 42 /// Resolved auth info needed for push operations. 32 - #[derive(Debug, Clone)] 33 43 pub struct ResolvedAuth { 34 44 pub pds_url: String, 35 45 pub did: String, 36 46 pub access_jwt: String, 47 + /// If set, the access token is DPoP-bound and requires proof headers. 48 + pub dpop_key: Option<KeyData>, 37 49 } 38 50 39 51 /// Returns the path to the auth config file. ··· 94 106 did: session.did.clone(), 95 107 access_jwt: session.access_jwt, 96 108 refresh_jwt: session.refresh_jwt, 109 + dpop_key: None, 110 + signing_key: None, 111 + token_expiry: None, 97 112 }; 98 113 99 114 // save to config ··· 114 129 pub async fn resolve_auth(handle: &str, pds_url: &str) -> Result<ResolvedAuth, String> { 115 130 // 1. direct token from env 116 131 if let (Ok(token), Ok(did)) = (std::env::var("PDS_ACCESS_TOKEN"), std::env::var("PDS_DID")) { 132 + let dpop_key = std::env::var("PDS_DPOP_KEY") 133 + .ok() 134 + .map(|k| crate::dpop::parse_dpop_key(&k)) 135 + .transpose()?; 136 + 117 137 return Ok(ResolvedAuth { 118 138 pds_url: pds_url.to_string(), 119 139 did, 120 140 access_jwt: token, 141 + dpop_key, 121 142 }); 122 143 } 123 144 ··· 130 151 pds_url: cred.pds_url, 131 152 did: cred.did, 132 153 access_jwt: cred.access_jwt, 154 + dpop_key: None, 133 155 }); 134 156 } 135 157 136 158 // 3. stored credential 137 159 if let Some(cred) = get_credential(handle)? { 160 + // if the credential has a DPoP key, use DPoP auth 161 + let dpop_key = cred 162 + .dpop_key 163 + .as_deref() 164 + .map(|k| crate::dpop::parse_dpop_key(k)) 165 + .transpose()?; 166 + 138 167 return Ok(ResolvedAuth { 139 168 pds_url: cred.pds_url, 140 169 did: cred.did, 141 170 access_jwt: cred.access_jwt, 171 + dpop_key, 142 172 }); 143 173 } 144 174 ··· 174 204 did: "did:plc:abc123".to_string(), 175 205 access_jwt: "jwt-access".to_string(), 176 206 refresh_jwt: "jwt-refresh".to_string(), 207 + dpop_key: None, 208 + signing_key: None, 209 + token_expiry: None, 177 210 }, 178 211 ); 179 212
+205
src/dpop.rs
··· 1 + //! DPoP (Demonstration of Proof-of-Possession) proof generation. 2 + //! 3 + //! Thin wrapper around `atproto_oauth` for generating RFC 9449 compliant DPoP 4 + //! proof JWTs. Used when the access token is DPoP-bound (obtained via OAuth, 5 + //! not createSession). 6 + 7 + use atproto_identity::key::KeyData; 8 + use atproto_oauth::dpop::request_dpop; 9 + use atproto_oauth::jwk::{WrappedJsonWebKey, to_key_data}; 10 + use atproto_oauth::jwt::{Claims, Header, JoseClaims, mint}; 11 + use atproto_oauth::pkce::challenge; 12 + use ulid::Ulid; 13 + 14 + /// Parses a serialized `WrappedJsonWebKey` JSON string into key data for signing. 15 + /// 16 + /// The input JSON has the shape produced by `atproto_oauth::jwk::generate`: 17 + /// `{"kid":"...","alg":"ES256","use":"sig","kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."}` 18 + pub fn parse_dpop_key(jwk_json: &str) -> Result<KeyData, String> { 19 + let wrapped: WrappedJsonWebKey = serde_json::from_str(jwk_json) 20 + .map_err(|e| format!("failed to parse DPoP key JSON: {}", e))?; 21 + 22 + to_key_data(&wrapped).map_err(|e| format!("failed to convert JWK to key data: {}", e)) 23 + } 24 + 25 + /// Generates a DPoP proof JWT for an authenticated XRPC request. 26 + /// 27 + /// Returns the signed compact JWT string (`header.claims.signature`). 28 + /// If `nonce` is provided, it is included in the claims (required after 29 + /// a PDS nonce challenge). 30 + pub fn make_dpop_proof( 31 + key_data: &KeyData, 32 + http_method: &str, 33 + http_uri: &str, 34 + access_token: &str, 35 + nonce: Option<&str>, 36 + ) -> Result<String, String> { 37 + match nonce { 38 + None => { 39 + // Use atproto-oauth's request_dpop directly 40 + let (token, _header, _claims) = 41 + request_dpop(key_data, http_method, http_uri, access_token) 42 + .map_err(|e| format!("failed to generate DPoP proof: {}", e))?; 43 + Ok(token) 44 + } 45 + Some(nonce_value) => { 46 + // Build proof manually with nonce included 47 + let public_key_data = atproto_identity::key::to_public(key_data) 48 + .map_err(|e| format!("failed to derive public key: {}", e))?; 49 + let dpop_jwk: elliptic_curve::JwkEcKey = 50 + TryInto::<elliptic_curve::JwkEcKey>::try_into(&public_key_data) 51 + .map_err(|e| format!("failed to convert to JWK: {}", e))?; 52 + 53 + let header = Header { 54 + type_: Some("dpop+jwt".to_string()), 55 + algorithm: Some("ES256".to_string()), 56 + json_web_key: Some(dpop_jwk), 57 + key_id: None, 58 + }; 59 + 60 + let now = std::time::SystemTime::now() 61 + .duration_since(std::time::UNIX_EPOCH) 62 + .map_err(|e| format!("system time error: {}", e))? 63 + .as_secs(); 64 + 65 + let claims = Claims::new(JoseClaims { 66 + auth: Some(challenge(access_token)), 67 + expiration: Some(now + 30), 68 + http_method: Some(http_method.to_string()), 69 + http_uri: Some(http_uri.to_string()), 70 + issued_at: Some(now), 71 + json_web_token_id: Some(Ulid::new().to_string()), 72 + nonce: Some(nonce_value.to_string()), 73 + ..Default::default() 74 + }); 75 + 76 + mint(key_data, &header, &claims) 77 + .map_err(|e| format!("failed to mint DPoP proof with nonce: {}", e)) 78 + } 79 + } 80 + } 81 + 82 + #[cfg(test)] 83 + mod tests { 84 + use super::*; 85 + use atproto_identity::key::{KeyData, KeyType}; 86 + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 87 + 88 + /// Generate a random P-256 key and serialize it as a WrappedJsonWebKey JSON string. 89 + fn make_test_key() -> (KeyData, String) { 90 + // Generate a random P-256 secret key 91 + let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); 92 + let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec()); 93 + 94 + // Convert to WrappedJsonWebKey JSON 95 + let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap(); 96 + let json = serde_json::to_string(&wrapped).unwrap(); 97 + 98 + (key_data, json) 99 + } 100 + 101 + #[test] 102 + fn parse_dpop_key_round_trip() { 103 + let (original_key_data, json) = make_test_key(); 104 + let parsed = parse_dpop_key(&json).unwrap(); 105 + assert_eq!(parsed.key_type(), original_key_data.key_type()); 106 + } 107 + 108 + #[test] 109 + fn parse_dpop_key_invalid_json() { 110 + assert!(parse_dpop_key("not json").is_err()); 111 + } 112 + 113 + #[test] 114 + fn parse_dpop_key_missing_private_key() { 115 + // Public-only JWK should fail or produce a public key type 116 + let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); 117 + let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec()); 118 + let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap(); 119 + 120 + // Serialize and strip the "d" parameter to make it public-only 121 + let mut value: serde_json::Value = serde_json::to_value(&wrapped).unwrap(); 122 + value.as_object_mut().unwrap().remove("d"); 123 + let json = serde_json::to_string(&value).unwrap(); 124 + 125 + // Should parse but produce a public key (not useful for signing) 126 + let result = parse_dpop_key(&json); 127 + match result { 128 + Ok(kd) => assert_eq!(*kd.key_type(), KeyType::P256Public), 129 + Err(_) => {} // also acceptable 130 + } 131 + } 132 + 133 + #[test] 134 + fn make_dpop_proof_produces_valid_jwt() { 135 + let (key_data, _json) = make_test_key(); 136 + let proof = make_dpop_proof( 137 + &key_data, 138 + "POST", 139 + "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob", 140 + "test-access-token", 141 + None, 142 + ) 143 + .unwrap(); 144 + 145 + // JWT has 3 parts 146 + let parts: Vec<&str> = proof.split('.').collect(); 147 + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); 148 + 149 + // Decode and verify header 150 + let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); 151 + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); 152 + assert_eq!(header["typ"], "dpop+jwt"); 153 + assert_eq!(header["alg"], "ES256"); 154 + assert!(header["jwk"].is_object()); 155 + 156 + // Decode and verify claims 157 + let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 158 + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap(); 159 + assert_eq!(claims["htm"], "POST"); 160 + assert_eq!( 161 + claims["htu"], 162 + "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob" 163 + ); 164 + assert!(claims["jti"].is_string()); 165 + assert!(claims["iat"].is_number()); 166 + assert!(claims["exp"].is_number()); 167 + assert!(claims["ath"].is_string()); 168 + } 169 + 170 + #[test] 171 + fn make_dpop_proof_with_nonce() { 172 + let (key_data, _json) = make_test_key(); 173 + let proof = make_dpop_proof( 174 + &key_data, 175 + "GET", 176 + "https://pds.example.com/xrpc/com.atproto.repo.getRecord", 177 + "token", 178 + Some("server-nonce-123"), 179 + ) 180 + .unwrap(); 181 + 182 + let parts: Vec<&str> = proof.split('.').collect(); 183 + let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 184 + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap(); 185 + assert_eq!(claims["nonce"], "server-nonce-123"); 186 + } 187 + 188 + #[test] 189 + fn make_dpop_proof_signature_verifies() { 190 + let (key_data, _json) = make_test_key(); 191 + let proof = make_dpop_proof( 192 + &key_data, 193 + "POST", 194 + "https://pds.example.com/xrpc/test", 195 + "token123", 196 + None, 197 + ) 198 + .unwrap(); 199 + 200 + // Verify using atproto-oauth's jwt::verify 201 + let public_key = atproto_identity::key::to_public(&key_data).unwrap(); 202 + let claims = atproto_oauth::jwt::verify(&proof, &public_key).unwrap(); 203 + assert_eq!(claims.jose.http_method.as_deref(), Some("POST")); 204 + } 205 + }
+3
src/lib.rs
··· 7 7 pub mod auth; 8 8 pub mod bundle; 9 9 pub mod chunk; 10 + pub mod dpop; 10 11 pub mod fetch; 11 12 pub mod identity; 13 + pub mod oauth_login; 14 + pub mod oauth_server; 12 15 pub mod pds_client; 13 16 pub mod push; 14 17 pub mod types;
+28 -1
src/main.rs
··· 30 30 31 31 #[derive(Subcommand)] 32 32 enum AuthAction { 33 - /// Log in to a PDS and store credentials 33 + /// Log in to a PDS using handle + password (createSession) 34 34 Login { 35 35 /// PDS server URL 36 36 #[arg(long)] ··· 38 38 /// AT Protocol handle 39 39 #[arg(long)] 40 40 handle: String, 41 + }, 42 + /// Log in via browser-based AT Protocol OAuth (DPoP-bound tokens) 43 + OauthLogin { 44 + /// AT Protocol handle 45 + #[arg(long)] 46 + handle: String, 47 + /// PDS server URL (optional, auto-discovered from handle) 48 + #[arg(long)] 49 + pds_url: Option<String>, 50 + /// Port for the localhost OAuth callback server 51 + #[arg(long, default_value = "8271")] 52 + port: u16, 41 53 }, 42 54 /// Show stored credentials 43 55 Status, ··· 80 92 AuthAction::Login { pds_url, handle } => { 81 93 handle_login(&pds_url, &handle).await; 82 94 } 95 + AuthAction::OauthLogin { 96 + handle, 97 + pds_url, 98 + port, 99 + } => { 100 + handle_oauth_login(&handle, pds_url.as_deref(), port).await; 101 + } 83 102 AuthAction::Status => { 84 103 handle_status(); 85 104 } ··· 87 106 handle_logout(&handle); 88 107 } 89 108 }, 109 + } 110 + } 111 + 112 + /// Handles `auth oauth-login` — browser-based OAuth login with DPoP. 113 + async fn handle_oauth_login(handle: &str, pds_url: Option<&str>, port: u16) { 114 + if let Err(e) = pds_git_remote::oauth_login::oauth_login(handle, pds_url, port).await { 115 + eprintln!("OAuth login failed: {}", e); 116 + std::process::exit(1); 90 117 } 91 118 } 92 119
+254
src/oauth_login.rs
··· 1 + //! Browser-based AT Protocol OAuth login flow. 2 + //! 3 + //! Orchestrates the full OAuth 2.0 + DPoP login: 4 + //! 1. Resolve handle → DID → PDS 5 + //! 2. Discover authorization server 6 + //! 3. Generate keys (signing, DPoP, PKCE) 7 + //! 4. Start localhost callback server 8 + //! 5. Make PAR request 9 + //! 6. Open browser to authorization URL 10 + //! 7. Wait for callback with authorization code 11 + //! 8. Exchange code for DPoP-bound tokens 12 + //! 9. Store credential with DPoP key 13 + 14 + use crate::auth; 15 + use crate::identity; 16 + use crate::oauth_server; 17 + 18 + use atproto_identity::key::{KeyType, generate_key}; 19 + use atproto_oauth::jwk; 20 + use atproto_oauth::pkce; 21 + use atproto_oauth::resources; 22 + use atproto_oauth::workflow::{self, OAuthClient, OAuthRequest, OAuthRequestState}; 23 + 24 + /// Runs the full OAuth login flow for a handle. 25 + /// 26 + /// Starts a localhost server, opens the browser, waits for the user to 27 + /// authorize, then stores the resulting DPoP-bound credential. 28 + pub async fn oauth_login(handle: &str, pds_url: Option<&str>, port: u16) -> Result<(), String> { 29 + // 1. resolve identity 30 + eprintln!("Resolving identity for {}...", handle); 31 + let (pds_url, did) = resolve_identity(handle, pds_url).await?; 32 + eprintln!("Resolved: {} @ {}", did, pds_url); 33 + 34 + // 2. discover authorization server 35 + eprintln!("Discovering authorization server..."); 36 + let http = reqwest::Client::new(); 37 + let (_resource, auth_server) = resources::pds_resources(&http, &pds_url) 38 + .await 39 + .map_err(|e| format!("failed to discover authorization server: {}", e))?; 40 + 41 + // 3. generate DPoP key (no signing key needed for public/loopback client) 42 + let dpop_key = generate_key(KeyType::P256Private) 43 + .map_err(|e| format!("failed to generate DPoP key: {}", e))?; 44 + 45 + // 4. build loopback client config 46 + // AT Protocol uses http://localhost as a special loopback client_id. 47 + // The PDS generates virtual client metadata for it — no need to serve our own. 48 + // Scopes are specified via query parameter on the client_id URL. 49 + // Redirect URI must be http://127.0.0.1/ (root path, any port is accepted). 50 + let scope = "atproto transition:generic"; 51 + let client_id = format!( 52 + "http://localhost?scope={}&redirect_uri={}", 53 + percent_encode(scope), 54 + percent_encode(&format!("http://127.0.0.1:{}/", port)), 55 + ); 56 + let redirect_uri = format!("http://127.0.0.1:{}/", port); 57 + 58 + let oauth_client = OAuthClient { 59 + redirect_uri: redirect_uri.clone(), 60 + client_id: client_id.clone(), 61 + // public client — no signing key, no client_assertion 62 + private_signing_key_data: None, 63 + }; 64 + 65 + // 5. generate PKCE challenge 66 + let (code_verifier, code_challenge) = pkce::generate(); 67 + 68 + let state = generate_random_state(); 69 + let nonce = generate_random_state(); 70 + let oauth_request_state = OAuthRequestState { 71 + state: state.clone(), 72 + nonce: nonce.clone(), 73 + code_challenge, 74 + scope: scope.to_string(), 75 + }; 76 + 77 + // 6. make PAR request 78 + eprintln!("Starting OAuth flow..."); 79 + let par_response = workflow::oauth_init( 80 + &http, 81 + &oauth_client, 82 + &dpop_key, 83 + Some(handle), 84 + &auth_server, 85 + &oauth_request_state, 86 + ) 87 + .await 88 + .map_err(|e| format!("OAuth PAR request failed: {}", e))?; 89 + 90 + // build the authorization URL 91 + let auth_url = format!( 92 + "{}?client_id={}&request_uri={}", 93 + auth_server.authorization_endpoint, 94 + percent_encode(&client_id), 95 + percent_encode(&par_response.request_uri), 96 + ); 97 + 98 + // serialize DPoP key for storage in the OAuthRequest 99 + let dpop_jwk = 100 + jwk::generate(&dpop_key).map_err(|e| format!("failed to generate DPoP JWK: {}", e))?; 101 + let dpop_private_key_json = serde_json::to_string(&dpop_jwk) 102 + .map_err(|e| format!("failed to serialize DPoP key: {}", e))?; 103 + 104 + let now = chrono::Utc::now(); 105 + let oauth_request = OAuthRequest { 106 + oauth_state: state.clone(), 107 + issuer: auth_server.issuer.clone(), 108 + authorization_server: auth_server.issuer.clone(), 109 + nonce, 110 + pkce_verifier: code_verifier, 111 + // no signing key for public client 112 + signing_public_key: String::new(), 113 + dpop_private_key: dpop_private_key_json.clone(), 114 + created_at: now, 115 + expires_at: now + chrono::Duration::seconds(par_response.expires_in as i64), 116 + }; 117 + 118 + // 7. print URL and try to open browser 119 + eprintln!("\nOpen this URL in your browser to authorize:\n"); 120 + eprintln!(" {}\n", auth_url); 121 + let _ = open_browser(&auth_url); 122 + 123 + // 8. start callback server and wait for the redirect 124 + let callback = oauth_server::run_oauth_server(port).await?; 125 + 126 + // verify state matches 127 + if callback.state != state { 128 + return Err("OAuth state mismatch — possible CSRF attack".to_string()); 129 + } 130 + 131 + // 9. exchange code for tokens 132 + eprintln!("Exchanging authorization code for tokens..."); 133 + let token_response = workflow::oauth_complete( 134 + &http, 135 + &oauth_client, 136 + &dpop_key, 137 + &callback.code, 138 + &oauth_request, 139 + &auth_server, 140 + ) 141 + .await 142 + .map_err(|e| format!("OAuth token exchange failed: {}", e))?; 143 + 144 + let token_did = token_response 145 + .sub 146 + .ok_or_else(|| "no sub (DID) in token response".to_string())?; 147 + 148 + // compute token expiry 149 + let token_expiry = std::time::SystemTime::now() 150 + .duration_since(std::time::UNIX_EPOCH) 151 + .map(|d| d.as_secs() as i64 + token_response.expires_in as i64) 152 + .ok(); 153 + 154 + // 10. store credential 155 + let cred = auth::StoredCredential { 156 + pds_url: pds_url.clone(), 157 + handle: handle.to_string(), 158 + did: token_did.clone(), 159 + access_jwt: token_response.access_token, 160 + refresh_jwt: token_response.refresh_token.unwrap_or_default(), 161 + dpop_key: Some(dpop_private_key_json), 162 + signing_key: None, 163 + token_expiry, 164 + }; 165 + 166 + let mut config = auth::load_config()?; 167 + config.credentials.insert(handle.to_string(), cred); 168 + auth::save_config(&config)?; 169 + 170 + eprintln!("Logged in as {} ({}) via OAuth", handle, token_did); 171 + eprintln!("Credentials stored with DPoP key for authenticated pushes."); 172 + 173 + Ok(()) 174 + } 175 + 176 + /// Resolves a handle to its PDS URL and DID. 177 + async fn resolve_identity(handle: &str, pds_url: Option<&str>) -> Result<(String, String), String> { 178 + if let Some(url) = pds_url { 179 + let did = identity::resolve_handle(handle, Some(url)).await?; 180 + Ok((url.to_string(), did)) 181 + } else { 182 + let resolved = identity::resolve_identity(handle, None, None).await?; 183 + Ok((resolved.pds_url, resolved.did)) 184 + } 185 + } 186 + 187 + /// Tries to open a URL in the default browser. 188 + fn open_browser(url: &str) -> Result<(), String> { 189 + #[cfg(target_os = "macos")] 190 + let cmd = std::process::Command::new("open").arg(url).spawn(); 191 + 192 + #[cfg(target_os = "linux")] 193 + let cmd = std::process::Command::new("xdg-open").arg(url).spawn(); 194 + 195 + #[cfg(target_os = "windows")] 196 + let cmd = std::process::Command::new("cmd") 197 + .args(["/c", "start", url]) 198 + .spawn(); 199 + 200 + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] 201 + let cmd: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new( 202 + std::io::ErrorKind::Unsupported, 203 + "unsupported platform", 204 + )); 205 + 206 + match cmd { 207 + Ok(_) => Ok(()), 208 + Err(e) => { 209 + eprintln!("Could not open browser automatically: {}", e); 210 + eprintln!("Please open the URL above manually."); 211 + Ok(()) 212 + } 213 + } 214 + } 215 + 216 + /// Simple percent-encoding for URL parameters. 217 + fn percent_encode(s: &str) -> String { 218 + let mut result = String::with_capacity(s.len() * 3); 219 + for byte in s.bytes() { 220 + match byte { 221 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { 222 + result.push(byte as char); 223 + } 224 + _ => { 225 + result.push('%'); 226 + result.push_str(&format!("{:02X}", byte)); 227 + } 228 + } 229 + } 230 + result 231 + } 232 + 233 + /// Generates a random state string. 234 + fn generate_random_state() -> String { 235 + use std::collections::hash_map::DefaultHasher; 236 + use std::hash::{Hash, Hasher}; 237 + use std::time::SystemTime; 238 + 239 + let now = SystemTime::now() 240 + .duration_since(SystemTime::UNIX_EPOCH) 241 + .unwrap_or_default(); 242 + 243 + let mut hasher = DefaultHasher::new(); 244 + now.as_nanos().hash(&mut hasher); 245 + std::thread::current().id().hash(&mut hasher); 246 + let stack_var = 0u8; 247 + (&stack_var as *const u8 as usize).hash(&mut hasher); 248 + let h1 = hasher.finish(); 249 + 250 + h1.hash(&mut hasher); 251 + let h2 = hasher.finish(); 252 + 253 + format!("{:016x}{:016x}", h1, h2) 254 + }
+92
src/oauth_server.rs
··· 1 + //! Temporary localhost HTTP server for OAuth callback. 2 + //! 3 + //! Listens on a loopback port for the OAuth redirect from the PDS. 4 + //! Shuts down as soon as the callback is received. 5 + 6 + use std::sync::Arc; 7 + 8 + use axum::response::IntoResponse; 9 + use tokio::sync::oneshot; 10 + 11 + /// Data received from the OAuth callback redirect. 12 + pub struct CallbackResult { 13 + pub code: String, 14 + pub state: String, 15 + pub iss: Option<String>, 16 + } 17 + 18 + /// Shared state for the OAuth callback server. 19 + struct ServerState { 20 + callback_tx: std::sync::Mutex<Option<oneshot::Sender<CallbackResult>>>, 21 + } 22 + 23 + /// Starts a temporary HTTP server for the OAuth login flow. 24 + /// 25 + /// Listens on `127.0.0.1:{port}` for the OAuth redirect. The PDS redirects 26 + /// to `http://127.0.0.1:{port}/?code=...&state=...` after authorization. 27 + /// 28 + /// Returns the authorization code once the callback is received. 29 + pub async fn run_oauth_server(port: u16) -> Result<CallbackResult, String> { 30 + let (tx, rx) = oneshot::channel::<CallbackResult>(); 31 + 32 + let state = Arc::new(ServerState { 33 + callback_tx: std::sync::Mutex::new(Some(tx)), 34 + }); 35 + 36 + // the PDS redirects to the root path with query params 37 + let app = axum::Router::new() 38 + .route("/", axum::routing::get(callback_handler)) 39 + .with_state(state); 40 + 41 + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); 42 + let listener = tokio::net::TcpListener::bind(addr) 43 + .await 44 + .map_err(|e| format!("failed to bind to port {}: {}", port, e))?; 45 + 46 + eprintln!( 47 + "OAuth callback server listening on http://127.0.0.1:{}", 48 + port 49 + ); 50 + 51 + // run the server until the callback is received 52 + let server = axum::serve(listener, app); 53 + tokio::select! { 54 + result = rx => { 55 + result.map_err(|_| "callback channel closed without result".to_string()) 56 + } 57 + err = server.into_future() => { 58 + Err(format!("server exited unexpectedly: {:?}", err)) 59 + } 60 + } 61 + } 62 + 63 + /// Query parameters on the OAuth callback. 64 + #[derive(serde::Deserialize)] 65 + struct CallbackParams { 66 + code: String, 67 + state: String, 68 + iss: Option<String>, 69 + } 70 + 71 + /// GET /?code=...&state=...&iss=... 72 + async fn callback_handler( 73 + axum::extract::State(state): axum::extract::State<Arc<ServerState>>, 74 + axum::extract::Query(params): axum::extract::Query<CallbackParams>, 75 + ) -> axum::response::Response { 76 + let result = CallbackResult { 77 + code: params.code, 78 + state: params.state, 79 + iss: params.iss, 80 + }; 81 + 82 + // send the result through the channel 83 + if let Some(tx) = state.callback_tx.lock().unwrap().take() { 84 + let _ = tx.send(result); 85 + } 86 + 87 + ( 88 + [(axum::http::header::CONTENT_TYPE, "text/html")], 89 + "<html><body><h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>", 90 + ) 91 + .into_response() 92 + }
+159 -36
src/pds_client.rs
··· 3 3 //! Wraps the AT Protocol XRPC endpoints needed for git backup: 4 4 //! record CRUD, blob upload/download, and session creation. 5 5 6 + use std::cell::RefCell; 7 + 8 + use atproto_identity::key::KeyData; 9 + 10 + use crate::dpop; 6 11 use crate::types::{BlobRef, CidLink}; 7 12 use serde::{Deserialize, Serialize}; 8 13 14 + /// Authentication mode for PDS requests. 15 + enum AuthMode { 16 + /// No authentication (unauthenticated reads). 17 + None, 18 + /// Classic Bearer token (from createSession). 19 + Bearer(String), 20 + /// DPoP-bound OAuth token (requires proof on every request). 21 + DPoP { 22 + access_token: String, 23 + key_data: KeyData, 24 + /// Cached nonce from the PDS (updated after each nonce challenge). 25 + last_nonce: RefCell<Option<String>>, 26 + }, 27 + } 28 + 9 29 /// Client for interacting with a PDS server over XRPC. 10 30 /// 11 - /// Supports both authenticated (push) and unauthenticated (clone/fetch) 12 - /// operations. Use `PdsClient::new` for unauthenticated access and 13 - /// `PdsClient::with_auth` when a bearer token is available. 14 - #[derive(Debug, Clone)] 31 + /// Supports three auth modes: 32 + /// - Unauthenticated (clone/fetch) 33 + /// - Bearer token (createSession-based push) 34 + /// - DPoP (OAuth-based push with proof-of-possession) 15 35 pub struct PdsClient { 16 36 /// base URL of the PDS, e.g. "https://bsky.social" 17 37 base_url: String, 18 - /// bearer token for authenticated requests (access JWT) 19 - auth_token: Option<String>, 38 + auth: AuthMode, 20 39 http: reqwest::Client, 21 40 } 22 41 ··· 95 114 pub fn new(base_url: impl Into<String>) -> Self { 96 115 Self { 97 116 base_url: base_url.into().trim_end_matches('/').to_string(), 98 - auth_token: None, 117 + auth: AuthMode::None, 99 118 http: reqwest::Client::new(), 100 119 } 101 120 } ··· 104 123 pub fn with_auth(base_url: impl Into<String>, token: impl Into<String>) -> Self { 105 124 Self { 106 125 base_url: base_url.into().trim_end_matches('/').to_string(), 107 - auth_token: Some(token.into()), 126 + auth: AuthMode::Bearer(token.into()), 108 127 http: reqwest::Client::new(), 109 128 } 110 129 } 111 130 112 - /// Sets or replaces the auth token. 131 + /// Creates an authenticated client with a DPoP-bound OAuth token. 132 + pub fn with_dpop_auth( 133 + base_url: impl Into<String>, 134 + access_token: impl Into<String>, 135 + key_data: KeyData, 136 + ) -> Self { 137 + Self { 138 + base_url: base_url.into().trim_end_matches('/').to_string(), 139 + auth: AuthMode::DPoP { 140 + access_token: access_token.into(), 141 + key_data, 142 + last_nonce: RefCell::new(None), 143 + }, 144 + http: reqwest::Client::new(), 145 + } 146 + } 147 + 148 + /// Sets or replaces the auth token (Bearer mode). 113 149 pub fn set_auth(&mut self, token: impl Into<String>) { 114 - self.auth_token = Some(token.into()); 150 + self.auth = AuthMode::Bearer(token.into()); 115 151 } 116 152 117 153 /// Returns the base URL of the PDS. ··· 152 188 .await 153 189 .map_err(|e| format!("failed to parse createSession response: {}", e))?; 154 190 155 - self.auth_token = Some(session.access_jwt.clone()); 191 + self.auth = AuthMode::Bearer(session.access_jwt.clone()); 156 192 Ok(session) 157 193 } 158 194 ··· 211 247 record: serde_json::Value, 212 248 swap_record: Option<String>, 213 249 ) -> Result<PutRecordResponse, String> { 214 - let token = self 215 - .auth_token 216 - .as_ref() 217 - .ok_or("putRecord requires authentication")?; 218 - 219 250 let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url); 220 251 221 252 let body = PutRecordRequest { ··· 226 257 swap_record, 227 258 }; 228 259 260 + let json_body = serde_json::to_vec(&body) 261 + .map_err(|e| format!("failed to serialize putRecord body: {}", e))?; 262 + 229 263 let resp = self 230 - .http 231 - .post(&url) 232 - .bearer_auth(token) 233 - .json(&body) 234 - .send() 235 - .await 236 - .map_err(|e| format!("putRecord request failed: {}", e))?; 264 + .send_authenticated("POST", &url, json_body.clone(), Some("application/json")) 265 + .await?; 237 266 238 267 if !resp.status().is_success() { 239 268 let err = parse_xrpc_error(resp).await; ··· 250 279 /// Calls `com.atproto.repo.uploadBlob`. Requires authentication. 251 280 /// Returns a `BlobRef` that can be embedded in a record. 252 281 pub async fn upload_blob(&self, data: Vec<u8>) -> Result<BlobRef, String> { 253 - let token = self 254 - .auth_token 255 - .as_ref() 256 - .ok_or("uploadBlob requires authentication")?; 257 - 258 282 let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url); 259 283 260 284 let resp = self 261 - .http 262 - .post(&url) 263 - .bearer_auth(token) 264 - .header("Content-Type", "application/octet-stream") 265 - .body(data) 266 - .send() 267 - .await 268 - .map_err(|e| format!("uploadBlob request failed: {}", e))?; 285 + .send_authenticated("POST", &url, data.clone(), Some("application/octet-stream")) 286 + .await?; 269 287 270 288 if !resp.status().is_success() { 271 289 let err = parse_xrpc_error(resp).await; ··· 311 329 .await 312 330 .map(|b| b.to_vec()) 313 331 .map_err(|e| format!("failed to read getBlob response body: {}", e)) 332 + } 333 + 334 + /// Sends an authenticated request, handling both Bearer and DPoP auth modes. 335 + /// 336 + /// For DPoP auth, generates proof headers and automatically retries once 337 + /// if the PDS responds with a nonce challenge (400/401 with DPoP-Nonce header). 338 + async fn send_authenticated( 339 + &self, 340 + method: &str, 341 + url: &str, 342 + body: Vec<u8>, 343 + content_type: Option<&str>, 344 + ) -> Result<reqwest::Response, String> { 345 + match &self.auth { 346 + AuthMode::None => Err("authenticated request requires auth".to_string()), 347 + AuthMode::Bearer(token) => { 348 + let mut req = self.http.post(url).bearer_auth(token); 349 + if let Some(ct) = content_type { 350 + req = req.header("Content-Type", ct); 351 + } 352 + req.body(body) 353 + .send() 354 + .await 355 + .map_err(|e| format!("request failed: {}", e)) 356 + } 357 + AuthMode::DPoP { 358 + access_token, 359 + key_data, 360 + last_nonce, 361 + } => { 362 + let nonce = last_nonce.borrow().clone(); 363 + let proof = 364 + dpop::make_dpop_proof(key_data, method, url, access_token, nonce.as_deref())?; 365 + 366 + let mut req = self 367 + .http 368 + .post(url) 369 + .header("Authorization", format!("DPoP {}", access_token)) 370 + .header("DPoP", &proof); 371 + if let Some(ct) = content_type { 372 + req = req.header("Content-Type", ct); 373 + } 374 + 375 + let resp = req 376 + .body(body.clone()) 377 + .send() 378 + .await 379 + .map_err(|e| format!("request failed: {}", e))?; 380 + 381 + // Check for nonce challenge: 400/401 with DPoP-Nonce header 382 + let status = resp.status(); 383 + if (status == reqwest::StatusCode::BAD_REQUEST 384 + || status == reqwest::StatusCode::UNAUTHORIZED) 385 + && resp.headers().contains_key("dpop-nonce") 386 + { 387 + let new_nonce = resp 388 + .headers() 389 + .get("dpop-nonce") 390 + .and_then(|v| v.to_str().ok()) 391 + .map(|s| s.to_string()); 392 + 393 + if let Some(ref nonce_val) = new_nonce { 394 + tracing::debug!("DPoP nonce challenge received, retrying with nonce"); 395 + 396 + // Cache the nonce for subsequent requests 397 + *last_nonce.borrow_mut() = new_nonce.clone(); 398 + 399 + // Regenerate proof with nonce and retry 400 + let retry_proof = dpop::make_dpop_proof( 401 + key_data, 402 + method, 403 + url, 404 + access_token, 405 + Some(nonce_val), 406 + )?; 407 + 408 + let mut retry_req = self 409 + .http 410 + .post(url) 411 + .header("Authorization", format!("DPoP {}", access_token)) 412 + .header("DPoP", &retry_proof); 413 + if let Some(ct) = content_type { 414 + retry_req = retry_req.header("Content-Type", ct); 415 + } 416 + 417 + return retry_req 418 + .body(body) 419 + .send() 420 + .await 421 + .map_err(|e| format!("request failed (retry): {}", e)); 422 + } 423 + } 424 + 425 + // Cache any nonce from successful responses too 426 + if let Some(nonce_val) = resp 427 + .headers() 428 + .get("dpop-nonce") 429 + .and_then(|v| v.to_str().ok()) 430 + { 431 + *last_nonce.borrow_mut() = Some(nonce_val.to_string()); 432 + } 433 + 434 + Ok(resp) 435 + } 436 + } 314 437 } 315 438 } 316 439
+4 -1
src/remote_helper.rs
··· 219 219 // resolve authentication 220 220 let auth = auth::resolve_auth(handle, &pds_url).await?; 221 221 222 - let client = PdsClient::with_auth(&pds_url, &auth.access_jwt); 222 + let client = match auth.dpop_key { 223 + Some(key) => PdsClient::with_dpop_auth(&pds_url, &auth.access_jwt, key), 224 + None => PdsClient::with_auth(&pds_url, &auth.access_jwt), 225 + }; 223 226 224 227 // determine repo path from cwd 225 228 let repo_path =