···11+//! DPoP (Demonstration of Proof-of-Possession) proof generation.
22+//!
33+//! Thin wrapper around `atproto_oauth` for generating RFC 9449 compliant DPoP
44+//! proof JWTs. Used when the access token is DPoP-bound (obtained via OAuth,
55+//! not createSession).
66+77+use atproto_identity::key::KeyData;
88+use atproto_oauth::dpop::request_dpop;
99+use atproto_oauth::jwk::{WrappedJsonWebKey, to_key_data};
1010+use atproto_oauth::jwt::{Claims, Header, JoseClaims, mint};
1111+use atproto_oauth::pkce::challenge;
1212+use ulid::Ulid;
1313+1414+/// Parses a serialized `WrappedJsonWebKey` JSON string into key data for signing.
1515+///
1616+/// The input JSON has the shape produced by `atproto_oauth::jwk::generate`:
1717+/// `{"kid":"...","alg":"ES256","use":"sig","kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."}`
1818+pub fn parse_dpop_key(jwk_json: &str) -> Result<KeyData, String> {
1919+ let wrapped: WrappedJsonWebKey = serde_json::from_str(jwk_json)
2020+ .map_err(|e| format!("failed to parse DPoP key JSON: {}", e))?;
2121+2222+ to_key_data(&wrapped).map_err(|e| format!("failed to convert JWK to key data: {}", e))
2323+}
2424+2525+/// Generates a DPoP proof JWT for an authenticated XRPC request.
2626+///
2727+/// Returns the signed compact JWT string (`header.claims.signature`).
2828+/// If `nonce` is provided, it is included in the claims (required after
2929+/// a PDS nonce challenge).
3030+pub fn make_dpop_proof(
3131+ key_data: &KeyData,
3232+ http_method: &str,
3333+ http_uri: &str,
3434+ access_token: &str,
3535+ nonce: Option<&str>,
3636+) -> Result<String, String> {
3737+ match nonce {
3838+ None => {
3939+ // Use atproto-oauth's request_dpop directly
4040+ let (token, _header, _claims) =
4141+ request_dpop(key_data, http_method, http_uri, access_token)
4242+ .map_err(|e| format!("failed to generate DPoP proof: {}", e))?;
4343+ Ok(token)
4444+ }
4545+ Some(nonce_value) => {
4646+ // Build proof manually with nonce included
4747+ let public_key_data = atproto_identity::key::to_public(key_data)
4848+ .map_err(|e| format!("failed to derive public key: {}", e))?;
4949+ let dpop_jwk: elliptic_curve::JwkEcKey =
5050+ TryInto::<elliptic_curve::JwkEcKey>::try_into(&public_key_data)
5151+ .map_err(|e| format!("failed to convert to JWK: {}", e))?;
5252+5353+ let header = Header {
5454+ type_: Some("dpop+jwt".to_string()),
5555+ algorithm: Some("ES256".to_string()),
5656+ json_web_key: Some(dpop_jwk),
5757+ key_id: None,
5858+ };
5959+6060+ let now = std::time::SystemTime::now()
6161+ .duration_since(std::time::UNIX_EPOCH)
6262+ .map_err(|e| format!("system time error: {}", e))?
6363+ .as_secs();
6464+6565+ let claims = Claims::new(JoseClaims {
6666+ auth: Some(challenge(access_token)),
6767+ expiration: Some(now + 30),
6868+ http_method: Some(http_method.to_string()),
6969+ http_uri: Some(http_uri.to_string()),
7070+ issued_at: Some(now),
7171+ json_web_token_id: Some(Ulid::new().to_string()),
7272+ nonce: Some(nonce_value.to_string()),
7373+ ..Default::default()
7474+ });
7575+7676+ mint(key_data, &header, &claims)
7777+ .map_err(|e| format!("failed to mint DPoP proof with nonce: {}", e))
7878+ }
7979+ }
8080+}
8181+8282+#[cfg(test)]
8383+mod tests {
8484+ use super::*;
8585+ use atproto_identity::key::{KeyData, KeyType};
8686+ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
8787+8888+ /// Generate a random P-256 key and serialize it as a WrappedJsonWebKey JSON string.
8989+ fn make_test_key() -> (KeyData, String) {
9090+ // Generate a random P-256 secret key
9191+ let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
9292+ let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec());
9393+9494+ // Convert to WrappedJsonWebKey JSON
9595+ let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap();
9696+ let json = serde_json::to_string(&wrapped).unwrap();
9797+9898+ (key_data, json)
9999+ }
100100+101101+ #[test]
102102+ fn parse_dpop_key_round_trip() {
103103+ let (original_key_data, json) = make_test_key();
104104+ let parsed = parse_dpop_key(&json).unwrap();
105105+ assert_eq!(parsed.key_type(), original_key_data.key_type());
106106+ }
107107+108108+ #[test]
109109+ fn parse_dpop_key_invalid_json() {
110110+ assert!(parse_dpop_key("not json").is_err());
111111+ }
112112+113113+ #[test]
114114+ fn parse_dpop_key_missing_private_key() {
115115+ // Public-only JWK should fail or produce a public key type
116116+ let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
117117+ let key_data = KeyData::new(KeyType::P256Private, secret_key.to_bytes().to_vec());
118118+ let wrapped = atproto_oauth::jwk::generate(&key_data).unwrap();
119119+120120+ // Serialize and strip the "d" parameter to make it public-only
121121+ let mut value: serde_json::Value = serde_json::to_value(&wrapped).unwrap();
122122+ value.as_object_mut().unwrap().remove("d");
123123+ let json = serde_json::to_string(&value).unwrap();
124124+125125+ // Should parse but produce a public key (not useful for signing)
126126+ let result = parse_dpop_key(&json);
127127+ match result {
128128+ Ok(kd) => assert_eq!(*kd.key_type(), KeyType::P256Public),
129129+ Err(_) => {} // also acceptable
130130+ }
131131+ }
132132+133133+ #[test]
134134+ fn make_dpop_proof_produces_valid_jwt() {
135135+ let (key_data, _json) = make_test_key();
136136+ let proof = make_dpop_proof(
137137+ &key_data,
138138+ "POST",
139139+ "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob",
140140+ "test-access-token",
141141+ None,
142142+ )
143143+ .unwrap();
144144+145145+ // JWT has 3 parts
146146+ let parts: Vec<&str> = proof.split('.').collect();
147147+ assert_eq!(parts.len(), 3, "JWT should have 3 parts");
148148+149149+ // Decode and verify header
150150+ let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap();
151151+ let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
152152+ assert_eq!(header["typ"], "dpop+jwt");
153153+ assert_eq!(header["alg"], "ES256");
154154+ assert!(header["jwk"].is_object());
155155+156156+ // Decode and verify claims
157157+ let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
158158+ let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap();
159159+ assert_eq!(claims["htm"], "POST");
160160+ assert_eq!(
161161+ claims["htu"],
162162+ "https://pds.example.com/xrpc/com.atproto.repo.uploadBlob"
163163+ );
164164+ assert!(claims["jti"].is_string());
165165+ assert!(claims["iat"].is_number());
166166+ assert!(claims["exp"].is_number());
167167+ assert!(claims["ath"].is_string());
168168+ }
169169+170170+ #[test]
171171+ fn make_dpop_proof_with_nonce() {
172172+ let (key_data, _json) = make_test_key();
173173+ let proof = make_dpop_proof(
174174+ &key_data,
175175+ "GET",
176176+ "https://pds.example.com/xrpc/com.atproto.repo.getRecord",
177177+ "token",
178178+ Some("server-nonce-123"),
179179+ )
180180+ .unwrap();
181181+182182+ let parts: Vec<&str> = proof.split('.').collect();
183183+ let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
184184+ let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap();
185185+ assert_eq!(claims["nonce"], "server-nonce-123");
186186+ }
187187+188188+ #[test]
189189+ fn make_dpop_proof_signature_verifies() {
190190+ let (key_data, _json) = make_test_key();
191191+ let proof = make_dpop_proof(
192192+ &key_data,
193193+ "POST",
194194+ "https://pds.example.com/xrpc/test",
195195+ "token123",
196196+ None,
197197+ )
198198+ .unwrap();
199199+200200+ // Verify using atproto-oauth's jwt::verify
201201+ let public_key = atproto_identity::key::to_public(&key_data).unwrap();
202202+ let claims = atproto_oauth::jwt::verify(&proof, &public_key).unwrap();
203203+ assert_eq!(claims.jose.http_method.as_deref(), Some("POST"));
204204+ }
205205+}
+3
src/lib.rs
···77pub mod auth;
88pub mod bundle;
99pub mod chunk;
1010+pub mod dpop;
1011pub mod fetch;
1112pub mod identity;
1313+pub mod oauth_login;
1414+pub mod oauth_server;
1215pub mod pds_client;
1316pub mod push;
1417pub mod types;
+28-1
src/main.rs
···30303131#[derive(Subcommand)]
3232enum AuthAction {
3333- /// Log in to a PDS and store credentials
3333+ /// Log in to a PDS using handle + password (createSession)
3434 Login {
3535 /// PDS server URL
3636 #[arg(long)]
···3838 /// AT Protocol handle
3939 #[arg(long)]
4040 handle: String,
4141+ },
4242+ /// Log in via browser-based AT Protocol OAuth (DPoP-bound tokens)
4343+ OauthLogin {
4444+ /// AT Protocol handle
4545+ #[arg(long)]
4646+ handle: String,
4747+ /// PDS server URL (optional, auto-discovered from handle)
4848+ #[arg(long)]
4949+ pds_url: Option<String>,
5050+ /// Port for the localhost OAuth callback server
5151+ #[arg(long, default_value = "8271")]
5252+ port: u16,
4153 },
4254 /// Show stored credentials
4355 Status,
···8092 AuthAction::Login { pds_url, handle } => {
8193 handle_login(&pds_url, &handle).await;
8294 }
9595+ AuthAction::OauthLogin {
9696+ handle,
9797+ pds_url,
9898+ port,
9999+ } => {
100100+ handle_oauth_login(&handle, pds_url.as_deref(), port).await;
101101+ }
83102 AuthAction::Status => {
84103 handle_status();
85104 }
···87106 handle_logout(&handle);
88107 }
89108 },
109109+ }
110110+}
111111+112112+/// Handles `auth oauth-login` — browser-based OAuth login with DPoP.
113113+async fn handle_oauth_login(handle: &str, pds_url: Option<&str>, port: u16) {
114114+ if let Err(e) = pds_git_remote::oauth_login::oauth_login(handle, pds_url, port).await {
115115+ eprintln!("OAuth login failed: {}", e);
116116+ std::process::exit(1);
90117 }
91118}
92119
+254
src/oauth_login.rs
···11+//! Browser-based AT Protocol OAuth login flow.
22+//!
33+//! Orchestrates the full OAuth 2.0 + DPoP login:
44+//! 1. Resolve handle → DID → PDS
55+//! 2. Discover authorization server
66+//! 3. Generate keys (signing, DPoP, PKCE)
77+//! 4. Start localhost callback server
88+//! 5. Make PAR request
99+//! 6. Open browser to authorization URL
1010+//! 7. Wait for callback with authorization code
1111+//! 8. Exchange code for DPoP-bound tokens
1212+//! 9. Store credential with DPoP key
1313+1414+use crate::auth;
1515+use crate::identity;
1616+use crate::oauth_server;
1717+1818+use atproto_identity::key::{KeyType, generate_key};
1919+use atproto_oauth::jwk;
2020+use atproto_oauth::pkce;
2121+use atproto_oauth::resources;
2222+use atproto_oauth::workflow::{self, OAuthClient, OAuthRequest, OAuthRequestState};
2323+2424+/// Runs the full OAuth login flow for a handle.
2525+///
2626+/// Starts a localhost server, opens the browser, waits for the user to
2727+/// authorize, then stores the resulting DPoP-bound credential.
2828+pub async fn oauth_login(handle: &str, pds_url: Option<&str>, port: u16) -> Result<(), String> {
2929+ // 1. resolve identity
3030+ eprintln!("Resolving identity for {}...", handle);
3131+ let (pds_url, did) = resolve_identity(handle, pds_url).await?;
3232+ eprintln!("Resolved: {} @ {}", did, pds_url);
3333+3434+ // 2. discover authorization server
3535+ eprintln!("Discovering authorization server...");
3636+ let http = reqwest::Client::new();
3737+ let (_resource, auth_server) = resources::pds_resources(&http, &pds_url)
3838+ .await
3939+ .map_err(|e| format!("failed to discover authorization server: {}", e))?;
4040+4141+ // 3. generate DPoP key (no signing key needed for public/loopback client)
4242+ let dpop_key = generate_key(KeyType::P256Private)
4343+ .map_err(|e| format!("failed to generate DPoP key: {}", e))?;
4444+4545+ // 4. build loopback client config
4646+ // AT Protocol uses http://localhost as a special loopback client_id.
4747+ // The PDS generates virtual client metadata for it — no need to serve our own.
4848+ // Scopes are specified via query parameter on the client_id URL.
4949+ // Redirect URI must be http://127.0.0.1/ (root path, any port is accepted).
5050+ let scope = "atproto transition:generic";
5151+ let client_id = format!(
5252+ "http://localhost?scope={}&redirect_uri={}",
5353+ percent_encode(scope),
5454+ percent_encode(&format!("http://127.0.0.1:{}/", port)),
5555+ );
5656+ let redirect_uri = format!("http://127.0.0.1:{}/", port);
5757+5858+ let oauth_client = OAuthClient {
5959+ redirect_uri: redirect_uri.clone(),
6060+ client_id: client_id.clone(),
6161+ // public client — no signing key, no client_assertion
6262+ private_signing_key_data: None,
6363+ };
6464+6565+ // 5. generate PKCE challenge
6666+ let (code_verifier, code_challenge) = pkce::generate();
6767+6868+ let state = generate_random_state();
6969+ let nonce = generate_random_state();
7070+ let oauth_request_state = OAuthRequestState {
7171+ state: state.clone(),
7272+ nonce: nonce.clone(),
7373+ code_challenge,
7474+ scope: scope.to_string(),
7575+ };
7676+7777+ // 6. make PAR request
7878+ eprintln!("Starting OAuth flow...");
7979+ let par_response = workflow::oauth_init(
8080+ &http,
8181+ &oauth_client,
8282+ &dpop_key,
8383+ Some(handle),
8484+ &auth_server,
8585+ &oauth_request_state,
8686+ )
8787+ .await
8888+ .map_err(|e| format!("OAuth PAR request failed: {}", e))?;
8989+9090+ // build the authorization URL
9191+ let auth_url = format!(
9292+ "{}?client_id={}&request_uri={}",
9393+ auth_server.authorization_endpoint,
9494+ percent_encode(&client_id),
9595+ percent_encode(&par_response.request_uri),
9696+ );
9797+9898+ // serialize DPoP key for storage in the OAuthRequest
9999+ let dpop_jwk =
100100+ jwk::generate(&dpop_key).map_err(|e| format!("failed to generate DPoP JWK: {}", e))?;
101101+ let dpop_private_key_json = serde_json::to_string(&dpop_jwk)
102102+ .map_err(|e| format!("failed to serialize DPoP key: {}", e))?;
103103+104104+ let now = chrono::Utc::now();
105105+ let oauth_request = OAuthRequest {
106106+ oauth_state: state.clone(),
107107+ issuer: auth_server.issuer.clone(),
108108+ authorization_server: auth_server.issuer.clone(),
109109+ nonce,
110110+ pkce_verifier: code_verifier,
111111+ // no signing key for public client
112112+ signing_public_key: String::new(),
113113+ dpop_private_key: dpop_private_key_json.clone(),
114114+ created_at: now,
115115+ expires_at: now + chrono::Duration::seconds(par_response.expires_in as i64),
116116+ };
117117+118118+ // 7. print URL and try to open browser
119119+ eprintln!("\nOpen this URL in your browser to authorize:\n");
120120+ eprintln!(" {}\n", auth_url);
121121+ let _ = open_browser(&auth_url);
122122+123123+ // 8. start callback server and wait for the redirect
124124+ let callback = oauth_server::run_oauth_server(port).await?;
125125+126126+ // verify state matches
127127+ if callback.state != state {
128128+ return Err("OAuth state mismatch — possible CSRF attack".to_string());
129129+ }
130130+131131+ // 9. exchange code for tokens
132132+ eprintln!("Exchanging authorization code for tokens...");
133133+ let token_response = workflow::oauth_complete(
134134+ &http,
135135+ &oauth_client,
136136+ &dpop_key,
137137+ &callback.code,
138138+ &oauth_request,
139139+ &auth_server,
140140+ )
141141+ .await
142142+ .map_err(|e| format!("OAuth token exchange failed: {}", e))?;
143143+144144+ let token_did = token_response
145145+ .sub
146146+ .ok_or_else(|| "no sub (DID) in token response".to_string())?;
147147+148148+ // compute token expiry
149149+ let token_expiry = std::time::SystemTime::now()
150150+ .duration_since(std::time::UNIX_EPOCH)
151151+ .map(|d| d.as_secs() as i64 + token_response.expires_in as i64)
152152+ .ok();
153153+154154+ // 10. store credential
155155+ let cred = auth::StoredCredential {
156156+ pds_url: pds_url.clone(),
157157+ handle: handle.to_string(),
158158+ did: token_did.clone(),
159159+ access_jwt: token_response.access_token,
160160+ refresh_jwt: token_response.refresh_token.unwrap_or_default(),
161161+ dpop_key: Some(dpop_private_key_json),
162162+ signing_key: None,
163163+ token_expiry,
164164+ };
165165+166166+ let mut config = auth::load_config()?;
167167+ config.credentials.insert(handle.to_string(), cred);
168168+ auth::save_config(&config)?;
169169+170170+ eprintln!("Logged in as {} ({}) via OAuth", handle, token_did);
171171+ eprintln!("Credentials stored with DPoP key for authenticated pushes.");
172172+173173+ Ok(())
174174+}
175175+176176+/// Resolves a handle to its PDS URL and DID.
177177+async fn resolve_identity(handle: &str, pds_url: Option<&str>) -> Result<(String, String), String> {
178178+ if let Some(url) = pds_url {
179179+ let did = identity::resolve_handle(handle, Some(url)).await?;
180180+ Ok((url.to_string(), did))
181181+ } else {
182182+ let resolved = identity::resolve_identity(handle, None, None).await?;
183183+ Ok((resolved.pds_url, resolved.did))
184184+ }
185185+}
186186+187187+/// Tries to open a URL in the default browser.
188188+fn open_browser(url: &str) -> Result<(), String> {
189189+ #[cfg(target_os = "macos")]
190190+ let cmd = std::process::Command::new("open").arg(url).spawn();
191191+192192+ #[cfg(target_os = "linux")]
193193+ let cmd = std::process::Command::new("xdg-open").arg(url).spawn();
194194+195195+ #[cfg(target_os = "windows")]
196196+ let cmd = std::process::Command::new("cmd")
197197+ .args(["/c", "start", url])
198198+ .spawn();
199199+200200+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
201201+ let cmd: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
202202+ std::io::ErrorKind::Unsupported,
203203+ "unsupported platform",
204204+ ));
205205+206206+ match cmd {
207207+ Ok(_) => Ok(()),
208208+ Err(e) => {
209209+ eprintln!("Could not open browser automatically: {}", e);
210210+ eprintln!("Please open the URL above manually.");
211211+ Ok(())
212212+ }
213213+ }
214214+}
215215+216216+/// Simple percent-encoding for URL parameters.
217217+fn percent_encode(s: &str) -> String {
218218+ let mut result = String::with_capacity(s.len() * 3);
219219+ for byte in s.bytes() {
220220+ match byte {
221221+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
222222+ result.push(byte as char);
223223+ }
224224+ _ => {
225225+ result.push('%');
226226+ result.push_str(&format!("{:02X}", byte));
227227+ }
228228+ }
229229+ }
230230+ result
231231+}
232232+233233+/// Generates a random state string.
234234+fn generate_random_state() -> String {
235235+ use std::collections::hash_map::DefaultHasher;
236236+ use std::hash::{Hash, Hasher};
237237+ use std::time::SystemTime;
238238+239239+ let now = SystemTime::now()
240240+ .duration_since(SystemTime::UNIX_EPOCH)
241241+ .unwrap_or_default();
242242+243243+ let mut hasher = DefaultHasher::new();
244244+ now.as_nanos().hash(&mut hasher);
245245+ std::thread::current().id().hash(&mut hasher);
246246+ let stack_var = 0u8;
247247+ (&stack_var as *const u8 as usize).hash(&mut hasher);
248248+ let h1 = hasher.finish();
249249+250250+ h1.hash(&mut hasher);
251251+ let h2 = hasher.finish();
252252+253253+ format!("{:016x}{:016x}", h1, h2)
254254+}
+92
src/oauth_server.rs
···11+//! Temporary localhost HTTP server for OAuth callback.
22+//!
33+//! Listens on a loopback port for the OAuth redirect from the PDS.
44+//! Shuts down as soon as the callback is received.
55+66+use std::sync::Arc;
77+88+use axum::response::IntoResponse;
99+use tokio::sync::oneshot;
1010+1111+/// Data received from the OAuth callback redirect.
1212+pub struct CallbackResult {
1313+ pub code: String,
1414+ pub state: String,
1515+ pub iss: Option<String>,
1616+}
1717+1818+/// Shared state for the OAuth callback server.
1919+struct ServerState {
2020+ callback_tx: std::sync::Mutex<Option<oneshot::Sender<CallbackResult>>>,
2121+}
2222+2323+/// Starts a temporary HTTP server for the OAuth login flow.
2424+///
2525+/// Listens on `127.0.0.1:{port}` for the OAuth redirect. The PDS redirects
2626+/// to `http://127.0.0.1:{port}/?code=...&state=...` after authorization.
2727+///
2828+/// Returns the authorization code once the callback is received.
2929+pub async fn run_oauth_server(port: u16) -> Result<CallbackResult, String> {
3030+ let (tx, rx) = oneshot::channel::<CallbackResult>();
3131+3232+ let state = Arc::new(ServerState {
3333+ callback_tx: std::sync::Mutex::new(Some(tx)),
3434+ });
3535+3636+ // the PDS redirects to the root path with query params
3737+ let app = axum::Router::new()
3838+ .route("/", axum::routing::get(callback_handler))
3939+ .with_state(state);
4040+4141+ let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
4242+ let listener = tokio::net::TcpListener::bind(addr)
4343+ .await
4444+ .map_err(|e| format!("failed to bind to port {}: {}", port, e))?;
4545+4646+ eprintln!(
4747+ "OAuth callback server listening on http://127.0.0.1:{}",
4848+ port
4949+ );
5050+5151+ // run the server until the callback is received
5252+ let server = axum::serve(listener, app);
5353+ tokio::select! {
5454+ result = rx => {
5555+ result.map_err(|_| "callback channel closed without result".to_string())
5656+ }
5757+ err = server.into_future() => {
5858+ Err(format!("server exited unexpectedly: {:?}", err))
5959+ }
6060+ }
6161+}
6262+6363+/// Query parameters on the OAuth callback.
6464+#[derive(serde::Deserialize)]
6565+struct CallbackParams {
6666+ code: String,
6767+ state: String,
6868+ iss: Option<String>,
6969+}
7070+7171+/// GET /?code=...&state=...&iss=...
7272+async fn callback_handler(
7373+ axum::extract::State(state): axum::extract::State<Arc<ServerState>>,
7474+ axum::extract::Query(params): axum::extract::Query<CallbackParams>,
7575+) -> axum::response::Response {
7676+ let result = CallbackResult {
7777+ code: params.code,
7878+ state: params.state,
7979+ iss: params.iss,
8080+ };
8181+8282+ // send the result through the channel
8383+ if let Some(tx) = state.callback_tx.lock().unwrap().take() {
8484+ let _ = tx.send(result);
8585+ }
8686+8787+ (
8888+ [(axum::http::header::CONTENT_TYPE, "text/html")],
8989+ "<html><body><h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>",
9090+ )
9191+ .into_response()
9292+}
+159-36
src/pds_client.rs
···33//! Wraps the AT Protocol XRPC endpoints needed for git backup:
44//! record CRUD, blob upload/download, and session creation.
5566+use std::cell::RefCell;
77+88+use atproto_identity::key::KeyData;
99+1010+use crate::dpop;
611use crate::types::{BlobRef, CidLink};
712use serde::{Deserialize, Serialize};
8131414+/// Authentication mode for PDS requests.
1515+enum AuthMode {
1616+ /// No authentication (unauthenticated reads).
1717+ None,
1818+ /// Classic Bearer token (from createSession).
1919+ Bearer(String),
2020+ /// DPoP-bound OAuth token (requires proof on every request).
2121+ DPoP {
2222+ access_token: String,
2323+ key_data: KeyData,
2424+ /// Cached nonce from the PDS (updated after each nonce challenge).
2525+ last_nonce: RefCell<Option<String>>,
2626+ },
2727+}
2828+929/// Client for interacting with a PDS server over XRPC.
1030///
1111-/// Supports both authenticated (push) and unauthenticated (clone/fetch)
1212-/// operations. Use `PdsClient::new` for unauthenticated access and
1313-/// `PdsClient::with_auth` when a bearer token is available.
1414-#[derive(Debug, Clone)]
3131+/// Supports three auth modes:
3232+/// - Unauthenticated (clone/fetch)
3333+/// - Bearer token (createSession-based push)
3434+/// - DPoP (OAuth-based push with proof-of-possession)
1535pub struct PdsClient {
1636 /// base URL of the PDS, e.g. "https://bsky.social"
1737 base_url: String,
1818- /// bearer token for authenticated requests (access JWT)
1919- auth_token: Option<String>,
3838+ auth: AuthMode,
2039 http: reqwest::Client,
2140}
2241···95114 pub fn new(base_url: impl Into<String>) -> Self {
96115 Self {
97116 base_url: base_url.into().trim_end_matches('/').to_string(),
9898- auth_token: None,
117117+ auth: AuthMode::None,
99118 http: reqwest::Client::new(),
100119 }
101120 }
···104123 pub fn with_auth(base_url: impl Into<String>, token: impl Into<String>) -> Self {
105124 Self {
106125 base_url: base_url.into().trim_end_matches('/').to_string(),
107107- auth_token: Some(token.into()),
126126+ auth: AuthMode::Bearer(token.into()),
108127 http: reqwest::Client::new(),
109128 }
110129 }
111130112112- /// Sets or replaces the auth token.
131131+ /// Creates an authenticated client with a DPoP-bound OAuth token.
132132+ pub fn with_dpop_auth(
133133+ base_url: impl Into<String>,
134134+ access_token: impl Into<String>,
135135+ key_data: KeyData,
136136+ ) -> Self {
137137+ Self {
138138+ base_url: base_url.into().trim_end_matches('/').to_string(),
139139+ auth: AuthMode::DPoP {
140140+ access_token: access_token.into(),
141141+ key_data,
142142+ last_nonce: RefCell::new(None),
143143+ },
144144+ http: reqwest::Client::new(),
145145+ }
146146+ }
147147+148148+ /// Sets or replaces the auth token (Bearer mode).
113149 pub fn set_auth(&mut self, token: impl Into<String>) {
114114- self.auth_token = Some(token.into());
150150+ self.auth = AuthMode::Bearer(token.into());
115151 }
116152117153 /// Returns the base URL of the PDS.
···152188 .await
153189 .map_err(|e| format!("failed to parse createSession response: {}", e))?;
154190155155- self.auth_token = Some(session.access_jwt.clone());
191191+ self.auth = AuthMode::Bearer(session.access_jwt.clone());
156192 Ok(session)
157193 }
158194···211247 record: serde_json::Value,
212248 swap_record: Option<String>,
213249 ) -> Result<PutRecordResponse, String> {
214214- let token = self
215215- .auth_token
216216- .as_ref()
217217- .ok_or("putRecord requires authentication")?;
218218-219250 let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url);
220251221252 let body = PutRecordRequest {
···226257 swap_record,
227258 };
228259260260+ let json_body = serde_json::to_vec(&body)
261261+ .map_err(|e| format!("failed to serialize putRecord body: {}", e))?;
262262+229263 let resp = self
230230- .http
231231- .post(&url)
232232- .bearer_auth(token)
233233- .json(&body)
234234- .send()
235235- .await
236236- .map_err(|e| format!("putRecord request failed: {}", e))?;
264264+ .send_authenticated("POST", &url, json_body.clone(), Some("application/json"))
265265+ .await?;
237266238267 if !resp.status().is_success() {
239268 let err = parse_xrpc_error(resp).await;
···250279 /// Calls `com.atproto.repo.uploadBlob`. Requires authentication.
251280 /// Returns a `BlobRef` that can be embedded in a record.
252281 pub async fn upload_blob(&self, data: Vec<u8>) -> Result<BlobRef, String> {
253253- let token = self
254254- .auth_token
255255- .as_ref()
256256- .ok_or("uploadBlob requires authentication")?;
257257-258282 let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url);
259283260284 let resp = self
261261- .http
262262- .post(&url)
263263- .bearer_auth(token)
264264- .header("Content-Type", "application/octet-stream")
265265- .body(data)
266266- .send()
267267- .await
268268- .map_err(|e| format!("uploadBlob request failed: {}", e))?;
285285+ .send_authenticated("POST", &url, data.clone(), Some("application/octet-stream"))
286286+ .await?;
269287270288 if !resp.status().is_success() {
271289 let err = parse_xrpc_error(resp).await;
···311329 .await
312330 .map(|b| b.to_vec())
313331 .map_err(|e| format!("failed to read getBlob response body: {}", e))
332332+ }
333333+334334+ /// Sends an authenticated request, handling both Bearer and DPoP auth modes.
335335+ ///
336336+ /// For DPoP auth, generates proof headers and automatically retries once
337337+ /// if the PDS responds with a nonce challenge (400/401 with DPoP-Nonce header).
338338+ async fn send_authenticated(
339339+ &self,
340340+ method: &str,
341341+ url: &str,
342342+ body: Vec<u8>,
343343+ content_type: Option<&str>,
344344+ ) -> Result<reqwest::Response, String> {
345345+ match &self.auth {
346346+ AuthMode::None => Err("authenticated request requires auth".to_string()),
347347+ AuthMode::Bearer(token) => {
348348+ let mut req = self.http.post(url).bearer_auth(token);
349349+ if let Some(ct) = content_type {
350350+ req = req.header("Content-Type", ct);
351351+ }
352352+ req.body(body)
353353+ .send()
354354+ .await
355355+ .map_err(|e| format!("request failed: {}", e))
356356+ }
357357+ AuthMode::DPoP {
358358+ access_token,
359359+ key_data,
360360+ last_nonce,
361361+ } => {
362362+ let nonce = last_nonce.borrow().clone();
363363+ let proof =
364364+ dpop::make_dpop_proof(key_data, method, url, access_token, nonce.as_deref())?;
365365+366366+ let mut req = self
367367+ .http
368368+ .post(url)
369369+ .header("Authorization", format!("DPoP {}", access_token))
370370+ .header("DPoP", &proof);
371371+ if let Some(ct) = content_type {
372372+ req = req.header("Content-Type", ct);
373373+ }
374374+375375+ let resp = req
376376+ .body(body.clone())
377377+ .send()
378378+ .await
379379+ .map_err(|e| format!("request failed: {}", e))?;
380380+381381+ // Check for nonce challenge: 400/401 with DPoP-Nonce header
382382+ let status = resp.status();
383383+ if (status == reqwest::StatusCode::BAD_REQUEST
384384+ || status == reqwest::StatusCode::UNAUTHORIZED)
385385+ && resp.headers().contains_key("dpop-nonce")
386386+ {
387387+ let new_nonce = resp
388388+ .headers()
389389+ .get("dpop-nonce")
390390+ .and_then(|v| v.to_str().ok())
391391+ .map(|s| s.to_string());
392392+393393+ if let Some(ref nonce_val) = new_nonce {
394394+ tracing::debug!("DPoP nonce challenge received, retrying with nonce");
395395+396396+ // Cache the nonce for subsequent requests
397397+ *last_nonce.borrow_mut() = new_nonce.clone();
398398+399399+ // Regenerate proof with nonce and retry
400400+ let retry_proof = dpop::make_dpop_proof(
401401+ key_data,
402402+ method,
403403+ url,
404404+ access_token,
405405+ Some(nonce_val),
406406+ )?;
407407+408408+ let mut retry_req = self
409409+ .http
410410+ .post(url)
411411+ .header("Authorization", format!("DPoP {}", access_token))
412412+ .header("DPoP", &retry_proof);
413413+ if let Some(ct) = content_type {
414414+ retry_req = retry_req.header("Content-Type", ct);
415415+ }
416416+417417+ return retry_req
418418+ .body(body)
419419+ .send()
420420+ .await
421421+ .map_err(|e| format!("request failed (retry): {}", e));
422422+ }
423423+ }
424424+425425+ // Cache any nonce from successful responses too
426426+ if let Some(nonce_val) = resp
427427+ .headers()
428428+ .get("dpop-nonce")
429429+ .and_then(|v| v.to_str().ok())
430430+ {
431431+ *last_nonce.borrow_mut() = Some(nonce_val.to_string());
432432+ }
433433+434434+ Ok(resp)
435435+ }
436436+ }
314437 }
315438}
316439
+4-1
src/remote_helper.rs
···219219 // resolve authentication
220220 let auth = auth::resolve_auth(handle, &pds_url).await?;
221221222222- let client = PdsClient::with_auth(&pds_url, &auth.access_jwt);
222222+ let client = match auth.dpop_key {
223223+ Some(key) => PdsClient::with_dpop_auth(&pds_url, &auth.access_jwt, key),
224224+ None => PdsClient::with_auth(&pds_url, &auth.access_jwt),
225225+ };
223226224227 // determine repo path from cwd
225228 let repo_path =