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

Configure Feed

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

fix dependency

notplants 437495e3 db311193

+415 -22
+1
Cargo.lock
··· 165 165 [[package]] 166 166 name = "atproto-oauth" 167 167 version = "0.14.0" 168 + source = "git+https://tangled.org/notplants.bsky.social/lichen-atproto-oauth#7575fcd5965af993f86d4a694fa8a80ca95cc316" 168 169 dependencies = [ 169 170 "anyhow", 170 171 "async-trait",
+1 -1
Cargo.toml
··· 11 11 12 12 [dependencies] 13 13 atproto-identity = "0.14.0" 14 - atproto-oauth = { path = "../atproto-oauth", default-features = false } 14 + atproto-oauth = { git = "https://tangled.org/notplants.bsky.social/lichen-atproto-oauth", default-features = false } 15 15 axum = "0.8" 16 16 chrono = "0.4" 17 17 clap = { version = "4.5", features = ["derive"] }
e2e-testing.md plans/e2e-testing.md
lexicon-plan.md plans/lexicon-plan.md
oauth-plan.md plans/oauth-plan.md
plan.md plans/plan.md
+102
playwright-test/test_oauth_push.py
··· 244 244 shutil.rmtree(repo_dir, ignore_errors=True) 245 245 246 246 247 + def test_oauth_token_refresh(test_config, cargo_binary, auth_config_dir, pds_tokens): 248 + """Test that expired OAuth/DPoP tokens are auto-refreshed during push. 249 + 250 + Modifies the stored credential's token_expiry to the past, then pushes. 251 + resolve_auth should detect the expired token, refresh it via the 252 + authorization server, and the push should succeed with the new token. 253 + """ 254 + handle = test_config["handle"] 255 + debug_dir = cargo_binary["debug_dir"] 256 + 257 + # read the stored auth.json and expire the token 258 + import json 259 + auth_json_path = os.path.join( 260 + auth_config_dir, ".config", "pds-git-remote", "auth.json" 261 + ) 262 + assert os.path.isfile(auth_json_path), ( 263 + f"auth.json not found — test_oauth_login must run first" 264 + ) 265 + 266 + with open(auth_json_path) as f: 267 + auth_config = json.load(f) 268 + 269 + cred = auth_config["credentials"].get(handle) 270 + assert cred is not None, f"No credential for {handle}" 271 + assert cred.get("dpop_key") is not None, "Credential missing dpop_key" 272 + 273 + # save the original access_jwt so we can verify it changed 274 + original_access_jwt = cred["access_jwt"] 275 + 276 + # set token_expiry to the past to force a refresh 277 + cred["token_expiry"] = 1_000_000_000 # 2001 — well in the past 278 + auth_config["credentials"][handle] = cred 279 + 280 + with open(auth_json_path, "w") as f: 281 + json.dump(auth_config, f, indent=2) 282 + 283 + # create a repo and push — this should trigger token refresh 284 + repo_name = f"playwright-refresh-{int(time.time())}" 285 + repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-refresh-test-") 286 + env = os.environ.copy() 287 + env["HOME"] = auth_config_dir 288 + env["PATH"] = debug_dir + ":" + env.get("PATH", "") 289 + env["PDS_URL"] = test_config["pds"] 290 + env["RUST_LOG"] = "debug" 291 + 292 + try: 293 + # init repo 294 + subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, 295 + capture_output=True) 296 + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir, 297 + check=True, capture_output=True) 298 + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, 299 + check=True, capture_output=True) 300 + 301 + # create test file and commit 302 + test_file = os.path.join(repo_dir, "refresh-test.txt") 303 + with open(test_file, "w") as f: 304 + f.write(f"Token refresh test {repo_name}\n") 305 + 306 + subprocess.run(["git", "add", "refresh-test.txt"], cwd=repo_dir, check=True, 307 + capture_output=True) 308 + subprocess.run(["git", "commit", "-m", "refresh test"], cwd=repo_dir, 309 + check=True, capture_output=True) 310 + 311 + # add PDS remote and push 312 + pds_remote = f"pds://{handle}/{repo_name}" 313 + subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir, 314 + check=True, capture_output=True) 315 + 316 + result = subprocess.run( 317 + ["git", "push", "origin", "main"], 318 + cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60, 319 + ) 320 + assert result.returncode == 0, ( 321 + f"Push with expired token failed (should have refreshed):\n" 322 + f"stdout: {result.stdout}\nstderr: {result.stderr}" 323 + ) 324 + print(f"Push after token refresh successful to {pds_remote}") 325 + 326 + # verify the stored credential was updated with a fresh token 327 + with open(auth_json_path) as f: 328 + updated_config = json.load(f) 329 + 330 + updated_cred = updated_config["credentials"].get(handle) 331 + assert updated_cred is not None 332 + 333 + assert updated_cred["access_jwt"] != original_access_jwt, ( 334 + "access_jwt should have changed after refresh" 335 + ) 336 + assert updated_cred["token_expiry"] > 1_000_000_000, ( 337 + f"token_expiry should be in the future, got {updated_cred['token_expiry']}" 338 + ) 339 + assert updated_cred.get("dpop_key") is not None, ( 340 + "dpop_key should still be present after refresh" 341 + ) 342 + print("Token refresh verified: new access_jwt and updated expiry stored") 343 + 344 + finally: 345 + import shutil 346 + shutil.rmtree(repo_dir, ignore_errors=True) 347 + 348 + 247 349 def test_oauth_incremental_push(test_config, cargo_binary, auth_config_dir, pds_tokens): 248 350 """Test incremental push (second commit) using OAuth/DPoP credentials.""" 249 351 handle = test_config["handle"]
+167 -3
src/auth.rs
··· 11 11 12 12 use atproto_identity::key::KeyData; 13 13 14 + use crate::dpop; 15 + use crate::oauth_login; 14 16 use crate::pds_client::PdsClient; 15 17 16 18 /// Stored credential for a single AT Protocol account. ··· 100 102 let mut client = PdsClient::new(pds_url); 101 103 let session = client.login(handle, password).await?; 102 104 105 + // bearer tokens from createSession don't include expires_in, 106 + // so use a conservative 90-minute TTL 107 + let token_expiry = std::time::SystemTime::now() 108 + .duration_since(std::time::UNIX_EPOCH) 109 + .map(|d| d.as_secs() as i64 + 90 * 60) 110 + .ok(); 111 + 103 112 let cred = StoredCredential { 104 113 pds_url: pds_url.to_string(), 105 114 handle: session.handle.clone(), ··· 108 117 refresh_jwt: session.refresh_jwt, 109 118 dpop_key: None, 110 119 signing_key: None, 111 - token_expiry: None, 120 + token_expiry, 112 121 }; 113 122 114 123 // save to config ··· 155 164 }); 156 165 } 157 166 158 - // 3. stored credential 167 + // 3. stored credential (with auto-refresh if expired) 159 168 if let Some(cred) = get_credential(handle)? { 169 + let cred = refresh_credential_if_expired(handle, cred).await?; 170 + 160 171 // if the credential has a DPoP key, use DPoP auth 161 172 let dpop_key = cred 162 173 .dpop_key 163 174 .as_deref() 164 - .map(|k| crate::dpop::parse_dpop_key(k)) 175 + .map(|k| dpop::parse_dpop_key(k)) 165 176 .transpose()?; 166 177 167 178 return Ok(ResolvedAuth { ··· 179 190 )) 180 191 } 181 192 193 + /// Checks whether a stored credential needs refreshing and refreshes it if so. 194 + /// 195 + /// Returns the credential unchanged if the token is still valid (more than 196 + /// 60 seconds until expiry), or refreshes it using the appropriate method: 197 + /// - DPoP credentials → OAuth token refresh via authorization server 198 + /// - Bearer credentials → `com.atproto.server.refreshSession` 199 + async fn refresh_credential_if_expired( 200 + handle: &str, 201 + cred: StoredCredential, 202 + ) -> Result<StoredCredential, String> { 203 + let now = std::time::SystemTime::now() 204 + .duration_since(std::time::UNIX_EPOCH) 205 + .map(|d| d.as_secs() as i64) 206 + .unwrap_or(0); 207 + 208 + // skip refresh if token_expiry is missing or more than 60s in the future 209 + match cred.token_expiry { 210 + Some(expiry) if expiry - now < 60 => { 211 + tracing::info!("access token expired or expiring soon, refreshing..."); 212 + } 213 + _ => return Ok(cred), 214 + } 215 + 216 + // pick refresh strategy based on auth type 217 + let refreshed = if cred.dpop_key.is_some() { 218 + refresh_oauth_credential(&cred).await 219 + } else { 220 + refresh_bearer_credential(&cred).await 221 + }; 222 + 223 + match refreshed { 224 + Ok(new_cred) => { 225 + // persist the refreshed credential 226 + let mut config = load_config()?; 227 + config 228 + .credentials 229 + .insert(handle.to_string(), new_cred.clone()); 230 + save_config(&config)?; 231 + tracing::info!("token refreshed successfully"); 232 + Ok(new_cred) 233 + } 234 + Err(e) => { 235 + tracing::warn!("token refresh failed: {}", e); 236 + // return the original credential — the caller will get an auth 237 + // error from the PDS if the token is truly expired 238 + Ok(cred) 239 + } 240 + } 241 + } 242 + 243 + /// Refreshes a Bearer token credential via `com.atproto.server.refreshSession`. 244 + async fn refresh_bearer_credential(cred: &StoredCredential) -> Result<StoredCredential, String> { 245 + let http = reqwest::Client::new(); 246 + let resp = PdsClient::refresh_session(&http, &cred.pds_url, &cred.refresh_jwt).await?; 247 + 248 + // conservative 90-minute TTL for bearer tokens 249 + let token_expiry = std::time::SystemTime::now() 250 + .duration_since(std::time::UNIX_EPOCH) 251 + .map(|d| d.as_secs() as i64 + 90 * 60) 252 + .ok(); 253 + 254 + Ok(StoredCredential { 255 + pds_url: cred.pds_url.clone(), 256 + handle: cred.handle.clone(), 257 + did: resp.did, 258 + access_jwt: resp.access_jwt, 259 + refresh_jwt: resp.refresh_jwt, 260 + dpop_key: None, 261 + signing_key: None, 262 + token_expiry, 263 + }) 264 + } 265 + 266 + /// Refreshes an OAuth/DPoP credential via the authorization server's token endpoint. 267 + async fn refresh_oauth_credential(cred: &StoredCredential) -> Result<StoredCredential, String> { 268 + use atproto_oauth::resources; 269 + use atproto_oauth::workflow; 270 + 271 + let dpop_key_json = cred 272 + .dpop_key 273 + .as_deref() 274 + .ok_or("OAuth credential missing DPoP key")?; 275 + let dpop_key_data = dpop::parse_dpop_key(dpop_key_json)?; 276 + 277 + // build loopback client matching the one used during login 278 + let oauth_client = oauth_login::build_loopback_client(8271); 279 + 280 + // discover authorization server from PDS 281 + let http = reqwest::Client::new(); 282 + let (_resource, auth_server) = resources::pds_resources(&http, &cred.pds_url) 283 + .await 284 + .map_err(|e| format!("failed to discover auth server for refresh: {}", e))?; 285 + 286 + // exchange refresh token for new tokens 287 + let token_response = workflow::oauth_refresh_with_auth_server( 288 + &http, 289 + &oauth_client, 290 + &dpop_key_data, 291 + &cred.refresh_jwt, 292 + &auth_server, 293 + ) 294 + .await 295 + .map_err(|e| format!("OAuth token refresh failed: {}", e))?; 296 + 297 + // compute new token expiry 298 + let token_expiry = std::time::SystemTime::now() 299 + .duration_since(std::time::UNIX_EPOCH) 300 + .map(|d| d.as_secs() as i64 + token_response.expires_in as i64) 301 + .ok(); 302 + 303 + Ok(StoredCredential { 304 + pds_url: cred.pds_url.clone(), 305 + handle: cred.handle.clone(), 306 + did: token_response.sub.unwrap_or_else(|| cred.did.clone()), 307 + access_jwt: token_response.access_token, 308 + refresh_jwt: token_response 309 + .refresh_token 310 + .unwrap_or_else(|| cred.refresh_jwt.clone()), 311 + dpop_key: cred.dpop_key.clone(), 312 + signing_key: cred.signing_key.clone(), 313 + token_expiry, 314 + }) 315 + } 316 + 182 317 /// Removes a stored credential by handle. 183 318 pub fn logout(handle: &str) -> Result<bool, String> { 184 319 let mut config = load_config()?; ··· 224 359 let json = "{}"; 225 360 let config: AuthConfig = serde_json::from_str(json).unwrap(); 226 361 assert!(config.credentials.is_empty()); 362 + } 363 + 364 + #[test] 365 + fn token_expiry_serialization() { 366 + let cred = StoredCredential { 367 + pds_url: "http://localhost:3000".to_string(), 368 + handle: "alice.test".to_string(), 369 + did: "did:plc:abc123".to_string(), 370 + access_jwt: "jwt-access".to_string(), 371 + refresh_jwt: "jwt-refresh".to_string(), 372 + dpop_key: Some(r#"{"kty":"EC","crv":"P-256"}"#.to_string()), 373 + signing_key: None, 374 + token_expiry: Some(1700000000), 375 + }; 376 + 377 + let json = serde_json::to_string(&cred).unwrap(); 378 + assert!(json.contains("\"token_expiry\":1700000000")); 379 + 380 + let parsed: StoredCredential = serde_json::from_str(&json).unwrap(); 381 + assert_eq!(parsed.token_expiry, Some(1700000000)); 382 + assert!(parsed.dpop_key.is_some()); 383 + } 384 + 385 + #[test] 386 + fn missing_token_expiry_defaults_to_none() { 387 + let json = r#"{"pds_url":"http://localhost","handle":"test","did":"did:plc:x","access_jwt":"a","refresh_jwt":"r"}"#; 388 + let cred: StoredCredential = serde_json::from_str(json).unwrap(); 389 + assert_eq!(cred.token_expiry, None); 390 + assert_eq!(cred.dpop_key, None); 227 391 } 228 392 }
+24 -18
src/oauth_login.rs
··· 21 21 use atproto_oauth::resources; 22 22 use atproto_oauth::workflow::{self, OAuthClient, OAuthRequest, OAuthRequestState}; 23 23 24 + /// Builds a loopback OAuthClient for AT Protocol OAuth. 25 + /// 26 + /// AT Protocol uses `http://localhost` as a special loopback client_id. 27 + /// The PDS generates virtual client metadata for it — no need to serve our own. 28 + /// Scopes are specified via query parameter on the client_id URL. 29 + /// Redirect URI must be `http://127.0.0.1/` (root path, any port is accepted). 30 + pub fn build_loopback_client(port: u16) -> OAuthClient { 31 + let scope = "atproto transition:generic"; 32 + let redirect_uri = format!("http://127.0.0.1:{}/", port); 33 + let client_id = format!( 34 + "http://localhost?scope={}&redirect_uri={}", 35 + percent_encode(scope), 36 + percent_encode(&redirect_uri), 37 + ); 38 + OAuthClient { 39 + redirect_uri, 40 + client_id, 41 + // public client — no signing key, no client_assertion 42 + private_signing_key_data: None, 43 + } 44 + } 45 + 24 46 /// Runs the full OAuth login flow for a handle. 25 47 /// 26 48 /// Starts a localhost server, opens the browser, waits for the user to ··· 43 65 .map_err(|e| format!("failed to generate DPoP key: {}", e))?; 44 66 45 67 // 4. build loopback client config 46 - // AT Protocol uses http://localhost as a special loopback client_id. 47 - // The PDS generates virtual client metadata for it — no need to serve our own. 48 - // Scopes are specified via query parameter on the client_id URL. 49 - // Redirect URI must be http://127.0.0.1/ (root path, any port is accepted). 68 + let oauth_client = build_loopback_client(port); 50 69 let scope = "atproto transition:generic"; 51 - let client_id = format!( 52 - "http://localhost?scope={}&redirect_uri={}", 53 - percent_encode(scope), 54 - percent_encode(&format!("http://127.0.0.1:{}/", port)), 55 - ); 56 - let redirect_uri = format!("http://127.0.0.1:{}/", port); 57 - 58 - let oauth_client = OAuthClient { 59 - redirect_uri: redirect_uri.clone(), 60 - client_id: client_id.clone(), 61 - // public client — no signing key, no client_assertion 62 - private_signing_key_data: None, 63 - }; 64 70 65 71 // 5. generate PKCE challenge 66 72 let (code_verifier, code_challenge) = pkce::generate(); ··· 91 97 let auth_url = format!( 92 98 "{}?client_id={}&request_uri={}", 93 99 auth_server.authorization_endpoint, 94 - percent_encode(&client_id), 100 + percent_encode(&oauth_client.client_id), 95 101 percent_encode(&par_response.request_uri), 96 102 ); 97 103
+42
src/pds_client.rs
··· 95 95 pub handle: String, 96 96 } 97 97 98 + /// Response from `com.atproto.server.refreshSession`. 99 + #[derive(Debug, Deserialize)] 100 + pub struct RefreshSessionResponse { 101 + pub did: String, 102 + #[serde(rename = "accessJwt")] 103 + pub access_jwt: String, 104 + #[serde(rename = "refreshJwt")] 105 + pub refresh_jwt: String, 106 + pub handle: String, 107 + } 108 + 98 109 /// Error response from PDS XRPC endpoints. 99 110 #[derive(Debug, Deserialize)] 100 111 pub struct XrpcError { ··· 190 201 191 202 self.auth = AuthMode::Bearer(session.access_jwt.clone()); 192 203 Ok(session) 204 + } 205 + 206 + /// Refreshes a Bearer session using a refresh JWT. 207 + /// 208 + /// Calls `com.atproto.server.refreshSession` with the refresh token 209 + /// in the Authorization header. Returns new access and refresh JWTs. 210 + pub async fn refresh_session( 211 + http: &reqwest::Client, 212 + base_url: &str, 213 + refresh_jwt: &str, 214 + ) -> Result<RefreshSessionResponse, String> { 215 + let url = format!( 216 + "{}/xrpc/com.atproto.server.refreshSession", 217 + base_url.trim_end_matches('/') 218 + ); 219 + 220 + let resp = http 221 + .post(&url) 222 + .bearer_auth(refresh_jwt) 223 + .send() 224 + .await 225 + .map_err(|e| format!("refreshSession request failed: {}", e))?; 226 + 227 + if !resp.status().is_success() { 228 + let err = parse_xrpc_error(resp).await; 229 + return Err(format!("refreshSession failed: {}", err)); 230 + } 231 + 232 + resp.json() 233 + .await 234 + .map_err(|e| format!("failed to parse refreshSession response: {}", e)) 193 235 } 194 236 195 237 /// Fetches a record from the PDS.
+78
tests/e2e_tests.rs
··· 8 8 9 9 use std::path::Path; 10 10 11 + use pds_git_remote::auth::{self, AuthConfig, StoredCredential}; 11 12 use pds_git_remote::fetch::{FetchResult, clone_repo, fetch_repo}; 12 13 use pds_git_remote::pds_client::PdsClient; 13 14 use pds_git_remote::push::{PushResult, push}; ··· 557 558 assert_eq!(readme, "# Remote Helper Test"); 558 559 let app_rs = read_file(dest.path(), "src/app.rs").await; 559 560 assert_eq!(app_rs, "fn app() {}"); 561 + } 562 + 563 + /// Bearer token refresh: stores a credential with an expired token_expiry, 564 + /// calls resolve_auth, verifies the token was refreshed and still works. 565 + #[tokio::test] 566 + async fn e2e_bearer_token_refresh() { 567 + require_pds!(); 568 + 569 + // login to get valid tokens including a refresh_jwt 570 + let mut client = PdsClient::new(PDS_URL); 571 + let session = client.login(TEST_HANDLE, TEST_PASSWORD).await.unwrap(); 572 + let original_access_jwt = session.access_jwt.clone(); 573 + 574 + // set up an isolated config directory so we don't touch real config 575 + let config_dir = tempfile::tempdir().unwrap(); 576 + let original_home = std::env::var("HOME").ok(); 577 + unsafe { std::env::set_var("HOME", config_dir.path()) }; 578 + 579 + // store credential with a token_expiry in the past (forces refresh) 580 + let expired_cred = StoredCredential { 581 + pds_url: PDS_URL.to_string(), 582 + handle: TEST_HANDLE.to_string(), 583 + did: session.did.clone(), 584 + access_jwt: original_access_jwt.clone(), 585 + refresh_jwt: session.refresh_jwt.clone(), 586 + dpop_key: None, 587 + signing_key: None, 588 + token_expiry: Some(1_000_000_000), // 2001 — well in the past 589 + }; 590 + 591 + let mut config = AuthConfig::default(); 592 + config 593 + .credentials 594 + .insert(TEST_HANDLE.to_string(), expired_cred); 595 + auth::save_config(&config).unwrap(); 596 + 597 + // resolve_auth should detect the expired token and refresh it 598 + let resolved = auth::resolve_auth(TEST_HANDLE, PDS_URL).await.unwrap(); 599 + 600 + // the access_jwt should be different (refreshed) 601 + assert_ne!( 602 + resolved.access_jwt, original_access_jwt, 603 + "token should have been refreshed" 604 + ); 605 + assert!( 606 + resolved.dpop_key.is_none(), 607 + "bearer refresh should not produce a DPoP key" 608 + ); 609 + 610 + // verify the refreshed token actually works on the PDS 611 + let refreshed_client = PdsClient::with_auth(PDS_URL, &resolved.access_jwt); 612 + let blob_result = refreshed_client 613 + .upload_blob(b"bearer refresh test".to_vec()) 614 + .await; 615 + assert!( 616 + blob_result.is_ok(), 617 + "refreshed token should work for PDS requests: {:?}", 618 + blob_result.err() 619 + ); 620 + 621 + // verify the stored credential was updated on disk 622 + let updated_config = auth::load_config().unwrap(); 623 + let updated_cred = updated_config.credentials.get(TEST_HANDLE).unwrap(); 624 + assert_ne!( 625 + updated_cred.access_jwt, original_access_jwt, 626 + "stored access_jwt should be updated" 627 + ); 628 + assert!( 629 + updated_cred.token_expiry.unwrap() > 1_000_000_000, 630 + "stored token_expiry should be updated to the future" 631 + ); 632 + 633 + // restore HOME 634 + match original_home { 635 + Some(h) => unsafe { std::env::set_var("HOME", h) }, 636 + None => unsafe { std::env::remove_var("HOME") }, 637 + } 560 638 } 561 639 562 640 /// Fetch with no new commits returns AlreadyUpToDate.