An encrypted personal cloud built on the AT Protocol.

Add AT Protocol OAuth 2.0 with DPoP for CLI authentication

Replace password-based createSession with OAuth 2.0 + DPoP as the
default login flow. The CLI discovers the PDS authorization server,
performs a Pushed Authorization Request with DPoP proof, opens the
browser for user authorization, and exchanges the code for tokens
via a loopback redirect server.

Session is now a discriminated union (Legacy/OAuth) with dual auth
dispatch in the XRPC client. Legacy password auth remains available
via --legacy flag. Existing session.json files deserialize as Legacy
for backward compatibility.

Also: inject $type field into putRecord/createRecord calls (required
by newer PDS versions), make logout account argument optional when
only one account exists, and update documentation.

New deps: p256 (ES256 signing), urlencoding.

sans-self.org 1484cdb8 4b46ef58

Waiting for spindle ...
+2584 -171
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add AT Protocol OAuth (DPoP) for CLI authentication [#175](https://issues.opake.app/issues/175.html) 15 16 - Wire opake-core WASM into web frontend [#163](https://issues.opake.app/issues/163.html) 16 17 - Add cat command to read and display file contents [#154](https://issues.opake.app/issues/154.html) 17 18 - Add directory record type and mkdir command [#98](https://issues.opake.app/issues/98.html)
+154
Cargo.lock
··· 224 224 ] 225 225 226 226 [[package]] 227 + name = "base16ct" 228 + version = "0.2.0" 229 + source = "registry+https://github.com/rust-lang/crates.io-index" 230 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 231 + 232 + [[package]] 227 233 name = "base64" 228 234 version = "0.22.1" 229 235 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 417 423 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 418 424 419 425 [[package]] 426 + name = "crypto-bigint" 427 + version = "0.5.5" 428 + source = "registry+https://github.com/rust-lang/crates.io-index" 429 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 430 + dependencies = [ 431 + "generic-array", 432 + "rand_core 0.6.4", 433 + "subtle", 434 + "zeroize", 435 + ] 436 + 437 + [[package]] 420 438 name = "crypto-common" 421 439 version = "0.1.7" 422 440 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 490 508 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 491 509 dependencies = [ 492 510 "const-oid", 511 + "pem-rfc7468", 493 512 "zeroize", 494 513 ] 495 514 ··· 500 519 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 501 520 dependencies = [ 502 521 "block-buffer", 522 + "const-oid", 503 523 "crypto-common", 504 524 "subtle", 505 525 ] ··· 522 542 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 523 543 524 544 [[package]] 545 + name = "ecdsa" 546 + version = "0.16.9" 547 + source = "registry+https://github.com/rust-lang/crates.io-index" 548 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 549 + dependencies = [ 550 + "der", 551 + "digest", 552 + "elliptic-curve", 553 + "rfc6979", 554 + "signature", 555 + "spki", 556 + ] 557 + 558 + [[package]] 525 559 name = "ed25519" 526 560 version = "2.2.3" 527 561 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 547 581 ] 548 582 549 583 [[package]] 584 + name = "elliptic-curve" 585 + version = "0.13.8" 586 + source = "registry+https://github.com/rust-lang/crates.io-index" 587 + checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 588 + dependencies = [ 589 + "base16ct", 590 + "base64ct", 591 + "crypto-bigint", 592 + "digest", 593 + "ff", 594 + "generic-array", 595 + "group", 596 + "pem-rfc7468", 597 + "pkcs8", 598 + "rand_core 0.6.4", 599 + "sec1", 600 + "serde_json", 601 + "serdect", 602 + "subtle", 603 + "zeroize", 604 + ] 605 + 606 + [[package]] 550 607 name = "env_filter" 551 608 version = "1.0.0" 552 609 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 604 661 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 605 662 606 663 [[package]] 664 + name = "ff" 665 + version = "0.13.1" 666 + source = "registry+https://github.com/rust-lang/crates.io-index" 667 + checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 668 + dependencies = [ 669 + "rand_core 0.6.4", 670 + "subtle", 671 + ] 672 + 673 + [[package]] 607 674 name = "fiat-crypto" 608 675 version = "0.2.9" 609 676 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 718 785 dependencies = [ 719 786 "typenum", 720 787 "version_check", 788 + "zeroize", 721 789 ] 722 790 723 791 [[package]] ··· 781 849 ] 782 850 783 851 [[package]] 852 + name = "group" 853 + version = "0.13.0" 854 + source = "registry+https://github.com/rust-lang/crates.io-index" 855 + checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 856 + dependencies = [ 857 + "ff", 858 + "rand_core 0.6.4", 859 + "subtle", 860 + ] 861 + 862 + [[package]] 784 863 name = "h2" 785 864 version = "0.4.13" 786 865 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1377 1456 "tempfile", 1378 1457 "tokio", 1379 1458 "toml", 1459 + "urlencoding", 1380 1460 ] 1381 1461 1382 1462 [[package]] ··· 1391 1471 "hkdf", 1392 1472 "log", 1393 1473 "opake-derive", 1474 + "p256", 1394 1475 "serde", 1395 1476 "serde_json", 1396 1477 "sha2", 1397 1478 "thiserror 2.0.18", 1398 1479 "tokio", 1480 + "urlencoding", 1399 1481 "x25519-dalek", 1400 1482 ] 1401 1483 ··· 1432 1514 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 1433 1515 1434 1516 [[package]] 1517 + name = "p256" 1518 + version = "0.13.2" 1519 + source = "registry+https://github.com/rust-lang/crates.io-index" 1520 + checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 1521 + dependencies = [ 1522 + "ecdsa", 1523 + "elliptic-curve", 1524 + "primeorder", 1525 + "sha2", 1526 + ] 1527 + 1528 + [[package]] 1435 1529 name = "parking_lot" 1436 1530 version = "0.12.5" 1437 1531 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1455 1549 ] 1456 1550 1457 1551 [[package]] 1552 + name = "pem-rfc7468" 1553 + version = "0.7.0" 1554 + source = "registry+https://github.com/rust-lang/crates.io-index" 1555 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 1556 + dependencies = [ 1557 + "base64ct", 1558 + ] 1559 + 1560 + [[package]] 1458 1561 name = "percent-encoding" 1459 1562 version = "2.3.2" 1460 1563 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1551 1654 checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1552 1655 dependencies = [ 1553 1656 "zerocopy", 1657 + ] 1658 + 1659 + [[package]] 1660 + name = "primeorder" 1661 + version = "0.13.6" 1662 + source = "registry+https://github.com/rust-lang/crates.io-index" 1663 + checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 1664 + dependencies = [ 1665 + "elliptic-curve", 1554 1666 ] 1555 1667 1556 1668 [[package]] ··· 1771 1883 ] 1772 1884 1773 1885 [[package]] 1886 + name = "rfc6979" 1887 + version = "0.4.0" 1888 + source = "registry+https://github.com/rust-lang/crates.io-index" 1889 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 1890 + dependencies = [ 1891 + "hmac", 1892 + "subtle", 1893 + ] 1894 + 1895 + [[package]] 1774 1896 name = "ring" 1775 1897 version = "0.17.14" 1776 1898 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1951 2073 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1952 2074 1953 2075 [[package]] 2076 + name = "sec1" 2077 + version = "0.7.3" 2078 + source = "registry+https://github.com/rust-lang/crates.io-index" 2079 + checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 2080 + dependencies = [ 2081 + "base16ct", 2082 + "der", 2083 + "generic-array", 2084 + "pkcs8", 2085 + "serdect", 2086 + "subtle", 2087 + "zeroize", 2088 + ] 2089 + 2090 + [[package]] 1954 2091 name = "security-framework" 1955 2092 version = "3.7.0" 1956 2093 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2066 2203 ] 2067 2204 2068 2205 [[package]] 2206 + name = "serdect" 2207 + version = "0.2.0" 2208 + source = "registry+https://github.com/rust-lang/crates.io-index" 2209 + checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" 2210 + dependencies = [ 2211 + "base16ct", 2212 + "serde", 2213 + ] 2214 + 2215 + [[package]] 2069 2216 name = "sha1" 2070 2217 version = "0.10.6" 2071 2218 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2109 2256 source = "registry+https://github.com/rust-lang/crates.io-index" 2110 2257 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2111 2258 dependencies = [ 2259 + "digest", 2112 2260 "rand_core 0.6.4", 2113 2261 ] 2114 2262 ··· 2606 2754 "percent-encoding", 2607 2755 "serde", 2608 2756 ] 2757 + 2758 + [[package]] 2759 + name = "urlencoding" 2760 + version = "2.1.3" 2761 + source = "registry+https://github.com/rust-lang/crates.io-index" 2762 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 2609 2763 2610 2764 [[package]] 2611 2765 name = "utf-8"
+7 -3
README.md
··· 37 37 ## Usage 38 38 39 39 ```sh 40 - # authenticate with your PDS 40 + # authenticate with your PDS (uses OAuth by default) 41 41 opake login --pds https://pds.example.com --identifier alice.example.com 42 + 43 + # force legacy password-based auth 44 + opake login --pds https://pds.example.com --identifier alice.example.com --legacy 42 45 43 46 # log in to a second account 44 47 opake login --pds https://other-pds.example.com --identifier bob.other.com ··· 111 114 opake download --keyring-member at://did:plc:abc/app.opake.cloud.document/tid456 112 115 opake keyring remove-member family-photos alice.example.com 113 116 114 - # remove an account 117 + # remove an account (defaults to only account if just one) 118 + opake logout 115 119 opake logout bob.other.com 116 120 ``` 117 121 ··· 155 159 - [x] Grant discovery (inbox command — queries AppView) 156 160 - [x] Keyring-based group sharing 157 161 - [ ] Web UI — cabinet file browser (in progress, auth stubbed) 158 - - [ ] AT Protocol OAuth (DPoP) for browser authentication 162 + - [x] AT Protocol OAuth (DPoP) for CLI and browser authentication 159 163 - [ ] Seed phrase key derivation for multi-device 160 164 161 165 ## Development
+1
crates/opake-cli/Cargo.toml
··· 23 23 serde_json.workspace = true 24 24 tokio.workspace = true 25 25 toml.workspace = true 26 + urlencoding = "2" 26 27 27 28 [dev-dependencies] 28 29 opake-core = { path = "../opake-core", features = ["test-utils"] }
+32 -10
crates/opake-cli/src/commands/login.rs
··· 40 40 /// Handle or DID 41 41 #[arg(long)] 42 42 identifier: String, 43 + 44 + /// Force legacy password-based authentication 45 + #[arg(long)] 46 + legacy: bool, 43 47 } 44 48 45 49 impl LoginCommand { 46 50 pub async fn execute(self, storage: &FileStorage) -> Result<Option<Session>> { 47 51 debug!("Starting login command"); 48 52 53 + if !self.legacy { 54 + match crate::oauth::try_oauth_login(&self.pds, &self.identifier, storage).await { 55 + Ok(session) => return Ok(Some(session)), 56 + Err(e) => { 57 + log::warn!("OAuth login failed, falling back to password authentication. Password auth is deprecated by AT Protocol and will stop working. Error: {e}"); 58 + } 59 + } 60 + } 61 + 62 + self.legacy_login(storage).await 63 + } 64 + 65 + async fn legacy_login(self, storage: &FileStorage) -> Result<Option<Session>> { 49 66 let password = resolve_password(prefixed_get_env("PASSWORD"), || { 50 67 prompt_password(&self.identifier, &self.pds) 51 68 })?; ··· 61 78 let mut cfg = storage.load_config_anyhow().unwrap_or_default(); 62 79 63 80 cfg.add_account( 64 - session.did.clone(), 81 + session.did().to_owned(), 65 82 AccountConfig { 66 83 pds_url: self.pds.clone(), 67 - handle: session.handle.clone(), 84 + handle: session.handle().to_owned(), 68 85 }, 69 86 ); 70 87 71 88 storage.save_config_anyhow(&cfg)?; 72 89 73 90 let (identity, generated) = 74 - identity::ensure_identity(storage, &session.did, &mut opake_core::crypto::OsRng)?; 91 + identity::ensure_identity(storage, session.did(), &mut opake_core::crypto::OsRng)?; 75 92 76 93 if generated { 77 94 println!("Generated new encryption keypair"); ··· 88 105 .await?; 89 106 println!("Published encryption public key"); 90 107 91 - println!("Logged in as {}", session.handle); 108 + println!("Logged in as {}", session.handle()); 92 109 93 110 Ok(Some(session)) 94 111 } ··· 116 133 Self { 117 134 response: HttpResponse { 118 135 status: 200, 136 + headers: vec![], 119 137 body: serde_json::to_vec(&body).unwrap(), 120 138 }, 121 139 } ··· 129 147 Self { 130 148 response: HttpResponse { 131 149 status: 401, 150 + headers: vec![], 132 151 body: serde_json::to_vec(&body).unwrap(), 133 152 }, 134 153 } ··· 148 167 149 168 let session = client.login("alice.test", "s3cret").await.unwrap(); 150 169 151 - assert_eq!(session.did, "did:plc:test123"); 152 - assert_eq!(session.handle, "alice.test"); 153 - assert!(!session.access_jwt.is_empty()); 154 - assert!(!session.refresh_jwt.is_empty()); 170 + assert_eq!(session.did(), "did:plc:test123"); 171 + assert_eq!(session.handle(), "alice.test"); 172 + match session { 173 + Session::Legacy(s) => { 174 + assert!(!s.access_jwt.is_empty()); 175 + assert!(!s.refresh_jwt.is_empty()); 176 + } 177 + _ => panic!("expected Legacy session"), 178 + } 155 179 } 156 180 157 181 #[tokio::test] ··· 189 213 190 214 #[test] 191 215 fn test_resolve_password_env_preserves_whitespace() { 192 - // env vars aren't trimmed — spaces in passwords are valid 193 216 let result = resolve_password(Some(" spaced ".into()), || { 194 217 panic!("prompt should not be called") 195 218 }); ··· 204 227 205 228 #[test] 206 229 fn test_resolve_password_rejects_empty_from_prompt() { 207 - // user just hits enter — trims to empty 208 230 let result = resolve_password(None, || Ok("".into())); 209 231 assert!(result.is_err()); 210 232 }
+16 -5
crates/opake-cli/src/commands/logout.rs
··· 6 6 #[derive(Args)] 7 7 /// Remove an account 8 8 pub struct LogoutCommand { 9 - /// Handle or DID of the account to remove 10 - account: String, 9 + /// Handle or DID of the account to remove (defaults to only account) 10 + account: Option<String>, 11 11 } 12 12 13 13 impl LogoutCommand { 14 14 pub fn run(self, storage: &FileStorage) -> Result<()> { 15 15 let cfg = storage.load_config_anyhow()?; 16 - let did = resolve_handle_or_did(&cfg, &self.account)?; 16 + let did = match self.account { 17 + Some(input) => resolve_handle_or_did(&cfg, &input)?, 18 + None => { 19 + let mut dids: Vec<_> = cfg.accounts.keys().collect(); 20 + anyhow::ensure!(!dids.is_empty(), "no accounts to log out"); 21 + anyhow::ensure!( 22 + dids.len() == 1, 23 + "multiple accounts — specify which one to log out" 24 + ); 25 + dids.remove(0).clone() 26 + } 27 + }; 17 28 let handle = cfg 18 29 .accounts 19 30 .get(&did) ··· 56 67 .unwrap(); 57 68 58 69 let cmd = LogoutCommand { 59 - account: "alice.test".into(), 70 + account: Some("alice.test".into()), 60 71 }; 61 72 cmd.run(&storage).unwrap(); 62 73 ··· 85 96 .unwrap(); 86 97 87 98 let cmd = LogoutCommand { 88 - account: "nobody.test".into(), 99 + account: Some("nobody.test".into()), 89 100 }; 90 101 assert!(cmd.run(&storage).is_err()); 91 102 }
+3 -2
crates/opake-cli/src/main.rs
··· 2 2 mod config; 3 3 mod identity; 4 4 mod keyring_store; 5 + mod oauth; 5 6 mod session; 6 7 mod transport; 7 8 pub mod utils; ··· 60 61 let ctx = session::resolve_context(storage, as_flag)?; 61 62 let refreshed = cmd.execute(&ctx).await?; 62 63 if let Some(ref s) = refreshed { 63 - session::persist_session(&ctx.storage, &s.did, s)?; 64 + session::persist_session(&ctx.storage, s.did(), s)?; 64 65 } 65 66 Ok(()) 66 67 } ··· 95 96 Command::Login(cmd) => { 96 97 let session = cmd.execute(&storage).await?; 97 98 if let Some(ref s) = session { 98 - session::persist_session(&storage, &s.did, s)?; 99 + session::persist_session(&storage, s.did(), s)?; 99 100 } 100 101 } 101 102 Command::Logout(cmd) => cmd.run(&storage)?,
+279
crates/opake-cli/src/oauth.rs
··· 1 + // OAuth 2.0 login flow for native CLI apps. 2 + // 3 + // Loopback redirect: start a local HTTP server on 127.0.0.1, open the 4 + // browser to the authorization URL, wait for the callback with the auth 5 + // code, exchange it for tokens. 6 + 7 + use anyhow::Result; 8 + use chrono::Utc; 9 + use log::{debug, info}; 10 + use opake_core::client::dpop::DpopKeyPair; 11 + use opake_core::client::oauth_discovery::{discover_authorization_server, generate_pkce}; 12 + use opake_core::client::oauth_token::{ 13 + build_authorization_url, exchange_code, pushed_authorization_request, 14 + }; 15 + use opake_core::client::{OAuthSession, Session, XrpcClient}; 16 + use opake_core::crypto::OsRng; 17 + use tokio::io::{AsyncReadExt, AsyncWriteExt}; 18 + use tokio::net::TcpListener; 19 + 20 + use crate::config::{AccountConfig, FileStorage}; 21 + use crate::identity; 22 + use crate::transport::ReqwestTransport; 23 + 24 + /// Attempt a full OAuth login flow. Returns `Err` if the PDS doesn't support 25 + /// OAuth discovery, so the caller can fall back to password auth. 26 + pub async fn try_oauth_login( 27 + pds_url: &str, 28 + identifier: &str, 29 + storage: &FileStorage, 30 + ) -> Result<Session> { 31 + let transport = ReqwestTransport::new(); 32 + 33 + // Step 1: Discover the authorization server 34 + info!("attempting OAuth discovery on {pds_url}"); 35 + let (_prm, asm) = discover_authorization_server(&transport, pds_url).await?; 36 + info!( 37 + "discovered AS: {} (issuer: {})", 38 + asm.authorization_endpoint, asm.issuer 39 + ); 40 + 41 + // Step 2: Bind loopback server to get the redirect URI 42 + let listener = TcpListener::bind("127.0.0.1:0").await?; 43 + let port = listener.local_addr()?.port(); 44 + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); 45 + debug!("loopback server on port {port}"); 46 + 47 + // Step 3: Generate DPoP keypair and PKCE challenge 48 + let dpop_key = DpopKeyPair::generate(&mut OsRng); 49 + let pkce = generate_pkce(&mut OsRng); 50 + 51 + // State parameter for CSRF protection 52 + let mut state_bytes = [0u8; 16]; 53 + OsRng.fill_bytes(&mut state_bytes); 54 + let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes); 55 + 56 + // Client ID: for native apps, use the redirect URI as client_id per atproto spec 57 + let client_id = format!( 58 + "http://localhost?redirect_uri={}", 59 + urlencoding::encode(&redirect_uri) 60 + ); 61 + 62 + let par_endpoint = asm 63 + .pushed_authorization_request_endpoint 64 + .as_deref() 65 + .unwrap_or(&asm.token_endpoint); 66 + debug!("PAR endpoint: {par_endpoint}"); 67 + 68 + let mut dpop_nonce = None; 69 + let timestamp = Utc::now().timestamp(); 70 + 71 + // Step 4: Pushed Authorization Request 72 + let par_response = pushed_authorization_request( 73 + &transport, 74 + par_endpoint, 75 + &client_id, 76 + &redirect_uri, 77 + &pkce, 78 + "atproto", 79 + &state, 80 + &dpop_key, 81 + &mut dpop_nonce, 82 + timestamp, 83 + &mut OsRng, 84 + ) 85 + .await?; 86 + debug!("PAR request_uri: {}", par_response.request_uri); 87 + 88 + // Step 5: Open browser 89 + let auth_url = build_authorization_url( 90 + &asm.authorization_endpoint, 91 + &client_id, 92 + &par_response.request_uri, 93 + ); 94 + println!("Opening browser for authentication..."); 95 + println!("If the browser doesn't open, visit:\n {auth_url}"); 96 + open_browser(&auth_url); 97 + 98 + // Step 6: Wait for the callback (PAR request_uri expires) 99 + let (code, callback_state) = wait_for_callback(listener, par_response.expires_in).await?; 100 + anyhow::ensure!( 101 + callback_state == state, 102 + "OAuth state mismatch — possible CSRF attack" 103 + ); 104 + info!("received authorization code"); 105 + 106 + // Step 7: Exchange code for tokens 107 + info!("exchanging authorization code for tokens"); 108 + let timestamp = Utc::now().timestamp(); 109 + let token_response = exchange_code( 110 + &transport, 111 + &asm.token_endpoint, 112 + &client_id, 113 + &code, 114 + &redirect_uri, 115 + &pkce.verifier, 116 + &dpop_key, 117 + &mut dpop_nonce, 118 + None, // don't verify sub yet — we'll get DID from the token 119 + timestamp, 120 + &mut OsRng, 121 + ) 122 + .await?; 123 + 124 + info!("token exchange successful"); 125 + 126 + let did = token_response 127 + .sub 128 + .ok_or_else(|| anyhow::anyhow!("token response missing `sub` claim"))?; 129 + info!("authenticated as {did}"); 130 + 131 + let handle = identifier.to_string(); 132 + 133 + let expires_at = token_response 134 + .expires_in 135 + .map(|secs| timestamp + secs as i64); 136 + 137 + let oauth_session = OAuthSession { 138 + did: did.clone(), 139 + handle: handle.clone(), 140 + access_token: token_response.access_token, 141 + refresh_token: token_response 142 + .refresh_token 143 + .ok_or_else(|| anyhow::anyhow!("token response missing refresh_token"))?, 144 + dpop_key, 145 + token_endpoint: asm.token_endpoint.clone(), 146 + dpop_nonce, 147 + expires_at, 148 + client_id, 149 + }; 150 + 151 + let session = Session::OAuth(oauth_session); 152 + 153 + // Step 8: Save account config 154 + let mut cfg = storage.load_config_anyhow().unwrap_or_default(); 155 + cfg.add_account( 156 + did.clone(), 157 + AccountConfig { 158 + pds_url: pds_url.to_string(), 159 + handle: handle.clone(), 160 + }, 161 + ); 162 + storage.save_config_anyhow(&cfg)?; 163 + 164 + // Step 9: Identity keypair + public key publication (same as legacy) 165 + let mut client = XrpcClient::with_session( 166 + ReqwestTransport::new(), 167 + pds_url.to_string(), 168 + session.clone(), 169 + ); 170 + 171 + let (identity, generated) = identity::ensure_identity(storage, &did, &mut OsRng)?; 172 + if generated { 173 + println!("Generated new encryption keypair"); 174 + } 175 + 176 + let public_key_bytes = identity.public_key_bytes()?; 177 + let verify_key_bytes = identity.verify_key_bytes()?; 178 + opake_core::resolve::publish_public_key( 179 + &mut client, 180 + &public_key_bytes, 181 + verify_key_bytes.as_ref(), 182 + &Utc::now().to_rfc3339(), 183 + ) 184 + .await?; 185 + println!("Published encryption public key"); 186 + 187 + println!("Logged in as {handle} (OAuth)"); 188 + 189 + Ok(session) 190 + } 191 + 192 + /// Wait for the OAuth callback on the loopback server. 193 + /// Returns `(code, state)` from the query parameters. 194 + /// Times out after `expires_in` seconds (the PAR request_uri lifetime). 195 + async fn wait_for_callback(listener: TcpListener, expires_in: u64) -> Result<(String, String)> { 196 + let timeout = std::time::Duration::from_secs(expires_in); 197 + let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept()) 198 + .await 199 + .map_err(|_| { 200 + anyhow::anyhow!( 201 + "OAuth authorization timed out after {expires_in}s — the request expired" 202 + ) 203 + })??; 204 + 205 + let mut buf = vec![0u8; 4096]; 206 + let n = stream.read(&mut buf).await?; 207 + let request_str = String::from_utf8_lossy(&buf[..n]); 208 + 209 + // Parse GET /callback?code=...&state=... HTTP/1.1 210 + let path = request_str 211 + .lines() 212 + .next() 213 + .and_then(|line| line.split_whitespace().nth(1)) 214 + .ok_or_else(|| anyhow::anyhow!("malformed callback request"))?; 215 + 216 + let query = path 217 + .split_once('?') 218 + .map(|(_, q)| q) 219 + .ok_or_else(|| anyhow::anyhow!("callback missing query params"))?; 220 + 221 + let mut code = None; 222 + let mut state = None; 223 + let mut error = None; 224 + 225 + for pair in query.split('&') { 226 + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); 227 + let value = urlencoding::decode(value)?.into_owned(); 228 + match key { 229 + "code" => code = Some(value), 230 + "state" => state = Some(value), 231 + "error" => error = Some(value), 232 + _ => {} 233 + } 234 + } 235 + 236 + // Respond to browser 237 + let (status, body) = if error.is_some() { 238 + ("400 Bad Request", "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>") 239 + } else { 240 + ("200 OK", "<html><body><h1>Authentication successful</h1><p>You can close this tab and return to the terminal.</p></body></html>") 241 + }; 242 + 243 + let response = format!( 244 + "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", 245 + body.len() 246 + ); 247 + stream.write_all(response.as_bytes()).await?; 248 + stream.shutdown().await?; 249 + 250 + if let Some(err) = error { 251 + anyhow::bail!("OAuth error from AS: {err}"); 252 + } 253 + 254 + Ok(( 255 + code.ok_or_else(|| anyhow::anyhow!("callback missing `code` parameter"))?, 256 + state.ok_or_else(|| anyhow::anyhow!("callback missing `state` parameter"))?, 257 + )) 258 + } 259 + 260 + /// Open a URL in the system browser. Best-effort — doesn't fail if the 261 + /// browser can't be opened (the URL is printed to stdout as a fallback). 262 + fn open_browser(url: &str) { 263 + let result = if cfg!(target_os = "macos") { 264 + std::process::Command::new("open").arg(url).spawn() 265 + } else if cfg!(target_os = "windows") { 266 + std::process::Command::new("cmd") 267 + .args(["/C", "start", url]) 268 + .spawn() 269 + } else { 270 + std::process::Command::new("xdg-open").arg(url).spawn() 271 + }; 272 + 273 + if let Err(e) = result { 274 + debug!("failed to open browser: {e}"); 275 + } 276 + } 277 + 278 + use base64::Engine; 279 + use opake_core::crypto::RngCore;
+5 -6
crates/opake-cli/src/session.rs
··· 79 79 use super::*; 80 80 use crate::config::{AccountConfig, Config}; 81 81 use crate::utils::test_harness::test_storage; 82 + use opake_core::client::LegacySession; 82 83 use std::collections::BTreeMap; 83 84 use std::fs; 84 85 85 86 fn fake_session() -> Session { 86 - Session { 87 + Session::Legacy(LegacySession { 87 88 did: "did:plc:test123".into(), 88 89 handle: "alice.test".into(), 89 90 access_jwt: "eyJ.access.token".into(), 90 91 refresh_jwt: "eyJ.refresh.token".into(), 91 - } 92 + }) 92 93 } 93 94 94 95 fn setup_account(storage: &FileStorage, did: &str, pds_url: &str, handle: &str) { ··· 118 119 persist_session(&storage, did, &session).unwrap(); 119 120 120 121 let loaded: Session = storage.load_account_json(did, "session.json").unwrap(); 121 - assert_eq!(loaded.did, session.did); 122 - assert_eq!(loaded.handle, session.handle); 123 - assert_eq!(loaded.access_jwt, session.access_jwt); 124 - assert_eq!(loaded.refresh_jwt, session.refresh_jwt); 122 + assert_eq!(loaded.did(), session.did()); 123 + assert_eq!(loaded.handle(), session.handle()); 125 124 } 126 125 127 126 #[test]
+18
crates/opake-cli/src/transport.rs
··· 32 32 RequestBody::Bytes { data, content_type } => { 33 33 builder.header("Content-Type", content_type).body(data) 34 34 } 35 + RequestBody::Form(params) => { 36 + let encoded: String = params 37 + .iter() 38 + .map(|(k, v)| { 39 + format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)) 40 + }) 41 + .collect::<Vec<_>>() 42 + .join("&"); 43 + builder 44 + .header("Content-Type", "application/x-www-form-urlencoded") 45 + .body(encoded) 46 + } 35 47 }; 36 48 } 37 49 ··· 41 53 })?; 42 54 43 55 let status = response.status().as_u16(); 56 + let headers = response 57 + .headers() 58 + .iter() 59 + .map(|(k, v)| (k.as_str().to_owned(), v.to_str().unwrap_or("").to_owned())) 60 + .collect(); 44 61 let body = response.bytes().await.map_err(|e| Error::Xrpc { 45 62 status, 46 63 message: e.to_string(), ··· 48 65 49 66 Ok(HttpResponse { 50 67 status, 68 + headers, 51 69 body: body.to_vec(), 52 70 }) 53 71 }
+2
crates/opake-core/Cargo.toml
··· 18 18 thiserror.workspace = true 19 19 20 20 aes-gcm = "0.10" 21 + p256 = { version = "0.13", features = ["ecdsa", "jwk"] } 22 + urlencoding = "2" 21 23 x25519-dalek = { version = "2", features = ["static_secrets"] } 22 24 aes-kw = { version = "0.2", features = ["alloc"] } 23 25 hkdf = "0.12" # HKDF key derivation (RFC 5869)
+6
crates/opake-core/src/client/appview.rs
··· 138 138 let mock = MockTransport::new(); 139 139 mock.enqueue(HttpResponse { 140 140 status: 200, 141 + headers: vec![], 141 142 body: inbox_json(&[&grant_json("g1"), &grant_json("g2")], None), 142 143 }); 143 144 ··· 168 169 let mock = MockTransport::new(); 169 170 mock.enqueue(HttpResponse { 170 171 status: 200, 172 + headers: vec![], 171 173 body: inbox_json(&[&grant_json("g1")], Some("cursor1")), 172 174 }); 173 175 mock.enqueue(HttpResponse { 174 176 status: 200, 177 + headers: vec![], 175 178 body: inbox_json(&[&grant_json("g2")], None), 176 179 }); 177 180 ··· 189 192 let mock = MockTransport::new(); 190 193 mock.enqueue(HttpResponse { 191 194 status: 200, 195 + headers: vec![], 192 196 body: inbox_json(&[], None), 193 197 }); 194 198 ··· 211 215 let mock = MockTransport::new(); 212 216 mock.enqueue(HttpResponse { 213 217 status: 401, 218 + headers: vec![], 214 219 body: r#"{"error":"signature verification failed"}"#.as_bytes().to_vec(), 215 220 }); 216 221 ··· 239 244 let mock = MockTransport::new(); 240 245 mock.enqueue(HttpResponse { 241 246 status: 500, 247 + headers: vec![], 242 248 body: r#"{"error":"internal server error"}"#.as_bytes().to_vec(), 243 249 }); 244 250
+2
crates/opake-core/src/client/did_tests.rs
··· 4 4 fn response(status: u16, body: &str) -> HttpResponse { 5 5 HttpResponse { 6 6 status, 7 + headers: vec![], 7 8 body: body.as_bytes().to_vec(), 8 9 } 9 10 } ··· 87 88 let blob_data = b"encrypted-blob-bytes"; 88 89 mock.enqueue(HttpResponse { 89 90 status: 200, 91 + headers: vec![], 90 92 body: blob_data.to_vec(), 91 93 }); 92 94
+207
crates/opake-core/src/client/dpop.rs
··· 1 + // DPoP (Demonstrating Proof-of-Possession) for OAuth 2.0. 2 + // 3 + // Hand-rolled ES256 JWT proofs. We only *create* DPoP proofs, never verify 4 + // them, so pulling in a full JWT crate would be overkill (and a dependency 5 + // nightmare for WASM). The JWS signature is raw r‖s (64 bytes), not DER. 6 + 7 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL, Engine}; 8 + use p256::ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey}; 9 + use serde::{Deserialize, Serialize}; 10 + use sha2::{Digest, Sha256}; 11 + 12 + use crate::crypto::{CryptoRng, RngCore}; 13 + use crate::error::Error; 14 + 15 + use super::transport::HttpResponse; 16 + 17 + // --------------------------------------------------------------------------- 18 + // DPoP keypair 19 + // --------------------------------------------------------------------------- 20 + 21 + /// A P-256 keypair used for DPoP proof generation. Session-scoped, not 22 + /// identity-scoped — created fresh on each OAuth login. 23 + #[derive(Clone, Serialize, Deserialize)] 24 + pub struct DpopKeyPair { 25 + /// SEC1-encoded private key bytes (32 bytes), base64url-encoded for storage. 26 + #[serde(rename = "privateKey")] 27 + private_key_b64: String, 28 + /// JWK public key (the `x` and `y` coordinates). Embedded directly in 29 + /// every DPoP proof header. 30 + #[serde(rename = "publicJwk")] 31 + public_jwk: DpopPublicJwk, 32 + } 33 + 34 + /// The public half of a DPoP key, serialized as a JWK in the DPoP JWT header. 35 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 36 + pub struct DpopPublicJwk { 37 + pub kty: String, 38 + pub crv: String, 39 + pub x: String, 40 + pub y: String, 41 + } 42 + 43 + impl std::fmt::Debug for DpopKeyPair { 44 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 + f.debug_struct("DpopKeyPair") 46 + .field("private_key_b64", &"[redacted]") 47 + .field("public_jwk", &self.public_jwk) 48 + .finish() 49 + } 50 + } 51 + 52 + impl DpopKeyPair { 53 + /// Generate a fresh P-256 keypair for DPoP proofs. 54 + pub fn generate(rng: &mut (impl CryptoRng + RngCore)) -> Self { 55 + let signing_key = SigningKey::random(rng); 56 + let verifying_key = VerifyingKey::from(&signing_key); 57 + 58 + let encoded_point = verifying_key.to_encoded_point(false); 59 + let public_jwk = DpopPublicJwk { 60 + kty: "EC".into(), 61 + crv: "P-256".into(), 62 + x: BASE64URL.encode(encoded_point.x().unwrap()), 63 + y: BASE64URL.encode(encoded_point.y().unwrap()), 64 + }; 65 + 66 + let private_key_b64 = BASE64URL.encode(signing_key.to_bytes()); 67 + 68 + Self { 69 + private_key_b64, 70 + public_jwk, 71 + } 72 + } 73 + 74 + fn signing_key(&self) -> Result<SigningKey, Error> { 75 + let bytes = BASE64URL 76 + .decode(&self.private_key_b64) 77 + .map_err(|e| Error::Auth(format!("invalid DPoP private key: {e}")))?; 78 + SigningKey::from_bytes(bytes.as_slice().into()) 79 + .map_err(|e| Error::Auth(format!("invalid DPoP private key: {e}"))) 80 + } 81 + 82 + /// The public JWK for embedding in DPoP proof headers. 83 + pub fn public_jwk(&self) -> &DpopPublicJwk { 84 + &self.public_jwk 85 + } 86 + 87 + /// JWK thumbprint (S256) of the public key, per RFC 7638. 88 + /// Used as the `jkt` confirmation claim in token introspection. 89 + pub fn jwk_thumbprint(&self) -> String { 90 + let canonical = format!( 91 + r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#, 92 + self.public_jwk.crv, self.public_jwk.kty, self.public_jwk.x, self.public_jwk.y, 93 + ); 94 + let hash = Sha256::digest(canonical.as_bytes()); 95 + BASE64URL.encode(hash) 96 + } 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // DPoP proof creation 101 + // --------------------------------------------------------------------------- 102 + 103 + /// Create a DPoP proof JWT for a given HTTP method and URL. 104 + /// 105 + /// The proof is a compact JWS (header.payload.signature) with: 106 + /// - `typ: "dpop+jwt"`, `alg: "ES256"`, `jwk: <public key>` 107 + /// - `jti: <unique id>`, `htm: <method>`, `htu: <url>`, `iat: <timestamp>` 108 + /// - Optional `nonce` (from AS `DPoP-Nonce` header) 109 + /// - Optional `ath` (access token hash, for resource server requests) 110 + /// 111 + /// Timestamp is injected so callers can control time (WASM has no clock). 112 + /// RNG is injected for the `jti` claim. 113 + pub fn create_dpop_proof( 114 + keypair: &DpopKeyPair, 115 + method: &str, 116 + url: &str, 117 + timestamp: i64, 118 + nonce: Option<&str>, 119 + access_token: Option<&str>, 120 + rng: &mut (impl CryptoRng + RngCore), 121 + ) -> Result<String, Error> { 122 + let signing_key = keypair.signing_key()?; 123 + 124 + // Header 125 + let header = serde_json::json!({ 126 + "typ": "dpop+jwt", 127 + "alg": "ES256", 128 + "jwk": keypair.public_jwk, 129 + }); 130 + 131 + // jti: random 16 bytes, base64url-encoded 132 + let mut jti_bytes = [0u8; 16]; 133 + rng.fill_bytes(&mut jti_bytes); 134 + let jti = BASE64URL.encode(jti_bytes); 135 + 136 + // htu: strip query and fragment per RFC 9449 §4.2 137 + let htu = strip_query_fragment(url); 138 + 139 + // Payload 140 + let mut payload = serde_json::json!({ 141 + "jti": jti, 142 + "htm": method, 143 + "htu": htu, 144 + "iat": timestamp, 145 + }); 146 + 147 + if let Some(n) = nonce { 148 + payload["nonce"] = serde_json::Value::String(n.to_string()); 149 + } 150 + 151 + if let Some(token) = access_token { 152 + let hash = Sha256::digest(token.as_bytes()); 153 + payload["ath"] = serde_json::Value::String(BASE64URL.encode(hash)); 154 + } 155 + 156 + // Encode 157 + let header_b64 = BASE64URL.encode(serde_json::to_vec(&header).unwrap()); 158 + let payload_b64 = BASE64URL.encode(serde_json::to_vec(&payload).unwrap()); 159 + let signing_input = format!("{header_b64}.{payload_b64}"); 160 + 161 + // Sign — raw r‖s (64 bytes), NOT DER 162 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 163 + let sig_b64 = BASE64URL.encode(signature.to_bytes()); 164 + 165 + Ok(format!("{signing_input}.{sig_b64}")) 166 + } 167 + 168 + // --------------------------------------------------------------------------- 169 + // Helpers 170 + // --------------------------------------------------------------------------- 171 + 172 + /// Strip query string and fragment from a URL (RFC 9449 §4.2). 173 + fn strip_query_fragment(url: &str) -> &str { 174 + let end = url.find('?').or_else(|| url.find('#')).unwrap_or(url.len()); 175 + &url[..end] 176 + } 177 + 178 + // --------------------------------------------------------------------------- 179 + // DPoP nonce helpers 180 + // --------------------------------------------------------------------------- 181 + 182 + /// Extract the `DPoP-Nonce` header from an HTTP response. 183 + pub fn extract_dpop_nonce(response: &HttpResponse) -> Option<String> { 184 + response.header("dpop-nonce").map(|v| v.to_string()) 185 + } 186 + 187 + /// Check whether a response is a `use_dpop_nonce` error — the AS telling us 188 + /// to retry with the nonce it provided in the `DPoP-Nonce` header. 189 + pub fn is_use_dpop_nonce_error(response: &HttpResponse) -> bool { 190 + if response.status != 400 { 191 + return false; 192 + } 193 + 194 + #[derive(Deserialize)] 195 + struct ErrorBody { 196 + error: Option<String>, 197 + } 198 + 199 + serde_json::from_slice::<ErrorBody>(&response.body) 200 + .ok() 201 + .and_then(|b| b.error) 202 + .is_some_and(|e| e == "use_dpop_nonce") 203 + } 204 + 205 + #[cfg(test)] 206 + #[path = "dpop_tests.rs"] 207 + mod tests;
+333
crates/opake-core/src/client/dpop_tests.rs
··· 1 + use super::*; 2 + use crate::crypto::OsRng; 3 + 4 + #[test] 5 + fn generate_keypair_produces_valid_jwk() { 6 + let kp = DpopKeyPair::generate(&mut OsRng); 7 + assert_eq!(kp.public_jwk.kty, "EC"); 8 + assert_eq!(kp.public_jwk.crv, "P-256"); 9 + assert!(!kp.public_jwk.x.is_empty()); 10 + assert!(!kp.public_jwk.y.is_empty()); 11 + } 12 + 13 + #[test] 14 + fn keypair_roundtrips_through_json() { 15 + let kp = DpopKeyPair::generate(&mut OsRng); 16 + let json = serde_json::to_string(&kp).unwrap(); 17 + let restored: DpopKeyPair = serde_json::from_str(&json).unwrap(); 18 + assert_eq!(restored.public_jwk, kp.public_jwk); 19 + } 20 + 21 + #[test] 22 + fn keypair_debug_redacts_private_key() { 23 + let kp = DpopKeyPair::generate(&mut OsRng); 24 + let debug = format!("{kp:?}"); 25 + assert!(debug.contains("[redacted]")); 26 + assert!(!debug.contains(&kp.private_key_b64)); 27 + } 28 + 29 + #[test] 30 + fn create_proof_produces_three_part_jwt() { 31 + let kp = DpopKeyPair::generate(&mut OsRng); 32 + let proof = create_dpop_proof( 33 + &kp, 34 + "POST", 35 + "https://pds.example/token", 36 + 1700000000, 37 + None, 38 + None, 39 + &mut OsRng, 40 + ) 41 + .unwrap(); 42 + let parts: Vec<&str> = proof.split('.').collect(); 43 + assert_eq!(parts.len(), 3, "JWT must have 3 parts: {proof}"); 44 + } 45 + 46 + #[test] 47 + fn proof_header_has_correct_fields() { 48 + let kp = DpopKeyPair::generate(&mut OsRng); 49 + let proof = create_dpop_proof( 50 + &kp, 51 + "POST", 52 + "https://pds.example/token", 53 + 1700000000, 54 + None, 55 + None, 56 + &mut OsRng, 57 + ) 58 + .unwrap(); 59 + let header_b64 = proof.split('.').next().unwrap(); 60 + let header: serde_json::Value = 61 + serde_json::from_slice(&BASE64URL.decode(header_b64).unwrap()).unwrap(); 62 + assert_eq!(header["typ"], "dpop+jwt"); 63 + assert_eq!(header["alg"], "ES256"); 64 + assert_eq!(header["jwk"]["kty"], "EC"); 65 + assert_eq!(header["jwk"]["crv"], "P-256"); 66 + } 67 + 68 + #[test] 69 + fn proof_payload_has_required_claims() { 70 + let kp = DpopKeyPair::generate(&mut OsRng); 71 + let proof = create_dpop_proof( 72 + &kp, 73 + "GET", 74 + "https://pds.example/xrpc/foo", 75 + 1700000000, 76 + None, 77 + None, 78 + &mut OsRng, 79 + ) 80 + .unwrap(); 81 + let payload_b64 = proof.split('.').nth(1).unwrap(); 82 + let payload: serde_json::Value = 83 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 84 + assert_eq!(payload["htm"], "GET"); 85 + assert_eq!(payload["htu"], "https://pds.example/xrpc/foo"); 86 + assert_eq!(payload["iat"], 1700000000); 87 + assert!(payload["jti"].is_string()); 88 + assert!(payload.get("nonce").is_none()); 89 + assert!(payload.get("ath").is_none()); 90 + } 91 + 92 + #[test] 93 + fn proof_includes_nonce_when_provided() { 94 + let kp = DpopKeyPair::generate(&mut OsRng); 95 + let proof = create_dpop_proof( 96 + &kp, 97 + "POST", 98 + "https://pds.example/token", 99 + 1700000000, 100 + Some("server-nonce-42"), 101 + None, 102 + &mut OsRng, 103 + ) 104 + .unwrap(); 105 + let payload_b64 = proof.split('.').nth(1).unwrap(); 106 + let payload: serde_json::Value = 107 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 108 + assert_eq!(payload["nonce"], "server-nonce-42"); 109 + } 110 + 111 + #[test] 112 + fn proof_includes_ath_when_access_token_provided() { 113 + let kp = DpopKeyPair::generate(&mut OsRng); 114 + let proof = create_dpop_proof( 115 + &kp, 116 + "GET", 117 + "https://pds.example/xrpc/foo", 118 + 1700000000, 119 + None, 120 + Some("my-access-token"), 121 + &mut OsRng, 122 + ) 123 + .unwrap(); 124 + let payload_b64 = proof.split('.').nth(1).unwrap(); 125 + let payload: serde_json::Value = 126 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 127 + // ath = base64url(sha256(access_token)) 128 + let expected_hash = Sha256::digest(b"my-access-token"); 129 + let expected_ath = BASE64URL.encode(expected_hash); 130 + assert_eq!(payload["ath"], expected_ath); 131 + } 132 + 133 + #[test] 134 + fn proof_signature_is_64_bytes_raw() { 135 + let kp = DpopKeyPair::generate(&mut OsRng); 136 + let proof = create_dpop_proof( 137 + &kp, 138 + "POST", 139 + "https://pds.example/token", 140 + 1700000000, 141 + None, 142 + None, 143 + &mut OsRng, 144 + ) 145 + .unwrap(); 146 + let sig_b64 = proof.split('.').nth(2).unwrap(); 147 + let sig_bytes = BASE64URL.decode(sig_b64).unwrap(); 148 + assert_eq!( 149 + sig_bytes.len(), 150 + 64, 151 + "ES256 raw r‖s signature must be 64 bytes" 152 + ); 153 + } 154 + 155 + #[test] 156 + fn proof_signature_verifies() { 157 + let kp = DpopKeyPair::generate(&mut OsRng); 158 + let proof = create_dpop_proof( 159 + &kp, 160 + "POST", 161 + "https://pds.example/token", 162 + 1700000000, 163 + None, 164 + None, 165 + &mut OsRng, 166 + ) 167 + .unwrap(); 168 + let parts: Vec<&str> = proof.split('.').collect(); 169 + let signing_input = format!("{}.{}", parts[0], parts[1]); 170 + let sig_bytes = BASE64URL.decode(parts[2]).unwrap(); 171 + let signature = Signature::from_bytes(sig_bytes.as_slice().into()).unwrap(); 172 + let verifying_key = VerifyingKey::from(&kp.signing_key().unwrap()); 173 + use p256::ecdsa::signature::Verifier; 174 + verifying_key 175 + .verify(signing_input.as_bytes(), &signature) 176 + .unwrap(); 177 + } 178 + 179 + #[test] 180 + fn jti_is_unique_per_proof() { 181 + let kp = DpopKeyPair::generate(&mut OsRng); 182 + let extract_jti = |proof: &str| -> String { 183 + let payload_b64 = proof.split('.').nth(1).unwrap(); 184 + let payload: serde_json::Value = 185 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 186 + payload["jti"].as_str().unwrap().to_string() 187 + }; 188 + let p1 = create_dpop_proof(&kp, "POST", "https://x", 1, None, None, &mut OsRng).unwrap(); 189 + let p2 = create_dpop_proof(&kp, "POST", "https://x", 1, None, None, &mut OsRng).unwrap(); 190 + assert_ne!(extract_jti(&p1), extract_jti(&p2)); 191 + } 192 + 193 + #[test] 194 + fn jwk_thumbprint_is_deterministic() { 195 + let kp = DpopKeyPair::generate(&mut OsRng); 196 + assert_eq!(kp.jwk_thumbprint(), kp.jwk_thumbprint()); 197 + } 198 + 199 + #[test] 200 + fn jwk_thumbprint_differs_between_keys() { 201 + let kp1 = DpopKeyPair::generate(&mut OsRng); 202 + let kp2 = DpopKeyPair::generate(&mut OsRng); 203 + assert_ne!(kp1.jwk_thumbprint(), kp2.jwk_thumbprint()); 204 + } 205 + 206 + // -- nonce helpers -- 207 + 208 + #[test] 209 + fn extract_dpop_nonce_finds_header() { 210 + let response = HttpResponse { 211 + status: 200, 212 + headers: vec![("DPoP-Nonce".into(), "abc123".into())], 213 + body: vec![], 214 + }; 215 + assert_eq!(extract_dpop_nonce(&response).unwrap(), "abc123"); 216 + } 217 + 218 + #[test] 219 + fn extract_dpop_nonce_case_insensitive() { 220 + let response = HttpResponse { 221 + status: 200, 222 + headers: vec![("dpop-nonce".into(), "lower".into())], 223 + body: vec![], 224 + }; 225 + assert_eq!(extract_dpop_nonce(&response).unwrap(), "lower"); 226 + } 227 + 228 + #[test] 229 + fn extract_dpop_nonce_missing_returns_none() { 230 + let response = HttpResponse { 231 + status: 200, 232 + headers: vec![], 233 + body: vec![], 234 + }; 235 + assert!(extract_dpop_nonce(&response).is_none()); 236 + } 237 + 238 + #[test] 239 + fn is_use_dpop_nonce_error_detects_correctly() { 240 + let response = HttpResponse { 241 + status: 400, 242 + headers: vec![("DPoP-Nonce".into(), "new-nonce".into())], 243 + body: br#"{"error":"use_dpop_nonce"}"#.to_vec(), 244 + }; 245 + assert!(is_use_dpop_nonce_error(&response)); 246 + } 247 + 248 + #[test] 249 + fn is_use_dpop_nonce_error_rejects_other_400() { 250 + let response = HttpResponse { 251 + status: 400, 252 + headers: vec![], 253 + body: br#"{"error":"invalid_request"}"#.to_vec(), 254 + }; 255 + assert!(!is_use_dpop_nonce_error(&response)); 256 + } 257 + 258 + #[test] 259 + fn is_use_dpop_nonce_error_rejects_non_400() { 260 + let response = HttpResponse { 261 + status: 401, 262 + headers: vec![], 263 + body: br#"{"error":"use_dpop_nonce"}"#.to_vec(), 264 + }; 265 + assert!(!is_use_dpop_nonce_error(&response)); 266 + } 267 + 268 + // -- htu stripping -- 269 + 270 + #[test] 271 + fn htu_strips_query_string() { 272 + let kp = DpopKeyPair::generate(&mut OsRng); 273 + let proof = create_dpop_proof( 274 + &kp, 275 + "GET", 276 + "https://pds.example/xrpc/foo?bar=1", 277 + 1700000000, 278 + None, 279 + None, 280 + &mut OsRng, 281 + ) 282 + .unwrap(); 283 + let payload_b64 = proof.split('.').nth(1).unwrap(); 284 + let payload: serde_json::Value = 285 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 286 + assert_eq!(payload["htu"], "https://pds.example/xrpc/foo"); 287 + } 288 + 289 + #[test] 290 + fn htu_strips_fragment() { 291 + let kp = DpopKeyPair::generate(&mut OsRng); 292 + let proof = create_dpop_proof( 293 + &kp, 294 + "GET", 295 + "https://pds.example/xrpc/foo#section", 296 + 1700000000, 297 + None, 298 + None, 299 + &mut OsRng, 300 + ) 301 + .unwrap(); 302 + let payload_b64 = proof.split('.').nth(1).unwrap(); 303 + let payload: serde_json::Value = 304 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 305 + assert_eq!(payload["htu"], "https://pds.example/xrpc/foo"); 306 + } 307 + 308 + #[test] 309 + fn htu_strips_query_and_fragment() { 310 + let kp = DpopKeyPair::generate(&mut OsRng); 311 + let proof = create_dpop_proof( 312 + &kp, 313 + "GET", 314 + "https://pds.example/path?q=1#frag", 315 + 1700000000, 316 + None, 317 + None, 318 + &mut OsRng, 319 + ) 320 + .unwrap(); 321 + let payload_b64 = proof.split('.').nth(1).unwrap(); 322 + let payload: serde_json::Value = 323 + serde_json::from_slice(&BASE64URL.decode(payload_b64).unwrap()).unwrap(); 324 + assert_eq!(payload["htu"], "https://pds.example/path"); 325 + } 326 + 327 + #[test] 328 + fn strip_query_fragment_no_op_for_clean_url() { 329 + assert_eq!( 330 + strip_query_fragment("https://example.com/path"), 331 + "https://example.com/path" 332 + ); 333 + }
+6 -3
crates/opake-core/src/client/list.rs
··· 67 67 #[cfg(test)] 68 68 mod tests { 69 69 use super::*; 70 - use crate::client::{HttpResponse, Session, XrpcClient}; 70 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 71 71 use crate::records; 72 72 use crate::test_utils::MockTransport; 73 73 use serde::{Deserialize, Serialize}; ··· 89 89 } 90 90 91 91 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 92 - let session = Session { 92 + let session = Session::Legacy(LegacySession { 93 93 did: TEST_DID.into(), 94 94 handle: "test.handle".into(), 95 95 access_jwt: "test-jwt".into(), 96 96 refresh_jwt: "test-refresh".into(), 97 - }; 97 + }); 98 98 XrpcClient::with_session(mock, "https://pds.test".into(), session) 99 99 } 100 100 ··· 117 117 118 118 HttpResponse { 119 119 status: 200, 120 + headers: vec![], 120 121 body: serde_json::to_vec(&body).unwrap(), 121 122 } 122 123 } ··· 224 225 let mock = MockTransport::new(); 225 226 mock.enqueue(HttpResponse { 226 227 status: 200, 228 + headers: vec![], 227 229 body: serde_json::to_vec(&body).unwrap(), 228 230 }); 229 231 ··· 257 259 let mock = MockTransport::new(); 258 260 mock.enqueue(HttpResponse { 259 261 status: 500, 262 + headers: vec![], 260 263 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 261 264 }); 262 265
+3
crates/opake-core/src/client/mod.rs
··· 2 2 mod appview_auth; 3 3 mod appview_types; 4 4 mod did; 5 + pub mod dpop; 5 6 mod list; 7 + pub mod oauth_discovery; 8 + pub mod oauth_token; 6 9 mod transport; 7 10 mod xrpc; 8 11
+153
crates/opake-core/src/client/oauth_discovery.rs
··· 1 + // OAuth 2.0 discovery for AT Protocol PDSes. 2 + // 3 + // Two-step discovery: first fetch the Protected Resource Metadata from the PDS, 4 + // then fetch the Authorization Server Metadata from the AS it points to. 5 + // Also includes PKCE (S256) generation since it's tightly coupled to the 6 + // OAuth flow and needs no extra deps (sha2 is already in the tree). 7 + 8 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL, Engine}; 9 + use serde::Deserialize; 10 + use sha2::{Digest, Sha256}; 11 + 12 + use crate::crypto::{CryptoRng, RngCore}; 13 + use crate::error::Error; 14 + 15 + use super::transport::{HttpMethod, HttpRequest, HttpResponse, Transport}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Metadata types 19 + // --------------------------------------------------------------------------- 20 + 21 + /// OAuth Protected Resource Metadata (RFC 9728). 22 + /// Fetched from `<pds>/.well-known/oauth-protected-resource`. 23 + #[derive(Debug, Clone, Deserialize)] 24 + pub struct ProtectedResourceMetadata { 25 + /// The PDS resource identifier (its own URL). 26 + pub resource: String, 27 + /// Authorization servers that protect this resource. 28 + pub authorization_servers: Vec<String>, 29 + /// Token endpoint auth methods the resource supports. 30 + #[serde(default)] 31 + pub bearer_methods_supported: Vec<String>, 32 + /// Scopes available at this resource. 33 + #[serde(default)] 34 + pub scopes_supported: Vec<String>, 35 + } 36 + 37 + /// OAuth Authorization Server Metadata (RFC 8414). 38 + /// Fetched from `<as>/.well-known/oauth-authorization-server`. 39 + #[derive(Debug, Clone, Deserialize)] 40 + pub struct AuthorizationServerMetadata { 41 + pub issuer: String, 42 + pub authorization_endpoint: String, 43 + pub token_endpoint: String, 44 + #[serde(default)] 45 + pub pushed_authorization_request_endpoint: Option<String>, 46 + #[serde(default)] 47 + pub scopes_supported: Vec<String>, 48 + #[serde(default)] 49 + pub response_types_supported: Vec<String>, 50 + #[serde(default)] 51 + pub grant_types_supported: Vec<String>, 52 + #[serde(default)] 53 + pub code_challenge_methods_supported: Vec<String>, 54 + #[serde(default)] 55 + pub dpop_signing_alg_values_supported: Vec<String>, 56 + #[serde(default)] 57 + pub token_endpoint_auth_methods_supported: Vec<String>, 58 + /// Whether this AS requires PAR (RFC 9126). 59 + #[serde(default)] 60 + pub require_pushed_authorization_requests: bool, 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // Discovery 65 + // --------------------------------------------------------------------------- 66 + 67 + /// Discover the OAuth authorization server for a PDS. 68 + /// 69 + /// 1. Fetch `<pds_url>/.well-known/oauth-protected-resource` 70 + /// 2. Extract the first `authorization_servers` entry 71 + /// 3. Fetch `<as_url>/.well-known/oauth-authorization-server` 72 + /// 73 + /// Returns both metadata documents. Errors if the PDS doesn't support OAuth 74 + /// (no protected resource metadata) or the AS metadata fetch fails. 75 + pub async fn discover_authorization_server( 76 + transport: &impl Transport, 77 + pds_url: &str, 78 + ) -> Result<(ProtectedResourceMetadata, AuthorizationServerMetadata), Error> { 79 + let pds_base = pds_url.trim_end_matches('/'); 80 + 81 + // Step 1: Protected Resource Metadata 82 + let prm_url = format!("{pds_base}/.well-known/oauth-protected-resource"); 83 + let prm_response = fetch_json(transport, &prm_url).await?; 84 + let prm: ProtectedResourceMetadata = serde_json::from_slice(&prm_response.body) 85 + .map_err(|e| Error::Auth(format!("invalid protected resource metadata: {e}")))?; 86 + 87 + let as_url = prm.authorization_servers.first().ok_or_else(|| { 88 + Error::Auth("no authorization servers in protected resource metadata".into()) 89 + })?; 90 + 91 + // Step 2: Authorization Server Metadata 92 + let as_base = as_url.trim_end_matches('/'); 93 + let asm_url = format!("{as_base}/.well-known/oauth-authorization-server"); 94 + let asm_response = fetch_json(transport, &asm_url).await?; 95 + let asm: AuthorizationServerMetadata = serde_json::from_slice(&asm_response.body) 96 + .map_err(|e| Error::Auth(format!("invalid authorization server metadata: {e}")))?; 97 + 98 + Ok((prm, asm)) 99 + } 100 + 101 + async fn fetch_json(transport: &impl Transport, url: &str) -> Result<HttpResponse, Error> { 102 + let response = transport 103 + .send(HttpRequest { 104 + method: HttpMethod::Get, 105 + url: url.to_string(), 106 + headers: vec![("Accept".into(), "application/json".into())], 107 + body: None, 108 + }) 109 + .await?; 110 + 111 + if response.status != 200 { 112 + return Err(Error::Auth(format!( 113 + "OAuth discovery failed: GET {url} returned HTTP {}", 114 + response.status 115 + ))); 116 + } 117 + 118 + Ok(response) 119 + } 120 + 121 + // --------------------------------------------------------------------------- 122 + // PKCE (RFC 7636) 123 + // --------------------------------------------------------------------------- 124 + 125 + /// A PKCE code verifier + challenge pair (S256 method). 126 + #[derive(Debug, Clone)] 127 + pub struct PkceChallenge { 128 + /// The raw verifier string (sent with the token exchange). 129 + pub verifier: String, 130 + /// The S256 challenge (sent with the authorization request). 131 + pub challenge: String, 132 + } 133 + 134 + /// Generate a PKCE S256 code challenge from 32 random bytes. 135 + /// 136 + /// verifier = base64url(random_bytes) 137 + /// challenge = base64url(sha256(verifier)) 138 + pub fn generate_pkce(rng: &mut (impl CryptoRng + RngCore)) -> PkceChallenge { 139 + let mut bytes = [0u8; 32]; 140 + rng.fill_bytes(&mut bytes); 141 + 142 + let verifier = BASE64URL.encode(bytes); 143 + let challenge = BASE64URL.encode(Sha256::digest(verifier.as_bytes())); 144 + 145 + PkceChallenge { 146 + verifier, 147 + challenge, 148 + } 149 + } 150 + 151 + #[cfg(test)] 152 + #[path = "oauth_discovery_tests.rs"] 153 + mod tests;
+163
crates/opake-core/src/client/oauth_discovery_tests.rs
··· 1 + use super::*; 2 + use crate::client::HttpResponse; 3 + use crate::crypto::OsRng; 4 + use crate::test_utils::MockTransport; 5 + 6 + fn prm_json(as_url: &str) -> String { 7 + serde_json::json!({ 8 + "resource": "https://pds.example.com", 9 + "authorization_servers": [as_url], 10 + "scopes_supported": ["atproto"], 11 + }) 12 + .to_string() 13 + } 14 + 15 + fn asm_json() -> String { 16 + serde_json::json!({ 17 + "issuer": "https://auth.example.com", 18 + "authorization_endpoint": "https://auth.example.com/authorize", 19 + "token_endpoint": "https://auth.example.com/token", 20 + "pushed_authorization_request_endpoint": "https://auth.example.com/par", 21 + "scopes_supported": ["atproto"], 22 + "response_types_supported": ["code"], 23 + "grant_types_supported": ["authorization_code", "refresh_token"], 24 + "code_challenge_methods_supported": ["S256"], 25 + "dpop_signing_alg_values_supported": ["ES256"], 26 + "token_endpoint_auth_methods_supported": ["none"], 27 + "require_pushed_authorization_requests": true, 28 + }) 29 + .to_string() 30 + } 31 + 32 + #[tokio::test] 33 + async fn discover_fetches_prm_then_asm() { 34 + let mock = MockTransport::new(); 35 + mock.enqueue(HttpResponse { 36 + status: 200, 37 + headers: vec![], 38 + body: prm_json("https://auth.example.com").into_bytes(), 39 + }); 40 + mock.enqueue(HttpResponse { 41 + status: 200, 42 + headers: vec![], 43 + body: asm_json().into_bytes(), 44 + }); 45 + 46 + let (prm, asm) = discover_authorization_server(&mock, "https://pds.example.com") 47 + .await 48 + .unwrap(); 49 + 50 + assert_eq!(prm.resource, "https://pds.example.com"); 51 + assert_eq!(prm.authorization_servers, vec!["https://auth.example.com"]); 52 + assert_eq!(asm.issuer, "https://auth.example.com"); 53 + assert_eq!(asm.token_endpoint, "https://auth.example.com/token"); 54 + assert!(asm.require_pushed_authorization_requests); 55 + 56 + let reqs = mock.requests(); 57 + assert_eq!(reqs.len(), 2); 58 + assert!(reqs[0].url.contains(".well-known/oauth-protected-resource")); 59 + assert!(reqs[1] 60 + .url 61 + .contains(".well-known/oauth-authorization-server")); 62 + } 63 + 64 + #[tokio::test] 65 + async fn discover_strips_trailing_slash() { 66 + let mock = MockTransport::new(); 67 + mock.enqueue(HttpResponse { 68 + status: 200, 69 + headers: vec![], 70 + body: prm_json("https://auth.example.com/").into_bytes(), 71 + }); 72 + mock.enqueue(HttpResponse { 73 + status: 200, 74 + headers: vec![], 75 + body: asm_json().into_bytes(), 76 + }); 77 + 78 + let _ = discover_authorization_server(&mock, "https://pds.example.com/") 79 + .await 80 + .unwrap(); 81 + 82 + let reqs = mock.requests(); 83 + // No double slashes 84 + assert!(!reqs[0].url.contains("//.")); 85 + assert!(!reqs[1].url.contains("//.")); 86 + } 87 + 88 + #[tokio::test] 89 + async fn discover_errors_on_prm_404() { 90 + let mock = MockTransport::new(); 91 + mock.enqueue(HttpResponse { 92 + status: 404, 93 + headers: vec![], 94 + body: b"not found".to_vec(), 95 + }); 96 + 97 + let err = discover_authorization_server(&mock, "https://pds.example.com") 98 + .await 99 + .unwrap_err(); 100 + assert!(err.to_string().contains("HTTP 404")); 101 + } 102 + 103 + #[tokio::test] 104 + async fn discover_errors_on_empty_authorization_servers() { 105 + let mock = MockTransport::new(); 106 + let prm = serde_json::json!({ 107 + "resource": "https://pds.example.com", 108 + "authorization_servers": [], 109 + }); 110 + mock.enqueue(HttpResponse { 111 + status: 200, 112 + headers: vec![], 113 + body: serde_json::to_vec(&prm).unwrap(), 114 + }); 115 + 116 + let err = discover_authorization_server(&mock, "https://pds.example.com") 117 + .await 118 + .unwrap_err(); 119 + assert!(err.to_string().contains("no authorization servers")); 120 + } 121 + 122 + #[tokio::test] 123 + async fn discover_errors_on_asm_failure() { 124 + let mock = MockTransport::new(); 125 + mock.enqueue(HttpResponse { 126 + status: 200, 127 + headers: vec![], 128 + body: prm_json("https://auth.example.com").into_bytes(), 129 + }); 130 + mock.enqueue(HttpResponse { 131 + status: 500, 132 + headers: vec![], 133 + body: b"internal error".to_vec(), 134 + }); 135 + 136 + let err = discover_authorization_server(&mock, "https://pds.example.com") 137 + .await 138 + .unwrap_err(); 139 + assert!(err.to_string().contains("HTTP 500")); 140 + } 141 + 142 + // -- PKCE -- 143 + 144 + #[test] 145 + fn pkce_verifier_is_43_chars() { 146 + // 32 bytes → 43 base64url chars (no padding) 147 + let pkce = generate_pkce(&mut OsRng); 148 + assert_eq!(pkce.verifier.len(), 43); 149 + } 150 + 151 + #[test] 152 + fn pkce_challenge_is_sha256_of_verifier() { 153 + let pkce = generate_pkce(&mut OsRng); 154 + let expected = BASE64URL.encode(Sha256::digest(pkce.verifier.as_bytes())); 155 + assert_eq!(pkce.challenge, expected); 156 + } 157 + 158 + #[test] 159 + fn pkce_verifiers_are_unique() { 160 + let p1 = generate_pkce(&mut OsRng); 161 + let p2 = generate_pkce(&mut OsRng); 162 + assert_ne!(p1.verifier, p2.verifier); 163 + }
+352
crates/opake-core/src/client/oauth_token.rs
··· 1 + // OAuth 2.0 token operations for AT Protocol. 2 + // 3 + // Pushed Authorization Requests (PAR), authorization code exchange, and token 4 + // refresh — all with DPoP proof attachment and `use_dpop_nonce` retry. 5 + #![allow(clippy::too_many_arguments)] 6 + 7 + use log::info; 8 + use serde::Deserialize; 9 + 10 + use crate::crypto::{CryptoRng, RngCore}; 11 + use crate::error::Error; 12 + 13 + use super::dpop::{create_dpop_proof, extract_dpop_nonce, is_use_dpop_nonce_error, DpopKeyPair}; 14 + use super::oauth_discovery::PkceChallenge; 15 + use super::transport::{HttpMethod, HttpRequest, HttpResponse, RequestBody, Transport}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Types 19 + // --------------------------------------------------------------------------- 20 + 21 + /// Response from a Pushed Authorization Request (RFC 9126). 22 + #[derive(Debug, Deserialize)] 23 + pub struct ParResponse { 24 + pub request_uri: String, 25 + pub expires_in: u64, 26 + } 27 + 28 + /// Token response from the authorization server. 29 + #[derive(Debug, Deserialize)] 30 + pub struct TokenResponse { 31 + pub access_token: String, 32 + pub token_type: String, 33 + pub refresh_token: Option<String>, 34 + pub expires_in: Option<u64>, 35 + pub scope: Option<String>, 36 + pub sub: Option<String>, 37 + } 38 + 39 + // --------------------------------------------------------------------------- 40 + // PAR 41 + // --------------------------------------------------------------------------- 42 + 43 + /// Send a Pushed Authorization Request. Returns the `request_uri` to embed 44 + /// in the browser authorization URL. 45 + /// 46 + /// `dpop_nonce` is updated in-place if the AS provides one. 47 + pub async fn pushed_authorization_request( 48 + transport: &impl Transport, 49 + par_endpoint: &str, 50 + client_id: &str, 51 + redirect_uri: &str, 52 + pkce: &PkceChallenge, 53 + scope: &str, 54 + state: &str, 55 + dpop_key: &DpopKeyPair, 56 + dpop_nonce: &mut Option<String>, 57 + timestamp: i64, 58 + rng: &mut (impl CryptoRng + RngCore), 59 + ) -> Result<ParResponse, Error> { 60 + let params = vec![ 61 + ("client_id".into(), client_id.into()), 62 + ("response_type".into(), "code".into()), 63 + ("redirect_uri".into(), redirect_uri.into()), 64 + ("scope".into(), scope.into()), 65 + ("state".into(), state.into()), 66 + ("code_challenge".into(), pkce.challenge.clone()), 67 + ("code_challenge_method".into(), "S256".into()), 68 + ]; 69 + 70 + let response = send_with_dpop_retry( 71 + transport, 72 + par_endpoint, 73 + "POST", 74 + params, 75 + dpop_key, 76 + dpop_nonce, 77 + None, 78 + timestamp, 79 + rng, 80 + ) 81 + .await?; 82 + 83 + if response.status != 200 && response.status != 201 { 84 + return Err(token_error(&response, "PAR request failed")); 85 + } 86 + 87 + serde_json::from_slice(&response.body) 88 + .map_err(|e| Error::Auth(format!("invalid PAR response: {e}"))) 89 + } 90 + 91 + /// Build the authorization URL for the browser redirect. 92 + pub fn build_authorization_url( 93 + authorization_endpoint: &str, 94 + client_id: &str, 95 + request_uri: &str, 96 + ) -> String { 97 + format!( 98 + "{}?client_id={}&request_uri={}", 99 + authorization_endpoint, 100 + urlencoding::encode(client_id), 101 + urlencoding::encode(request_uri), 102 + ) 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // Code exchange 107 + // --------------------------------------------------------------------------- 108 + 109 + /// Exchange an authorization code for tokens. 110 + /// 111 + /// `dpop_nonce` is updated in-place if the AS provides one. 112 + pub async fn exchange_code( 113 + transport: &impl Transport, 114 + token_endpoint: &str, 115 + client_id: &str, 116 + code: &str, 117 + redirect_uri: &str, 118 + pkce_verifier: &str, 119 + dpop_key: &DpopKeyPair, 120 + dpop_nonce: &mut Option<String>, 121 + expected_did: Option<&str>, 122 + timestamp: i64, 123 + rng: &mut (impl CryptoRng + RngCore), 124 + ) -> Result<TokenResponse, Error> { 125 + let params = vec![ 126 + ("grant_type".into(), "authorization_code".into()), 127 + ("client_id".into(), client_id.into()), 128 + ("code".into(), code.into()), 129 + ("redirect_uri".into(), redirect_uri.into()), 130 + ("code_verifier".into(), pkce_verifier.into()), 131 + ]; 132 + 133 + let response = send_with_dpop_retry( 134 + transport, 135 + token_endpoint, 136 + "POST", 137 + params, 138 + dpop_key, 139 + dpop_nonce, 140 + None, 141 + timestamp, 142 + rng, 143 + ) 144 + .await?; 145 + 146 + if response.status != 200 { 147 + return Err(token_error(&response, "token exchange failed")); 148 + } 149 + 150 + let token_response: TokenResponse = serde_json::from_slice(&response.body) 151 + .map_err(|e| Error::Auth(format!("invalid token response: {e}")))?; 152 + 153 + validate_token_response(&token_response, expected_did)?; 154 + Ok(token_response) 155 + } 156 + 157 + // --------------------------------------------------------------------------- 158 + // Refresh 159 + // --------------------------------------------------------------------------- 160 + 161 + /// Refresh an access token using a refresh token. 162 + /// 163 + /// `dpop_nonce` is updated in-place if the AS provides one. 164 + pub async fn refresh_token( 165 + transport: &impl Transport, 166 + token_endpoint: &str, 167 + client_id: &str, 168 + refresh_token_value: &str, 169 + dpop_key: &DpopKeyPair, 170 + dpop_nonce: &mut Option<String>, 171 + timestamp: i64, 172 + rng: &mut (impl CryptoRng + RngCore), 173 + ) -> Result<TokenResponse, Error> { 174 + let params = vec![ 175 + ("grant_type".into(), "refresh_token".into()), 176 + ("client_id".into(), client_id.into()), 177 + ("refresh_token".into(), refresh_token_value.into()), 178 + ]; 179 + 180 + let response = send_with_dpop_retry( 181 + transport, 182 + token_endpoint, 183 + "POST", 184 + params, 185 + dpop_key, 186 + dpop_nonce, 187 + None, 188 + timestamp, 189 + rng, 190 + ) 191 + .await?; 192 + 193 + if response.status != 200 { 194 + return Err(token_error( 195 + &response, 196 + "token refresh failed — run `opake login` again", 197 + )); 198 + } 199 + 200 + let token_response: TokenResponse = serde_json::from_slice(&response.body) 201 + .map_err(|e| Error::Auth(format!("invalid token refresh response: {e}")))?; 202 + 203 + validate_token_response(&token_response, None)?; 204 + Ok(token_response) 205 + } 206 + 207 + // --------------------------------------------------------------------------- 208 + // DPoP retry helper 209 + // --------------------------------------------------------------------------- 210 + 211 + /// Send a form POST with a DPoP proof, retrying once on `use_dpop_nonce`. 212 + /// 213 + /// This implements the server nonce negotiation dance: 214 + /// 1. Send request with DPoP proof (no nonce, or stale nonce) 215 + /// 2. AS responds 400 `use_dpop_nonce` + `DPoP-Nonce` header 216 + /// 3. Re-send with fresh nonce from the header 217 + async fn send_with_dpop_retry( 218 + transport: &impl Transport, 219 + url: &str, 220 + method: &str, 221 + params: Vec<(String, String)>, 222 + dpop_key: &DpopKeyPair, 223 + dpop_nonce: &mut Option<String>, 224 + access_token: Option<&str>, 225 + timestamp: i64, 226 + rng: &mut (impl CryptoRng + RngCore), 227 + ) -> Result<HttpResponse, Error> { 228 + let response = send_dpop_request( 229 + transport, 230 + url, 231 + method, 232 + params.clone(), 233 + dpop_key, 234 + dpop_nonce.as_deref(), 235 + access_token, 236 + timestamp, 237 + rng, 238 + ) 239 + .await?; 240 + 241 + // Capture nonce from every response 242 + if let Some(nonce) = extract_dpop_nonce(&response) { 243 + *dpop_nonce = Some(nonce); 244 + } 245 + 246 + if is_use_dpop_nonce_error(&response) { 247 + info!("AS requested DPoP nonce, retrying"); 248 + 249 + let retry = send_dpop_request( 250 + transport, 251 + url, 252 + method, 253 + params, 254 + dpop_key, 255 + dpop_nonce.as_deref(), 256 + access_token, 257 + timestamp, 258 + rng, 259 + ) 260 + .await?; 261 + 262 + if let Some(nonce) = extract_dpop_nonce(&retry) { 263 + *dpop_nonce = Some(nonce); 264 + } 265 + 266 + return Ok(retry); 267 + } 268 + 269 + Ok(response) 270 + } 271 + 272 + async fn send_dpop_request( 273 + transport: &impl Transport, 274 + url: &str, 275 + method: &str, 276 + params: Vec<(String, String)>, 277 + dpop_key: &DpopKeyPair, 278 + nonce: Option<&str>, 279 + access_token: Option<&str>, 280 + timestamp: i64, 281 + rng: &mut (impl CryptoRng + RngCore), 282 + ) -> Result<HttpResponse, Error> { 283 + let proof = create_dpop_proof(dpop_key, method, url, timestamp, nonce, access_token, rng)?; 284 + 285 + let request = HttpRequest { 286 + method: HttpMethod::Post, 287 + url: url.to_string(), 288 + headers: vec![("DPoP".into(), proof)], 289 + body: Some(RequestBody::Form(params)), 290 + }; 291 + 292 + transport.send(request).await 293 + } 294 + 295 + // --------------------------------------------------------------------------- 296 + // Validation 297 + // --------------------------------------------------------------------------- 298 + 299 + fn validate_token_response( 300 + response: &TokenResponse, 301 + expected_did: Option<&str>, 302 + ) -> Result<(), Error> { 303 + if !response.token_type.eq_ignore_ascii_case("DPoP") { 304 + return Err(Error::Auth(format!( 305 + "expected token_type \"DPoP\", got \"{}\"", 306 + response.token_type 307 + ))); 308 + } 309 + 310 + if let Some(scope) = &response.scope { 311 + if !scope.split_whitespace().any(|s| s == "atproto") { 312 + return Err(Error::Auth(format!( 313 + "token scope missing \"atproto\": \"{scope}\"" 314 + ))); 315 + } 316 + } 317 + 318 + if let Some(did) = expected_did { 319 + if let Some(sub) = &response.sub { 320 + if sub != did { 321 + return Err(Error::Auth(format!( 322 + "token sub \"{sub}\" does not match expected DID \"{did}\"" 323 + ))); 324 + } 325 + } 326 + } 327 + 328 + Ok(()) 329 + } 330 + 331 + fn token_error(response: &HttpResponse, context: &str) -> Error { 332 + #[derive(Deserialize)] 333 + struct OAuthError { 334 + error: Option<String>, 335 + error_description: Option<String>, 336 + } 337 + 338 + let detail = serde_json::from_slice::<OAuthError>(&response.body) 339 + .ok() 340 + .and_then(|e| match (e.error, e.error_description) { 341 + (Some(code), Some(desc)) => Some(format!("{code}: {desc}")), 342 + (Some(code), None) => Some(code), 343 + _ => None, 344 + }) 345 + .unwrap_or_else(|| format!("HTTP {}", response.status)); 346 + 347 + Error::Auth(format!("{context}: {detail}")) 348 + } 349 + 350 + #[cfg(test)] 351 + #[path = "oauth_token_tests.rs"] 352 + mod tests;
+326
crates/opake-core/src/client/oauth_token_tests.rs
··· 1 + use super::*; 2 + use crate::client::HttpResponse; 3 + use crate::crypto::OsRng; 4 + use crate::test_utils::MockTransport; 5 + 6 + fn token_json(sub: &str) -> Vec<u8> { 7 + serde_json::to_vec(&serde_json::json!({ 8 + "access_token": "at_tok_123", 9 + "token_type": "DPoP", 10 + "refresh_token": "rt_tok_456", 11 + "expires_in": 3600, 12 + "scope": "atproto", 13 + "sub": sub, 14 + })) 15 + .unwrap() 16 + } 17 + 18 + fn par_json() -> Vec<u8> { 19 + serde_json::to_vec(&serde_json::json!({ 20 + "request_uri": "urn:ietf:params:oauth:request_uri:abc", 21 + "expires_in": 60, 22 + })) 23 + .unwrap() 24 + } 25 + 26 + fn dpop_key() -> DpopKeyPair { 27 + DpopKeyPair::generate(&mut OsRng) 28 + } 29 + 30 + // -- PAR -- 31 + 32 + #[tokio::test] 33 + async fn par_sends_form_with_dpop_header() { 34 + let mock = MockTransport::new(); 35 + mock.enqueue(HttpResponse { 36 + status: 201, 37 + headers: vec![], 38 + body: par_json(), 39 + }); 40 + 41 + let pkce = super::super::oauth_discovery::PkceChallenge { 42 + verifier: "verifier".into(), 43 + challenge: "challenge".into(), 44 + }; 45 + let key = dpop_key(); 46 + let mut nonce = None; 47 + 48 + let par = pushed_authorization_request( 49 + &mock, 50 + "https://auth.example/par", 51 + "https://opake.app/client-metadata.json", 52 + "http://127.0.0.1:9999/callback", 53 + &pkce, 54 + "atproto", 55 + "state123", 56 + &key, 57 + &mut nonce, 58 + 1700000000, 59 + &mut OsRng, 60 + ) 61 + .await 62 + .unwrap(); 63 + 64 + assert_eq!(par.request_uri, "urn:ietf:params:oauth:request_uri:abc"); 65 + 66 + let reqs = mock.requests(); 67 + assert_eq!(reqs.len(), 1); 68 + assert_eq!(reqs[0].url, "https://auth.example/par"); 69 + assert!(reqs[0].headers.iter().any(|(k, _)| k == "DPoP")); 70 + assert!(matches!(&reqs[0].body, Some(RequestBody::Form(_)))); 71 + } 72 + 73 + // -- build_authorization_url -- 74 + 75 + #[test] 76 + fn authorization_url_encodes_params() { 77 + let url = build_authorization_url( 78 + "https://auth.example/authorize", 79 + "https://opake.app/client-metadata.json", 80 + "urn:ietf:params:oauth:request_uri:abc", 81 + ); 82 + assert!(url.starts_with("https://auth.example/authorize?")); 83 + assert!(url.contains("client_id=")); 84 + assert!(url.contains("request_uri=")); 85 + } 86 + 87 + // -- exchange_code -- 88 + 89 + #[tokio::test] 90 + async fn exchange_code_happy_path() { 91 + let mock = MockTransport::new(); 92 + mock.enqueue(HttpResponse { 93 + status: 200, 94 + headers: vec![], 95 + body: token_json("did:plc:test"), 96 + }); 97 + 98 + let key = dpop_key(); 99 + let mut nonce = None; 100 + 101 + let token = exchange_code( 102 + &mock, 103 + "https://auth.example/token", 104 + "https://opake.app/client-metadata.json", 105 + "auth_code_xyz", 106 + "http://127.0.0.1:9999/callback", 107 + "pkce_verifier", 108 + &key, 109 + &mut nonce, 110 + Some("did:plc:test"), 111 + 1700000000, 112 + &mut OsRng, 113 + ) 114 + .await 115 + .unwrap(); 116 + 117 + assert_eq!(token.access_token, "at_tok_123"); 118 + assert_eq!(token.token_type, "DPoP"); 119 + assert_eq!(token.sub.as_deref(), Some("did:plc:test")); 120 + } 121 + 122 + #[tokio::test] 123 + async fn exchange_code_rejects_wrong_sub() { 124 + let mock = MockTransport::new(); 125 + mock.enqueue(HttpResponse { 126 + status: 200, 127 + headers: vec![], 128 + body: token_json("did:plc:other"), 129 + }); 130 + 131 + let key = dpop_key(); 132 + let mut nonce = None; 133 + 134 + let err = exchange_code( 135 + &mock, 136 + "https://auth.example/token", 137 + "client_id", 138 + "code", 139 + "http://127.0.0.1:9999/callback", 140 + "verifier", 141 + &key, 142 + &mut nonce, 143 + Some("did:plc:expected"), 144 + 1700000000, 145 + &mut OsRng, 146 + ) 147 + .await 148 + .unwrap_err(); 149 + 150 + assert!(err.to_string().contains("does not match")); 151 + } 152 + 153 + #[tokio::test] 154 + async fn exchange_code_rejects_bearer_token_type() { 155 + let mock = MockTransport::new(); 156 + let body = serde_json::to_vec(&serde_json::json!({ 157 + "access_token": "tok", 158 + "token_type": "Bearer", 159 + "scope": "atproto", 160 + })) 161 + .unwrap(); 162 + mock.enqueue(HttpResponse { 163 + status: 200, 164 + headers: vec![], 165 + body, 166 + }); 167 + 168 + let key = dpop_key(); 169 + let mut nonce = None; 170 + 171 + let err = exchange_code( 172 + &mock, 173 + "https://auth.example/token", 174 + "cid", 175 + "code", 176 + "redir", 177 + "verifier", 178 + &key, 179 + &mut nonce, 180 + None, 181 + 1700000000, 182 + &mut OsRng, 183 + ) 184 + .await 185 + .unwrap_err(); 186 + 187 + assert!(err.to_string().contains("DPoP")); 188 + } 189 + 190 + // -- dpop nonce retry -- 191 + 192 + #[tokio::test] 193 + async fn exchange_code_retries_on_use_dpop_nonce() { 194 + let mock = MockTransport::new(); 195 + // First attempt: use_dpop_nonce error with a nonce in the header 196 + mock.enqueue(HttpResponse { 197 + status: 400, 198 + headers: vec![("DPoP-Nonce".into(), "server-nonce-1".into())], 199 + body: br#"{"error":"use_dpop_nonce"}"#.to_vec(), 200 + }); 201 + // Retry succeeds 202 + mock.enqueue(HttpResponse { 203 + status: 200, 204 + headers: vec![("DPoP-Nonce".into(), "server-nonce-1".into())], 205 + body: token_json("did:plc:test"), 206 + }); 207 + 208 + let key = dpop_key(); 209 + let mut nonce = None; 210 + 211 + let token = exchange_code( 212 + &mock, 213 + "https://auth.example/token", 214 + "cid", 215 + "code", 216 + "redir", 217 + "verifier", 218 + &key, 219 + &mut nonce, 220 + None, 221 + 1700000000, 222 + &mut OsRng, 223 + ) 224 + .await 225 + .unwrap(); 226 + 227 + assert_eq!(token.access_token, "at_tok_123"); 228 + assert_eq!(nonce.as_deref(), Some("server-nonce-1")); 229 + assert_eq!(mock.requests().len(), 2); 230 + } 231 + 232 + // -- refresh -- 233 + 234 + #[tokio::test] 235 + async fn refresh_token_happy_path() { 236 + let mock = MockTransport::new(); 237 + mock.enqueue(HttpResponse { 238 + status: 200, 239 + headers: vec![], 240 + body: token_json("did:plc:test"), 241 + }); 242 + 243 + let key = dpop_key(); 244 + let mut nonce = None; 245 + 246 + let token = refresh_token( 247 + &mock, 248 + "https://auth.example/token", 249 + "cid", 250 + "old_refresh_token", 251 + &key, 252 + &mut nonce, 253 + 1700000000, 254 + &mut OsRng, 255 + ) 256 + .await 257 + .unwrap(); 258 + 259 + assert_eq!(token.access_token, "at_tok_123"); 260 + let reqs = mock.requests(); 261 + assert_eq!(reqs.len(), 1); 262 + if let Some(RequestBody::Form(params)) = &reqs[0].body { 263 + assert!(params 264 + .iter() 265 + .any(|(k, v)| k == "grant_type" && v == "refresh_token")); 266 + } else { 267 + panic!("expected form body"); 268 + } 269 + } 270 + 271 + #[tokio::test] 272 + async fn refresh_token_error_suggests_login() { 273 + let mock = MockTransport::new(); 274 + mock.enqueue(HttpResponse { 275 + status: 401, 276 + headers: vec![], 277 + body: br#"{"error":"invalid_grant","error_description":"expired"}"#.to_vec(), 278 + }); 279 + 280 + let key = dpop_key(); 281 + let mut nonce = None; 282 + 283 + let err = refresh_token( 284 + &mock, 285 + "https://auth.example/token", 286 + "cid", 287 + "bad_rt", 288 + &key, 289 + &mut nonce, 290 + 1700000000, 291 + &mut OsRng, 292 + ) 293 + .await 294 + .unwrap_err(); 295 + 296 + assert!(err.to_string().contains("opake login")); 297 + } 298 + 299 + // -- validation -- 300 + 301 + #[test] 302 + fn validate_rejects_missing_atproto_scope() { 303 + let response = TokenResponse { 304 + access_token: "tok".into(), 305 + token_type: "DPoP".into(), 306 + refresh_token: None, 307 + expires_in: None, 308 + scope: Some("email profile".into()), 309 + sub: None, 310 + }; 311 + let err = validate_token_response(&response, None).unwrap_err(); 312 + assert!(err.to_string().contains("atproto")); 313 + } 314 + 315 + #[test] 316 + fn validate_accepts_atproto_among_multiple_scopes() { 317 + let response = TokenResponse { 318 + access_token: "tok".into(), 319 + token_type: "DPoP".into(), 320 + refresh_token: None, 321 + expires_in: None, 322 + scope: Some("atproto transition:generic".into()), 323 + sub: None, 324 + }; 325 + validate_token_response(&response, None).unwrap(); 326 + }
+18 -1
crates/opake-core/src/client/transport.rs
··· 16 16 #[derive(Debug, Clone)] 17 17 pub enum RequestBody { 18 18 Json(serde_json::Value), 19 - Bytes { data: Vec<u8>, content_type: String }, 19 + Bytes { 20 + data: Vec<u8>, 21 + content_type: String, 22 + }, 23 + /// URL-encoded form body (`application/x-www-form-urlencoded`). 24 + /// Used for OAuth token requests. 25 + Form(Vec<(String, String)>), 20 26 } 21 27 22 28 #[derive(Debug, Clone)] ··· 30 36 #[derive(Debug, Clone)] 31 37 pub struct HttpResponse { 32 38 pub status: u16, 39 + pub headers: Vec<(String, String)>, 33 40 pub body: Vec<u8>, 41 + } 42 + 43 + impl HttpResponse { 44 + /// Case-insensitive header lookup. Returns the first matching value. 45 + pub fn header(&self, name: &str) -> Option<&str> { 46 + self.headers 47 + .iter() 48 + .find(|(k, _)| k.eq_ignore_ascii_case(name)) 49 + .map(|(_, v)| v.as_str()) 50 + } 34 51 } 35 52 36 53 /// The only thing a platform needs to provide: send an HTTP request, get bytes back.
+71 -14
crates/opake-core/src/client/xrpc/auth.rs
··· 1 1 use log::{info, warn}; 2 2 3 - use super::{Session, Transport}; 3 + use super::{LegacySession, Session, Transport}; 4 + // dpop re-exports used transitively via XrpcClient methods 5 + use crate::client::oauth_token; 4 6 use crate::client::transport::*; 7 + use crate::crypto::OsRng; 5 8 use crate::error::Error; 6 9 7 10 impl<T: Transport> super::XrpcClient<T> { 8 - /// Authenticate via `com.atproto.server.createSession`. 11 + /// Authenticate via `com.atproto.server.createSession` (legacy password flow). 12 + /// Returns a `Session::Legacy`. 9 13 pub async fn login(&mut self, identifier: &str, password: &str) -> Result<&Session, Error> { 10 14 info!("authenticating as {} against {}", identifier, self.base_url); 11 15 ··· 32 36 ))); 33 37 } 34 38 35 - let session: Session = serde_json::from_slice(&response.body)?; 36 - info!("authenticated as {} ({})", session.handle, session.did); 37 - self.session = Some(session); 39 + let legacy: LegacySession = serde_json::from_slice(&response.body)?; 40 + info!("authenticated as {} ({})", legacy.handle, legacy.did); 41 + self.session = Some(Session::Legacy(legacy)); 38 42 Ok(self.session.as_ref().unwrap()) 39 43 } 40 44 41 - /// Refresh the session using the stored refresh_jwt. 45 + /// Refresh the session — dispatches to legacy or OAuth refresh. 42 46 pub(crate) async fn refresh_session(&mut self) -> Result<(), Error> { 43 - let refresh_jwt = self 47 + let session = self 44 48 .session 45 - .as_ref() 46 - .map(|s| s.refresh_jwt.clone()) 49 + .take() 47 50 .ok_or_else(|| Error::Auth("not logged in".into()))?; 48 51 49 - info!("access token expired, refreshing session"); 52 + match session { 53 + Session::Legacy(s) => self.refresh_legacy(s).await, 54 + Session::OAuth(s) => self.refresh_oauth(s).await, 55 + } 56 + } 57 + 58 + async fn refresh_legacy(&mut self, session: LegacySession) -> Result<(), Error> { 59 + info!("access token expired, refreshing legacy session"); 50 60 51 61 let response = self 52 62 .transport 53 63 .send(HttpRequest { 54 64 method: HttpMethod::Post, 55 65 url: format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url), 56 - headers: vec![("Authorization".into(), format!("Bearer {}", refresh_jwt))], 66 + headers: vec![( 67 + "Authorization".into(), 68 + format!("Bearer {}", session.refresh_jwt), 69 + )], 57 70 body: None, 58 71 }) 59 72 .await?; 60 73 61 74 if response.status != 200 { 62 75 warn!("session refresh failed with HTTP {}", response.status); 76 + // Put the old session back so the user can still try other things 77 + self.session = Some(Session::Legacy(session)); 63 78 return Err(Error::Auth(format!( 64 79 "session refresh failed (HTTP {}) — run `opake login` again", 65 80 response.status 66 81 ))); 67 82 } 68 83 69 - let new_session: Session = serde_json::from_slice(&response.body)?; 70 - info!("session refreshed for {}", new_session.handle); 71 - self.session = Some(new_session); 84 + let new_legacy: LegacySession = serde_json::from_slice(&response.body)?; 85 + info!("session refreshed for {}", new_legacy.handle); 86 + self.session = Some(Session::Legacy(new_legacy)); 72 87 self.session_refreshed = true; 73 88 74 89 Ok(()) 90 + } 91 + 92 + async fn refresh_oauth(&mut self, mut session: super::OAuthSession) -> Result<(), Error> { 93 + info!("access token expired, refreshing OAuth session"); 94 + 95 + let timestamp = super::unix_timestamp(); 96 + let result = oauth_token::refresh_token( 97 + &self.transport, 98 + &session.token_endpoint, 99 + &session.client_id, 100 + &session.refresh_token, 101 + &session.dpop_key, 102 + &mut session.dpop_nonce, 103 + timestamp, 104 + &mut OsRng, 105 + ) 106 + .await; 107 + 108 + match result { 109 + Ok(token_response) => { 110 + session.access_token = token_response.access_token; 111 + if let Some(rt) = token_response.refresh_token { 112 + session.refresh_token = rt; 113 + } 114 + if let Some(expires_in) = token_response.expires_in { 115 + session.expires_at = Some(timestamp + expires_in as i64); 116 + } 117 + info!("OAuth session refreshed for {}", session.handle); 118 + self.session = Some(Session::OAuth(session)); 119 + self.session_refreshed = true; 120 + Ok(()) 121 + } 122 + Err(e) => { 123 + self.session = Some(Session::OAuth(session)); 124 + Err(e) 125 + } 126 + } 127 + } 128 + 129 + /// Set an OAuth session directly (used by the login command after code exchange). 130 + pub fn set_oauth_session(&mut self, session: super::OAuthSession) { 131 + self.session = Some(Session::OAuth(session)); 75 132 } 76 133 }
+20 -21
crates/opake-core/src/client/xrpc/blobs.rs
··· 10 10 /// Upload raw bytes as a blob via `com.atproto.repo.uploadBlob`. 11 11 pub async fn upload_blob(&mut self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, Error> { 12 12 debug!("uploading blob ({} bytes, {})", data.len(), mime_type); 13 - let auth = self.auth_header()?; 13 + 14 + let mut request = HttpRequest { 15 + method: HttpMethod::Post, 16 + url: format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url), 17 + headers: vec![("Content-Type".into(), mime_type.into())], 18 + body: Some(RequestBody::Bytes { 19 + data, 20 + content_type: mime_type.into(), 21 + }), 22 + }; 23 + self.attach_auth(&mut request)?; 14 24 15 - let response = self 16 - .send_checked(HttpRequest { 17 - method: HttpMethod::Post, 18 - url: format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url), 19 - headers: vec![auth, ("Content-Type".into(), mime_type.into())], 20 - body: Some(RequestBody::Bytes { 21 - data, 22 - content_type: mime_type.into(), 23 - }), 24 - }) 25 - .await?; 25 + let response = self.send_checked(request).await?; 26 26 27 27 #[derive(Deserialize)] 28 28 struct UploadResponse { ··· 36 36 /// Fetch a blob by DID + CID via `com.atproto.sync.getBlob`. 37 37 pub async fn get_blob(&mut self, did: &str, cid: &str) -> Result<Vec<u8>, Error> { 38 38 debug!("fetching blob did={} cid={}", did, cid); 39 - let auth = self.auth_header()?; 40 39 let url = format!( 41 40 "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", 42 41 self.base_url, did, cid, 43 42 ); 44 43 45 - let response = self 46 - .send_checked(HttpRequest { 47 - method: HttpMethod::Get, 48 - url, 49 - headers: vec![auth], 50 - body: None, 51 - }) 52 - .await?; 44 + let mut request = HttpRequest { 45 + method: HttpMethod::Get, 46 + url, 47 + headers: vec![], 48 + body: None, 49 + }; 50 + self.attach_auth(&mut request)?; 53 51 52 + let response = self.send_checked(request).await?; 54 53 Ok(response.body) 55 54 } 56 55 }
+149 -16
crates/opake-core/src/client/xrpc/mod.rs
··· 10 10 11 11 use serde::{Deserialize, Serialize}; 12 12 13 + use super::dpop::{create_dpop_proof, extract_dpop_nonce, DpopKeyPair}; 13 14 use super::transport::*; 15 + use crate::crypto::OsRng; 14 16 use crate::error::Error; 15 17 16 18 // --------------------------------------------------------------------------- 17 - // Types 19 + // Session types — discriminated union 18 20 // --------------------------------------------------------------------------- 19 21 20 - /// An authenticated session with a PDS. 22 + /// An authenticated session with a PDS. Either legacy (password-based Bearer 23 + /// tokens) or OAuth (DPoP-bound tokens). 24 + /// 25 + /// Custom deserializer: JSON without a `"type"` field deserializes as `Legacy` 26 + /// for backward compat with existing session.json files. 27 + #[derive(Clone, Debug, Serialize)] 28 + #[serde(tag = "type", rename_all = "camelCase")] 29 + #[allow(clippy::large_enum_variant)] 30 + pub enum Session { 31 + Legacy(LegacySession), 32 + OAuth(OAuthSession), 33 + } 34 + 35 + /// Legacy password-based session (createSession / refreshSession). 21 36 #[derive(Clone, crate::RedactedDebug, Serialize, Deserialize)] 22 37 #[serde(rename_all = "camelCase")] 23 - pub struct Session { 38 + pub struct LegacySession { 24 39 pub did: String, 25 40 pub handle: String, 26 41 #[redact] ··· 29 44 pub refresh_jwt: String, 30 45 } 31 46 47 + /// OAuth 2.0 + DPoP session. 48 + #[derive(Clone, crate::RedactedDebug, Serialize, Deserialize)] 49 + #[serde(rename_all = "camelCase")] 50 + pub struct OAuthSession { 51 + pub did: String, 52 + pub handle: String, 53 + #[redact] 54 + pub access_token: String, 55 + #[redact] 56 + pub refresh_token: String, 57 + pub dpop_key: DpopKeyPair, 58 + pub token_endpoint: String, 59 + #[serde(default)] 60 + pub dpop_nonce: Option<String>, 61 + /// Unix timestamp when the access token expires. 62 + #[serde(default)] 63 + pub expires_at: Option<i64>, 64 + pub client_id: String, 65 + } 66 + 67 + impl Session { 68 + pub fn did(&self) -> &str { 69 + match self { 70 + Session::Legacy(s) => &s.did, 71 + Session::OAuth(s) => &s.did, 72 + } 73 + } 74 + 75 + pub fn handle(&self) -> &str { 76 + match self { 77 + Session::Legacy(s) => &s.handle, 78 + Session::OAuth(s) => &s.handle, 79 + } 80 + } 81 + } 82 + 83 + /// Custom deserializer: if the JSON has a `"type"` field, use it as the tag. 84 + /// If it doesn't (old session.json files), assume Legacy. 85 + impl<'de> Deserialize<'de> for Session { 86 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 87 + where 88 + D: serde::Deserializer<'de>, 89 + { 90 + let value = serde_json::Value::deserialize(deserializer)?; 91 + 92 + match value.get("type").and_then(|t| t.as_str()) { 93 + Some("oauth") => { 94 + let oauth: OAuthSession = 95 + serde_json::from_value(value).map_err(serde::de::Error::custom)?; 96 + Ok(Session::OAuth(oauth)) 97 + } 98 + Some("legacy") | None => { 99 + let legacy: LegacySession = 100 + serde_json::from_value(value).map_err(serde::de::Error::custom)?; 101 + Ok(Session::Legacy(legacy)) 102 + } 103 + Some(other) => Err(serde::de::Error::custom(format!( 104 + "unknown session type: {other}" 105 + ))), 106 + } 107 + } 108 + } 109 + 110 + // --------------------------------------------------------------------------- 111 + // Other record types 112 + // --------------------------------------------------------------------------- 113 + 32 114 /// Reference to a created record. 33 115 #[derive(Debug, Clone, Serialize, Deserialize)] 34 116 pub struct RecordRef { ··· 128 210 self.session_refreshed 129 211 } 130 212 131 - fn auth_header(&self) -> Result<(String, String), Error> { 213 + /// Attach auth headers to a request, dispatching on session variant. 214 + /// Legacy: `Authorization: Bearer <access_jwt>` 215 + /// OAuth: `Authorization: DPoP <access_token>` + `DPoP: <proof>` 216 + fn attach_auth(&mut self, request: &mut HttpRequest) -> Result<(), Error> { 132 217 let session = self 133 218 .session 134 219 .as_ref() 135 220 .ok_or_else(|| Error::Auth("not logged in".into()))?; 136 - Ok(( 137 - "Authorization".into(), 138 - format!("Bearer {}", session.access_jwt), 139 - )) 221 + 222 + match session { 223 + Session::Legacy(s) => { 224 + request 225 + .headers 226 + .push(("Authorization".into(), format!("Bearer {}", s.access_jwt))); 227 + } 228 + Session::OAuth(s) => { 229 + let method = match request.method { 230 + HttpMethod::Get => "GET", 231 + HttpMethod::Post => "POST", 232 + }; 233 + let timestamp = unix_timestamp(); 234 + let proof = create_dpop_proof( 235 + &s.dpop_key, 236 + method, 237 + &request.url, 238 + timestamp, 239 + s.dpop_nonce.as_deref(), 240 + Some(&s.access_token), 241 + &mut OsRng, 242 + )?; 243 + request 244 + .headers 245 + .push(("Authorization".into(), format!("DPoP {}", s.access_token))); 246 + request.headers.push(("DPoP".into(), proof)); 247 + } 248 + } 249 + 250 + Ok(()) 140 251 } 141 252 142 253 fn did(&self) -> Result<&str, Error> { 143 254 self.session 144 255 .as_ref() 145 - .map(|s| s.did.as_str()) 256 + .map(|s| s.did()) 146 257 .ok_or_else(|| Error::Auth("not logged in".into())) 147 258 } 148 259 149 - /// Replace the Authorization header in a request with the current access token. 150 - fn replace_auth_header(&self, mut request: HttpRequest) -> Result<HttpRequest, Error> { 151 - let (key, value) = self.auth_header()?; 152 - if let Some(h) = request.headers.iter_mut().find(|(k, _)| k == &key) { 153 - h.1 = value; 260 + /// Replace auth headers in a request with current credentials. 261 + fn replace_auth_headers(&mut self, mut request: HttpRequest) -> Result<HttpRequest, Error> { 262 + // Remove existing auth headers 263 + request.headers.retain(|(k, _)| { 264 + !k.eq_ignore_ascii_case("authorization") && !k.eq_ignore_ascii_case("dpop") 265 + }); 266 + self.attach_auth(&mut request)?; 267 + Ok(request) 268 + } 269 + 270 + /// Capture DPoP-Nonce from response and update OAuth session if present. 271 + fn update_dpop_nonce(&mut self, response: &HttpResponse) { 272 + if let Some(Session::OAuth(ref mut s)) = self.session { 273 + if let Some(nonce) = extract_dpop_nonce(response) { 274 + if s.dpop_nonce.as_deref() != Some(&nonce) { 275 + s.dpop_nonce = Some(nonce); 276 + self.session_refreshed = true; 277 + } 278 + } 154 279 } 155 - Ok(request) 156 280 } 157 281 158 282 /// Check whether a PDS response is an expired-token error. ··· 179 303 /// and the request is retried once with the new access token. 180 304 async fn send_checked(&mut self, request: HttpRequest) -> Result<HttpResponse, Error> { 181 305 let mut response = self.transport.send(request.clone()).await?; 306 + self.update_dpop_nonce(&response); 182 307 183 308 if Self::is_expired_token(&response) { 184 309 self.refresh_session().await?; 185 - let retried = self.replace_auth_header(request)?; 310 + let retried = self.replace_auth_headers(request)?; 186 311 response = self.transport.send(retried).await?; 312 + self.update_dpop_nonce(&response); 187 313 } 188 314 189 315 check_response(&response)?; 190 316 Ok(response) 191 317 } 318 + } 319 + 320 + fn unix_timestamp() -> i64 { 321 + std::time::SystemTime::now() 322 + .duration_since(std::time::UNIX_EPOCH) 323 + .expect("system clock before UNIX epoch") 324 + .as_secs() as i64 192 325 } 193 326 194 327 #[cfg(test)]
+52 -47
crates/opake-core/src/client/xrpc/repo.rs
··· 5 5 use crate::client::transport::*; 6 6 use crate::error::Error; 7 7 8 + /// Inject `$type` into a serialized record value. 9 + fn record_with_type<R: Serialize>(collection: &str, record: &R) -> serde_json::Value { 10 + let mut value = serde_json::to_value(record).expect("record must be serializable"); 11 + if let serde_json::Value::Object(ref mut map) = value { 12 + map.insert("$type".into(), collection.into()); 13 + } 14 + value 15 + } 16 + 8 17 impl<T: Transport> super::XrpcClient<T> { 9 18 /// Create a record via `com.atproto.repo.createRecord`. 10 19 pub async fn create_record<R: Serialize>( ··· 13 22 record: &R, 14 23 ) -> Result<RecordRef, Error> { 15 24 debug!("creating record in {}", collection); 16 - let auth = self.auth_header()?; 17 - let did = self.did()?; 25 + let did = self.did()?.to_owned(); 18 26 19 27 let body = serde_json::json!({ 20 28 "repo": did, 21 29 "collection": collection, 22 - "record": record, 30 + "record": record_with_type(collection, record), 23 31 }); 24 32 25 - let response = self 26 - .send_checked(HttpRequest { 27 - method: HttpMethod::Post, 28 - url: format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url), 29 - headers: vec![auth, ("Content-Type".into(), "application/json".into())], 30 - body: Some(RequestBody::Json(body)), 31 - }) 32 - .await?; 33 + let mut request = HttpRequest { 34 + method: HttpMethod::Post, 35 + url: format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url), 36 + headers: vec![("Content-Type".into(), "application/json".into())], 37 + body: Some(RequestBody::Json(body)), 38 + }; 39 + self.attach_auth(&mut request)?; 33 40 41 + let response = self.send_checked(request).await?; 34 42 Ok(serde_json::from_slice(&response.body)?) 35 43 } 36 44 ··· 45 53 record: &R, 46 54 ) -> Result<RecordRef, Error> { 47 55 debug!("putting record {}/{}", collection, rkey); 48 - let auth = self.auth_header()?; 49 - let did = self.did()?; 56 + let did = self.did()?.to_owned(); 50 57 51 58 let body = serde_json::json!({ 52 59 "repo": did, 53 60 "collection": collection, 54 61 "rkey": rkey, 55 - "record": record, 62 + "record": record_with_type(collection, record), 56 63 }); 57 64 58 - let response = self 59 - .send_checked(HttpRequest { 60 - method: HttpMethod::Post, 61 - url: format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url), 62 - headers: vec![auth, ("Content-Type".into(), "application/json".into())], 63 - body: Some(RequestBody::Json(body)), 64 - }) 65 - .await?; 65 + let mut request = HttpRequest { 66 + method: HttpMethod::Post, 67 + url: format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url), 68 + headers: vec![("Content-Type".into(), "application/json".into())], 69 + body: Some(RequestBody::Json(body)), 70 + }; 71 + self.attach_auth(&mut request)?; 66 72 73 + let response = self.send_checked(request).await?; 67 74 Ok(serde_json::from_slice(&response.body)?) 68 75 } 69 76 ··· 75 82 rkey: &str, 76 83 ) -> Result<RecordEntry, Error> { 77 84 debug!("getting record {}/{}/{}", did, collection, rkey); 78 - let auth = self.auth_header()?; 79 85 let url = format!( 80 86 "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 81 87 self.base_url, did, collection, rkey, 82 88 ); 83 89 84 - let response = self 85 - .send_checked(HttpRequest { 86 - method: HttpMethod::Get, 87 - url, 88 - headers: vec![auth], 89 - body: None, 90 - }) 91 - .await?; 90 + let mut request = HttpRequest { 91 + method: HttpMethod::Get, 92 + url, 93 + headers: vec![], 94 + body: None, 95 + }; 96 + self.attach_auth(&mut request)?; 92 97 98 + let response = self.send_checked(request).await?; 93 99 Ok(serde_json::from_slice(&response.body)?) 94 100 } 95 101 ··· 101 107 cursor: Option<&str>, 102 108 ) -> Result<RecordPage, Error> { 103 109 debug!("listing records in {}", collection); 104 - let auth = self.auth_header()?; 105 - let did = self.did()?; 110 + let did = self.did()?.to_owned(); 106 111 107 112 let mut url = format!( 108 113 "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}", ··· 115 120 url.push_str(&format!("&cursor={}", cursor)); 116 121 } 117 122 118 - let response = self 119 - .send_checked(HttpRequest { 120 - method: HttpMethod::Get, 121 - url, 122 - headers: vec![auth], 123 - body: None, 124 - }) 125 - .await?; 123 + let mut request = HttpRequest { 124 + method: HttpMethod::Get, 125 + url, 126 + headers: vec![], 127 + body: None, 128 + }; 129 + self.attach_auth(&mut request)?; 126 130 131 + let response = self.send_checked(request).await?; 127 132 Ok(serde_json::from_slice(&response.body)?) 128 133 } 129 134 130 135 /// Delete a record via `com.atproto.repo.deleteRecord`. 131 136 pub async fn delete_record(&mut self, collection: &str, rkey: &str) -> Result<(), Error> { 132 137 debug!("deleting record {}/{}", collection, rkey); 133 - let auth = self.auth_header()?; 134 - let did = self.did()?; 138 + let did = self.did()?.to_owned(); 135 139 136 140 let body = serde_json::json!({ 137 141 "repo": did, ··· 139 143 "rkey": rkey, 140 144 }); 141 145 142 - self.send_checked(HttpRequest { 146 + let mut request = HttpRequest { 143 147 method: HttpMethod::Post, 144 148 url: format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url), 145 - headers: vec![auth, ("Content-Type".into(), "application/json".into())], 149 + headers: vec![("Content-Type".into(), "application/json".into())], 146 150 body: Some(RequestBody::Json(body)), 147 - }) 148 - .await?; 151 + }; 152 + self.attach_auth(&mut request)?; 149 153 154 + self.send_checked(request).await?; 150 155 Ok(()) 151 156 } 152 157 }
+15 -4
crates/opake-core/src/client/xrpc/xrpc_tests.rs
··· 4 4 fn response(status: u16, body: &str) -> HttpResponse { 5 5 HttpResponse { 6 6 status, 7 + headers: vec![], 7 8 body: body.as_bytes().to_vec(), 8 9 } 9 10 } ··· 135 136 fn expired_token_response() -> HttpResponse { 136 137 HttpResponse { 137 138 status: 400, 139 + headers: vec![], 138 140 body: br#"{"error":"ExpiredToken","message":"Token has expired"}"#.to_vec(), 139 141 } 140 142 } ··· 148 150 }); 149 151 HttpResponse { 150 152 status: 200, 153 + headers: vec![], 151 154 body: serde_json::to_vec(&body).unwrap(), 152 155 } 153 156 } ··· 155 158 fn success_response(body: &str) -> HttpResponse { 156 159 HttpResponse { 157 160 status: 200, 161 + headers: vec![], 158 162 body: body.as_bytes().to_vec(), 159 163 } 160 164 } 161 165 162 166 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 163 - let session = Session { 167 + let session = Session::Legacy(LegacySession { 164 168 did: "did:plc:test".into(), 165 169 handle: "test.handle".into(), 166 170 access_jwt: "stale-access-jwt".into(), 167 171 refresh_jwt: "valid-refresh-jwt".into(), 168 - }; 172 + }); 169 173 XrpcClient::with_session(mock, "https://pds.test".into(), session) 170 174 } 171 175 ··· 189 193 assert!(client.session_refreshed()); 190 194 191 195 let session = client.session().unwrap(); 192 - assert_eq!(session.access_jwt, "fresh-access-jwt"); 193 - assert_eq!(session.refresh_jwt, "fresh-refresh-jwt"); 196 + match session { 197 + Session::Legacy(s) => { 198 + assert_eq!(s.access_jwt, "fresh-access-jwt"); 199 + assert_eq!(s.refresh_jwt, "fresh-refresh-jwt"); 200 + } 201 + _ => panic!("expected Legacy session after refresh"), 202 + } 194 203 195 204 // Verify: 3 requests — original, refresh, retry 196 205 let reqs = mock.requests(); ··· 215 224 // Refresh fails 216 225 mock.enqueue(HttpResponse { 217 226 status: 401, 227 + headers: vec![], 218 228 body: br#"{"error":"InvalidToken","message":"bad refresh token"}"#.to_vec(), 219 229 }); 220 230 ··· 234 244 let mock = MockTransport::new(); 235 245 mock.enqueue(HttpResponse { 236 246 status: 500, 247 + headers: vec![], 237 248 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 238 249 }); 239 250
+1
crates/opake-core/src/directories/create.rs
··· 64 64 let mock = MockTransport::new(); 65 65 mock.enqueue(crate::client::HttpResponse { 66 66 status: 500, 67 + headers: vec![], 67 68 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 68 69 }); 69 70
+2
crates/opake-core/src/directories/delete.rs
··· 71 71 mock.enqueue(get_record_response(&uri, &directory)); 72 72 mock.enqueue(HttpResponse { 73 73 status: 200, 74 + headers: vec![], 74 75 body: b"{}".to_vec(), 75 76 }); 76 77 ··· 135 136 let mock = MockTransport::new(); 136 137 mock.enqueue(HttpResponse { 137 138 status: 404, 139 + headers: vec![], 138 140 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 139 141 }); 140 142
+1
crates/opake-core/src/directories/get_or_create_root.rs
··· 102 102 let mock = MockTransport::new(); 103 103 mock.enqueue(crate::client::HttpResponse { 104 104 status: 500, 105 + headers: vec![], 105 106 body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 106 107 }); 107 108
+1
crates/opake-core/src/directories/list.rs
··· 137 137 let mock = MockTransport::new(); 138 138 mock.enqueue(HttpResponse { 139 139 status: 500, 140 + headers: vec![], 140 141 body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 141 142 }); 142 143
+8 -3
crates/opake-core/src/directories/mod.rs
··· 28 28 29 29 #[cfg(test)] 30 30 pub(crate) mod tests { 31 - use crate::client::{HttpResponse, Session, XrpcClient}; 31 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 32 32 use crate::records::Directory; 33 33 use crate::test_utils::MockTransport; 34 34 ··· 37 37 pub const TEST_DID: &str = "did:plc:test"; 38 38 39 39 pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 40 - let session = Session { 40 + let session = Session::Legacy(LegacySession { 41 41 did: TEST_DID.into(), 42 42 handle: "test.handle".into(), 43 43 access_jwt: "test-jwt".into(), 44 44 refresh_jwt: "test-refresh".into(), 45 - }; 45 + }); 46 46 XrpcClient::with_session(mock, "https://pds.test".into(), session) 47 47 } 48 48 ··· 60 60 pub fn create_record_response(uri: &str) -> HttpResponse { 61 61 HttpResponse { 62 62 status: 200, 63 + headers: vec![], 63 64 body: serde_json::to_vec(&serde_json::json!({ 64 65 "uri": uri, 65 66 "cid": "bafydirectory", ··· 71 72 pub fn put_record_response(uri: &str) -> HttpResponse { 72 73 HttpResponse { 73 74 status: 200, 75 + headers: vec![], 74 76 body: serde_json::to_vec(&serde_json::json!({ 75 77 "uri": uri, 76 78 "cid": "bafyupdated", ··· 82 84 pub fn get_record_response(uri: &str, directory: &Directory) -> HttpResponse { 83 85 HttpResponse { 84 86 status: 200, 87 + headers: vec![], 85 88 body: serde_json::to_vec(&serde_json::json!({ 86 89 "uri": uri, 87 90 "cid": "bafydirectory", ··· 113 116 114 117 HttpResponse { 115 118 status: 200, 119 + headers: vec![], 116 120 body: serde_json::to_vec(&body).unwrap(), 117 121 } 118 122 } ··· 120 124 pub fn not_found_response() -> HttpResponse { 121 125 HttpResponse { 122 126 status: 404, 127 + headers: vec![], 123 128 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 124 129 } 125 130 }
+1
crates/opake-core/src/directories/mv_tests.rs
··· 12 12 fn record_response<T: serde::Serialize>(uri: &str, record: &T) -> HttpResponse { 13 13 HttpResponse { 14 14 status: 200, 15 + headers: vec![], 15 16 body: serde_json::to_vec(&serde_json::json!({ 16 17 "uri": uri, 17 18 "cid": "bafyrecord",
+2
crates/opake-core/src/directories/remove_tests.rs
··· 17 17 fn delete_ok() -> HttpResponse { 18 18 HttpResponse { 19 19 status: 200, 20 + headers: vec![], 20 21 body: b"{}".to_vec(), 21 22 } 22 23 } ··· 26 27 let doc = dummy_document(name, 100, vec![]); 27 28 HttpResponse { 28 29 status: 200, 30 + headers: vec![], 29 31 body: serde_json::to_vec(&serde_json::json!({ 30 32 "uri": uri, 31 33 "cid": "bafydocument",
+3
crates/opake-core/src/directories/tree_tests.rs
··· 17 17 let doc = dummy_document(name, 100, vec![]); 18 18 HttpResponse { 19 19 status: 200, 20 + headers: vec![], 20 21 body: serde_json::to_vec(&serde_json::json!({ 21 22 "uri": uri, 22 23 "cid": "bafydocument", ··· 96 97 let mock = MockTransport::new(); 97 98 mock.enqueue(HttpResponse { 98 99 status: 500, 100 + headers: vec![], 99 101 body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 100 102 }); 101 103 ··· 155 157 // getRecord 404 for unknown document 156 158 mock.enqueue(HttpResponse { 157 159 status: 404, 160 + headers: vec![], 158 161 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 159 162 }); 160 163
+3
crates/opake-core/src/documents/delete.rs
··· 44 44 let mock = MockTransport::new(); 45 45 mock.enqueue(HttpResponse { 46 46 status: 200, 47 + headers: vec![], 47 48 body: b"{}".to_vec(), 48 49 }); 49 50 ··· 102 103 let mock = MockTransport::new(); 103 104 mock.enqueue(HttpResponse { 104 105 status: 404, 106 + headers: vec![], 105 107 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 106 108 }); 107 109 ··· 116 118 let mock = MockTransport::new(); 117 119 mock.enqueue(HttpResponse { 118 120 status: 500, 121 + headers: vec![], 119 122 body: br#"{"error":"InternalServerError","message":"storage error"}"#.to_vec(), 120 123 }); 121 124
+9 -1
crates/opake-core/src/documents/download.rs
··· 231 231 "value": doc, 232 232 })) 233 233 .unwrap(); 234 - HttpResponse { status: 200, body } 234 + HttpResponse { 235 + status: 200, 236 + headers: vec![], 237 + body, 238 + } 235 239 } 236 240 237 241 fn blob_response(data: &[u8]) -> HttpResponse { 238 242 HttpResponse { 239 243 status: 200, 244 + headers: vec![], 240 245 body: data.to_vec(), 241 246 } 242 247 } ··· 333 338 let mock = MockTransport::new(); 334 339 mock.enqueue(HttpResponse { 335 340 status: 200, 341 + headers: vec![], 336 342 body: serde_json::to_vec(&doc_value).unwrap(), 337 343 }); 338 344 ··· 349 355 let mock = MockTransport::new(); 350 356 mock.enqueue(HttpResponse { 351 357 status: 404, 358 + headers: vec![], 352 359 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 353 360 }); 354 361 ··· 370 377 mock.enqueue(record_response(&doc)); 371 378 mock.enqueue(HttpResponse { 372 379 status: 500, 380 + headers: vec![], 373 381 body: br#"{"error":"InternalServerError","message":"blob storage error"}"#.to_vec(), 374 382 }); 375 383
+8 -1
crates/opake-core/src/documents/download_grant.rs
··· 128 128 }); 129 129 HttpResponse { 130 130 status: 200, 131 + headers: vec![], 131 132 body: serde_json::to_vec(&body).unwrap(), 132 133 } 133 134 } ··· 140 141 }); 141 142 HttpResponse { 142 143 status: 200, 144 + headers: vec![], 143 145 body: serde_json::to_vec(&body).unwrap(), 144 146 } 145 147 } ··· 151 153 "value": doc, 152 154 })) 153 155 .unwrap(); 154 - HttpResponse { status: 200, body } 156 + HttpResponse { 157 + status: 200, 158 + headers: vec![], 159 + body, 160 + } 155 161 } 156 162 157 163 fn blob_response(data: &[u8]) -> HttpResponse { 158 164 HttpResponse { 159 165 status: 200, 166 + headers: vec![], 160 167 body: data.to_vec(), 161 168 } 162 169 }
+3
crates/opake-core/src/documents/download_keyring_tests.rs
··· 24 24 }); 25 25 HttpResponse { 26 26 status: 200, 27 + headers: vec![], 27 28 body: serde_json::to_vec(&body).unwrap(), 28 29 } 29 30 } ··· 36 37 }); 37 38 HttpResponse { 38 39 status: 200, 40 + headers: vec![], 39 41 body: serde_json::to_vec(&body).unwrap(), 40 42 } 41 43 } ··· 43 45 fn blob_response(data: &[u8]) -> HttpResponse { 44 46 HttpResponse { 45 47 status: 200, 48 + headers: vec![], 46 49 body: data.to_vec(), 47 50 } 48 51 }
+2
crates/opake-core/src/documents/list.rs
··· 143 143 let mock = MockTransport::new(); 144 144 mock.enqueue(HttpResponse { 145 145 status: 200, 146 + headers: vec![], 146 147 body: serde_json::to_vec(&body).unwrap(), 147 148 }); 148 149 ··· 171 172 let mock = MockTransport::new(); 172 173 mock.enqueue(HttpResponse { 173 174 status: 500, 175 + headers: vec![], 174 176 body: br#"{"error":"InternalServerError","message":"something broke"}"#.to_vec(), 175 177 }); 176 178
+4 -3
crates/opake-core/src/documents/mod.rs
··· 29 29 pub(crate) mod tests { 30 30 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 31 31 32 - use crate::client::{HttpResponse, Session, XrpcClient}; 32 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 33 33 use crate::records::{ 34 34 AtBytes, BlobRef, CidLink, DirectEncryption, Document, Encryption, EncryptionEnvelope, 35 35 WrappedKey, ··· 40 40 pub const TEST_URI: &str = "at://did:plc:test/app.opake.cloud.document/abc123"; 41 41 42 42 pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 43 - let session = Session { 43 + let session = Session::Legacy(LegacySession { 44 44 did: TEST_DID.into(), 45 45 handle: "test.handle".into(), 46 46 access_jwt: "test-jwt".into(), 47 47 refresh_jwt: "test-refresh".into(), 48 - }; 48 + }); 49 49 XrpcClient::with_session(mock, "https://pds.test".into(), session) 50 50 } 51 51 ··· 104 104 105 105 HttpResponse { 106 106 status: 200, 107 + headers: vec![], 107 108 body: serde_json::to_vec(&body).unwrap(), 108 109 } 109 110 }
+4
crates/opake-core/src/documents/upload.rs
··· 198 198 }); 199 199 HttpResponse { 200 200 status: 200, 201 + headers: vec![], 201 202 body: serde_json::to_vec(&body).unwrap(), 202 203 } 203 204 } ··· 210 211 }); 211 212 HttpResponse { 212 213 status: 200, 214 + headers: vec![], 213 215 body: serde_json::to_vec(&body).unwrap(), 214 216 } 215 217 } ··· 316 318 let mock = MockTransport::new(); 317 319 mock.enqueue(HttpResponse { 318 320 status: 500, 321 + headers: vec![], 319 322 body: br#"{"error":"InternalServerError","message":"blob storage down"}"#.to_vec(), 320 323 }); 321 324 ··· 335 338 mock.enqueue(upload_blob_response()); 336 339 mock.enqueue(HttpResponse { 337 340 status: 500, 341 + headers: vec![], 338 342 body: br#"{"error":"InternalServerError","message":"record write failed"}"#.to_vec(), 339 343 }); 340 344
+5 -3
crates/opake-core/src/keyrings/add_member.rs
··· 53 53 #[cfg(test)] 54 54 mod tests { 55 55 use super::*; 56 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 56 + use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 57 57 use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 58 58 use crate::records::{AtBytes, Keyring, WrappedKey, SCHEMA_VERSION}; 59 59 use crate::test_utils::MockTransport; ··· 62 62 const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 63 63 64 64 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 65 - let session = Session { 65 + let session = Session::Legacy(LegacySession { 66 66 did: TEST_DID.into(), 67 67 handle: "owner.test".into(), 68 68 access_jwt: "test-jwt".into(), 69 69 refresh_jwt: "test-refresh".into(), 70 - }; 70 + }); 71 71 XrpcClient::with_session(mock, "https://pds.test".into(), session) 72 72 } 73 73 ··· 100 100 fn get_record_response(keyring: &Keyring) -> HttpResponse { 101 101 HttpResponse { 102 102 status: 200, 103 + headers: vec![], 103 104 body: serde_json::to_vec(&serde_json::json!({ 104 105 "uri": KEYRING_URI, 105 106 "cid": "bafykeyring", ··· 112 113 fn put_record_response() -> HttpResponse { 113 114 HttpResponse { 114 115 status: 200, 116 + headers: vec![], 115 117 body: serde_json::to_vec(&serde_json::json!({ 116 118 "uri": KEYRING_URI, 117 119 "cid": "bafyupdated",
+5 -3
crates/opake-core/src/keyrings/create.rs
··· 43 43 #[cfg(test)] 44 44 mod tests { 45 45 use super::*; 46 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 46 + use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 47 47 use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 48 48 use crate::records::Keyring; 49 49 use crate::test_utils::MockTransport; ··· 51 51 const TEST_DID: &str = "did:plc:owner"; 52 52 53 53 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 54 - let session = Session { 54 + let session = Session::Legacy(LegacySession { 55 55 did: TEST_DID.into(), 56 56 handle: "owner.test".into(), 57 57 access_jwt: "test-jwt".into(), 58 58 refresh_jwt: "test-refresh".into(), 59 - }; 59 + }); 60 60 XrpcClient::with_session(mock, "https://pds.test".into(), session) 61 61 } 62 62 ··· 69 69 fn create_record_response(uri: &str) -> HttpResponse { 70 70 HttpResponse { 71 71 status: 200, 72 + headers: vec![], 72 73 body: serde_json::to_vec(&serde_json::json!({ 73 74 "uri": uri, 74 75 "cid": "bafykeyring", ··· 127 128 let mock = MockTransport::new(); 128 129 mock.enqueue(HttpResponse { 129 130 status: 500, 131 + headers: vec![], 130 132 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 131 133 }); 132 134
+4 -3
crates/opake-core/src/keyrings/list.rs
··· 33 33 #[cfg(test)] 34 34 mod tests { 35 35 use super::*; 36 - use crate::client::{HttpResponse, Session, XrpcClient}; 36 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 37 37 use crate::records::{self, AtBytes, Keyring, WrappedKey}; 38 38 use crate::test_utils::MockTransport; 39 39 40 40 const TEST_DID: &str = "did:plc:owner"; 41 41 42 42 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 43 - let session = Session { 43 + let session = Session::Legacy(LegacySession { 44 44 did: TEST_DID.into(), 45 45 handle: "owner.test".into(), 46 46 access_jwt: "test-jwt".into(), 47 47 refresh_jwt: "test-refresh".into(), 48 - }; 48 + }); 49 49 XrpcClient::with_session(mock, "https://pds.test".into(), session) 50 50 } 51 51 ··· 92 92 93 93 HttpResponse { 94 94 status: 200, 95 + headers: vec![], 95 96 body: serde_json::to_vec(&body).unwrap(), 96 97 } 97 98 }
+5 -3
crates/opake-core/src/keyrings/remove_member_tests.rs
··· 1 1 use super::*; 2 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 2 + use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 3 3 use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 4 4 use crate::records::{AtBytes, Keyring, WrappedKey}; 5 5 use crate::test_utils::MockTransport; ··· 8 8 const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 9 9 10 10 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 11 - let session = Session { 11 + let session = Session::Legacy(LegacySession { 12 12 did: TEST_DID.into(), 13 13 handle: "owner.test".into(), 14 14 access_jwt: "test-jwt".into(), 15 15 refresh_jwt: "test-refresh".into(), 16 - }; 16 + }); 17 17 XrpcClient::with_session(mock, "https://pds.test".into(), session) 18 18 } 19 19 ··· 49 49 fn get_record_response(keyring: &Keyring) -> HttpResponse { 50 50 HttpResponse { 51 51 status: 200, 52 + headers: vec![], 52 53 body: serde_json::to_vec(&serde_json::json!({ 53 54 "uri": KEYRING_URI, 54 55 "cid": "bafykeyring", ··· 61 62 fn put_record_response() -> HttpResponse { 62 63 HttpResponse { 63 64 status: 200, 65 + headers: vec![], 64 66 body: serde_json::to_vec(&serde_json::json!({ 65 67 "uri": KEYRING_URI, 66 68 "cid": "bafyrotated",
+4 -2
crates/opake-core/src/resolve.rs
··· 140 140 fn success(body: &str) -> HttpResponse { 141 141 HttpResponse { 142 142 status: 200, 143 + headers: vec![], 143 144 body: body.as_bytes().to_vec(), 144 145 } 145 146 } ··· 234 235 ))); 235 236 mock.enqueue(HttpResponse { 236 237 status: 404, 238 + headers: vec![], 237 239 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 238 240 }); 239 241 ··· 278 280 }); 279 281 mock.enqueue(success(&put_response.to_string())); 280 282 281 - let session = crate::client::Session { 283 + let session = crate::client::Session::Legacy(crate::client::LegacySession { 282 284 did: "did:plc:test".into(), 283 285 handle: "test.handle".into(), 284 286 access_jwt: "test-jwt".into(), 285 287 refresh_jwt: "test-refresh".into(), 286 - }; 288 + }); 287 289 let mut client = XrpcClient::with_session(mock.clone(), "https://pds.test".into(), session); 288 290 289 291 let signing_key = [88u8; 32];
+4 -3
crates/opake-core/src/sharing/create.rs
··· 51 51 #[cfg(test)] 52 52 mod tests { 53 53 use super::*; 54 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 54 + use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 55 55 use crate::crypto::{generate_content_key, OsRng}; 56 56 use crate::test_utils::MockTransport; 57 57 58 58 const TEST_DID: &str = "did:plc:owner"; 59 59 60 60 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 61 - let session = Session { 61 + let session = Session::Legacy(LegacySession { 62 62 did: TEST_DID.into(), 63 63 handle: "owner.test".into(), 64 64 access_jwt: "test-jwt".into(), 65 65 refresh_jwt: "test-refresh".into(), 66 - }; 66 + }); 67 67 XrpcClient::with_session(mock, "https://pds.test".into(), session) 68 68 } 69 69 70 70 fn create_record_response(uri: &str) -> HttpResponse { 71 71 HttpResponse { 72 72 status: 200, 73 + headers: vec![], 73 74 body: serde_json::to_vec(&serde_json::json!({ 74 75 "uri": uri, 75 76 "cid": "bafygrant",
+6 -3
crates/opake-core/src/sharing/list.rs
··· 35 35 #[cfg(test)] 36 36 mod tests { 37 37 use super::*; 38 - use crate::client::{HttpResponse, Session, XrpcClient}; 38 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 39 39 use crate::records::{self, AtBytes, Grant, WrappedKey}; 40 40 use crate::test_utils::MockTransport; 41 41 42 42 const TEST_DID: &str = "did:plc:owner"; 43 43 44 44 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 45 - let session = Session { 45 + let session = Session::Legacy(LegacySession { 46 46 did: TEST_DID.into(), 47 47 handle: "owner.test".into(), 48 48 access_jwt: "test-jwt".into(), 49 49 refresh_jwt: "test-refresh".into(), 50 - }; 50 + }); 51 51 XrpcClient::with_session(mock, "https://pds.test".into(), session) 52 52 } 53 53 ··· 89 89 90 90 HttpResponse { 91 91 status: 200, 92 + headers: vec![], 92 93 body: serde_json::to_vec(&body).unwrap(), 93 94 } 94 95 } ··· 186 187 let mock = MockTransport::new(); 187 188 mock.enqueue(HttpResponse { 188 189 status: 200, 190 + headers: vec![], 189 191 body: serde_json::to_vec(&body).unwrap(), 190 192 }); 191 193 ··· 214 216 let mock = MockTransport::new(); 215 217 mock.enqueue(HttpResponse { 216 218 status: 500, 219 + headers: vec![], 217 220 body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 218 221 }); 219 222
+5 -3
crates/opake-core/src/sharing/revoke.rs
··· 30 30 #[cfg(test)] 31 31 mod tests { 32 32 use super::*; 33 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 33 + use crate::client::{HttpResponse, LegacySession, RequestBody, Session, XrpcClient}; 34 34 use crate::test_utils::MockTransport; 35 35 36 36 const TEST_DID: &str = "did:plc:owner"; 37 37 38 38 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 39 - let session = Session { 39 + let session = Session::Legacy(LegacySession { 40 40 did: TEST_DID.into(), 41 41 handle: "owner.test".into(), 42 42 access_jwt: "test-jwt".into(), 43 43 refresh_jwt: "test-refresh".into(), 44 - }; 44 + }); 45 45 XrpcClient::with_session(mock, "https://pds.test".into(), session) 46 46 } 47 47 ··· 50 50 let mock = MockTransport::new(); 51 51 mock.enqueue(HttpResponse { 52 52 status: 200, 53 + headers: vec![], 53 54 body: b"{}".to_vec(), 54 55 }); 55 56 ··· 96 97 let mock = MockTransport::new(); 97 98 mock.enqueue(HttpResponse { 98 99 status: 404, 100 + headers: vec![], 99 101 body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 100 102 }); 101 103
+4
crates/opake-core/src/test_utils.rs
··· 74 74 let mock = MockTransport::new(); 75 75 mock.enqueue(HttpResponse { 76 76 status: 200, 77 + headers: vec![], 77 78 body: b"first".to_vec(), 78 79 }); 79 80 mock.enqueue(HttpResponse { 80 81 status: 201, 82 + headers: vec![], 81 83 body: b"second".to_vec(), 82 84 }); 83 85 ··· 95 97 let mock = MockTransport::new(); 96 98 mock.enqueue(HttpResponse { 97 99 status: 200, 100 + headers: vec![], 98 101 body: vec![], 99 102 }); 100 103 mock.enqueue(HttpResponse { 101 104 status: 200, 105 + headers: vec![], 102 106 body: vec![], 103 107 }); 104 108
+25 -4
docs/ARCHITECTURE.md
··· 74 74 transport.rs Transport trait (HTTP abstraction for WASM compat) 75 75 did.rs Unauthenticated DID resolution and cross-PDS queries 76 76 list.rs Generic paginated collection fetcher 77 + dpop.rs DPoP keypair (P-256/ES256) + proof JWT generation 78 + oauth_discovery.rs OAuth AS discovery + PKCE S256 generation 79 + oauth_token.rs PAR, authorization code exchange, token refresh (all with DPoP) 77 80 xrpc/ 78 - mod.rs XrpcClient struct, Session, response types, check_response() 79 - auth.rs login(), refresh_session() 81 + mod.rs XrpcClient struct, Session enum (Legacy/OAuth), dual auth dispatch 82 + auth.rs login(), refresh_session() (legacy + OAuth) 80 83 blobs.rs upload_blob(), get_blob() 81 84 repo.rs create_record(), put_record(), get_record(), list_records(), delete_record() 82 85 directories/ ··· 117 120 identity.rs Keypair generation, migration, permission checks 118 121 keyring_store.rs Local group key persistence (per-keyring) 119 122 transport.rs reqwest-based Transport implementation 123 + oauth.rs OAuth loopback redirect server + browser open 120 124 utils.rs Test harness, env helpers 121 125 commands/ 122 - login.rs Auth + key publish 126 + login.rs Auth + key publish (OAuth-first with --legacy fallback) 123 127 upload.rs File → encrypt → upload (direct or --keyring) 124 128 download.rs Download + decrypt (direct, keyring, or --grant) 125 129 ls.rs List documents ··· 311 315 312 316 **Web (`IndexedDbStorage`)** — Dexie.js over IndexedDB with three object stores (`configs`, `identities`, `sessions`). `removeAccount` runs config mutation + data deletion in a single transaction for atomicity. 313 317 318 + ## Authentication 319 + 320 + The CLI authenticates via AT Protocol OAuth 2.0 with DPoP (Demonstrating Proof-of-Possession). On `opake login`, the CLI: 321 + 322 + 1. Discovers the PDS's authorization server via `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` 323 + 2. Generates a DPoP keypair (P-256/ES256) and PKCE S256 challenge 324 + 3. Sends a Pushed Authorization Request (PAR) with DPoP proof 325 + 4. Opens the browser for user authorization 326 + 5. Listens on a loopback server (`127.0.0.1`) for the OAuth callback 327 + 6. Exchanges the authorization code for tokens with DPoP proof 328 + 329 + If OAuth discovery fails (PDS doesn't support it), the CLI falls back to legacy password-based `createSession` with a warning. 330 + 331 + `Session` is a discriminated union — `Legacy(LegacySession)` or `OAuth(OAuthSession)`. The XRPC client dispatches auth headers based on the variant: `Authorization: Bearer` for legacy, `Authorization: DPoP` + `DPoP` proof header for OAuth. Token refresh also dispatches per-variant. Existing `session.json` files without a `"type"` field deserialize as `Legacy` for backward compatibility. 332 + 333 + The DPoP key is per-session (generated at login time), not per-identity. It's a separate key from the X25519 encryption key and Ed25519 signing key. 334 + 314 335 ## Multi-Account Support 315 336 316 337 The CLI supports multiple authenticated accounts. Each account has its own: 317 338 318 - - Session tokens (access + refresh JWT) 339 + - Session (OAuth tokens + DPoP key, or legacy JWTs) 319 340 - X25519 keypair 320 341 - PDS URL and handle 321 342
+71 -4
docs/flows/authentication.md
··· 1 1 # Authentication 2 2 3 - ## Login 3 + ## Login (OAuth) 4 + 5 + Default login flow. Uses AT Protocol OAuth 2.0 with DPoP (Demonstrating Proof-of-Possession) and a loopback redirect server for the browser-based authorization. 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI 11 + participant Browser 12 + participant PDS/AS 13 + 14 + User->>CLI: opake login --pds <url> --identifier <handle> 15 + 16 + CLI->>PDS/AS: GET /.well-known/oauth-protected-resource 17 + PDS/AS-->>CLI: { authorization_servers: [<as_url>] } 18 + CLI->>PDS/AS: GET /.well-known/oauth-authorization-server 19 + PDS/AS-->>CLI: AS metadata (endpoints, PAR, DPoP algs) 20 + 21 + CLI->>CLI: Generate DPoP keypair (P-256), PKCE S256, state 22 + 23 + CLI->>PDS/AS: POST /oauth/par (DPoP proof, PKCE challenge, redirect_uri) 24 + PDS/AS-->>CLI: { request_uri, expires_in } 25 + 26 + CLI->>CLI: Start loopback server on 127.0.0.1 27 + CLI->>Browser: Open authorization URL 28 + Browser->>PDS/AS: User authorizes 29 + PDS/AS->>Browser: Redirect to 127.0.0.1/callback?code=...&state=... 30 + Browser->>CLI: GET /callback?code=...&state=... 31 + 32 + CLI->>CLI: Verify state (CSRF check) 33 + CLI->>PDS/AS: POST /oauth/token (code, PKCE verifier, DPoP proof) 34 + PDS/AS-->>CLI: { access_token, refresh_token, sub } 35 + 36 + CLI->>CLI: Save OAuthSession (tokens + DPoP key) 37 + CLI->>CLI: Load or generate X25519 keypair 38 + 39 + CLI->>PDS/AS: com.atproto.repo.putRecord (publicKey/self, DPoP auth) 40 + PDS/AS-->>CLI: { uri, cid } 41 + 42 + CLI->>User: Logged in as <handle> (OAuth) 43 + ``` 4 44 5 - Authenticates with a PDS, persists session + identity, and publishes the encryption public key as a singleton record. 45 + The loopback server times out after `expires_in` seconds (from the PAR response). If OAuth discovery fails, the CLI falls back to legacy password authentication with a warning. 46 + 47 + ## Login (Legacy) 48 + 49 + Password-based authentication via `createSession`. Used when `--legacy` is passed or when the PDS doesn't support OAuth discovery. 6 50 7 51 ```mermaid 8 52 sequenceDiagram ··· 10 54 participant CLI 11 55 participant PDS 12 56 13 - User->>CLI: opake login --pds <url> --identifier <handle> 57 + User->>CLI: opake login --pds <url> --identifier <handle> --legacy 14 58 CLI->>User: Password prompt (or OPAKE_PASSWORD env) 15 59 User-->>CLI: password 16 60 ··· 30 74 31 75 ## Token Refresh 32 76 33 - Transparent to the user. The XRPC client detects expired tokens and refreshes automatically. 77 + Transparent to the user. The XRPC client detects expired tokens and refreshes automatically. The refresh path depends on the session variant. 78 + 79 + ### Legacy Refresh 34 80 35 81 ```mermaid 36 82 sequenceDiagram ··· 48 94 CLI->>PDS: Retry original XRPC call (new accessJwt) 49 95 PDS-->>CLI: Success 50 96 ``` 97 + 98 + ### OAuth Refresh 99 + 100 + ```mermaid 101 + sequenceDiagram 102 + participant CLI 103 + participant AS 104 + 105 + CLI->>AS: Any XRPC call (expired access_token, DPoP proof) 106 + AS-->>CLI: 400 ExpiredToken 107 + 108 + CLI->>AS: POST /oauth/token (grant_type=refresh_token, DPoP proof) 109 + AS-->>CLI: { access_token, refresh_token } (new tokens) 110 + 111 + CLI->>CLI: Update stored OAuthSession 112 + 113 + CLI->>AS: Retry original XRPC call (new access_token, DPoP proof) 114 + AS-->>CLI: Success 115 + ``` 116 + 117 + DPoP nonces are captured from every response and included in subsequent proofs. If the server returns a `use_dpop_nonce` error, the client retries once with the fresh nonce.