···244244 shutil.rmtree(repo_dir, ignore_errors=True)
245245246246247247+def test_oauth_token_refresh(test_config, cargo_binary, auth_config_dir, pds_tokens):
248248+ """Test that expired OAuth/DPoP tokens are auto-refreshed during push.
249249+250250+ Modifies the stored credential's token_expiry to the past, then pushes.
251251+ resolve_auth should detect the expired token, refresh it via the
252252+ authorization server, and the push should succeed with the new token.
253253+ """
254254+ handle = test_config["handle"]
255255+ debug_dir = cargo_binary["debug_dir"]
256256+257257+ # read the stored auth.json and expire the token
258258+ import json
259259+ auth_json_path = os.path.join(
260260+ auth_config_dir, ".config", "pds-git-remote", "auth.json"
261261+ )
262262+ assert os.path.isfile(auth_json_path), (
263263+ f"auth.json not found — test_oauth_login must run first"
264264+ )
265265+266266+ with open(auth_json_path) as f:
267267+ auth_config = json.load(f)
268268+269269+ cred = auth_config["credentials"].get(handle)
270270+ assert cred is not None, f"No credential for {handle}"
271271+ assert cred.get("dpop_key") is not None, "Credential missing dpop_key"
272272+273273+ # save the original access_jwt so we can verify it changed
274274+ original_access_jwt = cred["access_jwt"]
275275+276276+ # set token_expiry to the past to force a refresh
277277+ cred["token_expiry"] = 1_000_000_000 # 2001 — well in the past
278278+ auth_config["credentials"][handle] = cred
279279+280280+ with open(auth_json_path, "w") as f:
281281+ json.dump(auth_config, f, indent=2)
282282+283283+ # create a repo and push — this should trigger token refresh
284284+ repo_name = f"playwright-refresh-{int(time.time())}"
285285+ repo_dir = tempfile.mkdtemp(prefix="pds-git-remote-refresh-test-")
286286+ env = os.environ.copy()
287287+ env["HOME"] = auth_config_dir
288288+ env["PATH"] = debug_dir + ":" + env.get("PATH", "")
289289+ env["PDS_URL"] = test_config["pds"]
290290+ env["RUST_LOG"] = "debug"
291291+292292+ try:
293293+ # init repo
294294+ subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True,
295295+ capture_output=True)
296296+ subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_dir,
297297+ check=True, capture_output=True)
298298+ subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir,
299299+ check=True, capture_output=True)
300300+301301+ # create test file and commit
302302+ test_file = os.path.join(repo_dir, "refresh-test.txt")
303303+ with open(test_file, "w") as f:
304304+ f.write(f"Token refresh test {repo_name}\n")
305305+306306+ subprocess.run(["git", "add", "refresh-test.txt"], cwd=repo_dir, check=True,
307307+ capture_output=True)
308308+ subprocess.run(["git", "commit", "-m", "refresh test"], cwd=repo_dir,
309309+ check=True, capture_output=True)
310310+311311+ # add PDS remote and push
312312+ pds_remote = f"pds://{handle}/{repo_name}"
313313+ subprocess.run(["git", "remote", "add", "origin", pds_remote], cwd=repo_dir,
314314+ check=True, capture_output=True)
315315+316316+ result = subprocess.run(
317317+ ["git", "push", "origin", "main"],
318318+ cwd=repo_dir, env=env, capture_output=True, text=True, timeout=60,
319319+ )
320320+ assert result.returncode == 0, (
321321+ f"Push with expired token failed (should have refreshed):\n"
322322+ f"stdout: {result.stdout}\nstderr: {result.stderr}"
323323+ )
324324+ print(f"Push after token refresh successful to {pds_remote}")
325325+326326+ # verify the stored credential was updated with a fresh token
327327+ with open(auth_json_path) as f:
328328+ updated_config = json.load(f)
329329+330330+ updated_cred = updated_config["credentials"].get(handle)
331331+ assert updated_cred is not None
332332+333333+ assert updated_cred["access_jwt"] != original_access_jwt, (
334334+ "access_jwt should have changed after refresh"
335335+ )
336336+ assert updated_cred["token_expiry"] > 1_000_000_000, (
337337+ f"token_expiry should be in the future, got {updated_cred['token_expiry']}"
338338+ )
339339+ assert updated_cred.get("dpop_key") is not None, (
340340+ "dpop_key should still be present after refresh"
341341+ )
342342+ print("Token refresh verified: new access_jwt and updated expiry stored")
343343+344344+ finally:
345345+ import shutil
346346+ shutil.rmtree(repo_dir, ignore_errors=True)
347347+348348+247349def test_oauth_incremental_push(test_config, cargo_binary, auth_config_dir, pds_tokens):
248350 """Test incremental push (second commit) using OAuth/DPoP credentials."""
249351 handle = test_config["handle"]
+167-3
src/auth.rs
···11111212use atproto_identity::key::KeyData;
13131414+use crate::dpop;
1515+use crate::oauth_login;
1416use crate::pds_client::PdsClient;
15171618/// Stored credential for a single AT Protocol account.
···100102 let mut client = PdsClient::new(pds_url);
101103 let session = client.login(handle, password).await?;
102104105105+ // bearer tokens from createSession don't include expires_in,
106106+ // so use a conservative 90-minute TTL
107107+ let token_expiry = std::time::SystemTime::now()
108108+ .duration_since(std::time::UNIX_EPOCH)
109109+ .map(|d| d.as_secs() as i64 + 90 * 60)
110110+ .ok();
111111+103112 let cred = StoredCredential {
104113 pds_url: pds_url.to_string(),
105114 handle: session.handle.clone(),
···108117 refresh_jwt: session.refresh_jwt,
109118 dpop_key: None,
110119 signing_key: None,
111111- token_expiry: None,
120120+ token_expiry,
112121 };
113122114123 // save to config
···155164 });
156165 }
157166158158- // 3. stored credential
167167+ // 3. stored credential (with auto-refresh if expired)
159168 if let Some(cred) = get_credential(handle)? {
169169+ let cred = refresh_credential_if_expired(handle, cred).await?;
170170+160171 // if the credential has a DPoP key, use DPoP auth
161172 let dpop_key = cred
162173 .dpop_key
163174 .as_deref()
164164- .map(|k| crate::dpop::parse_dpop_key(k))
175175+ .map(|k| dpop::parse_dpop_key(k))
165176 .transpose()?;
166177167178 return Ok(ResolvedAuth {
···179190 ))
180191}
181192193193+/// Checks whether a stored credential needs refreshing and refreshes it if so.
194194+///
195195+/// Returns the credential unchanged if the token is still valid (more than
196196+/// 60 seconds until expiry), or refreshes it using the appropriate method:
197197+/// - DPoP credentials → OAuth token refresh via authorization server
198198+/// - Bearer credentials → `com.atproto.server.refreshSession`
199199+async fn refresh_credential_if_expired(
200200+ handle: &str,
201201+ cred: StoredCredential,
202202+) -> Result<StoredCredential, String> {
203203+ let now = std::time::SystemTime::now()
204204+ .duration_since(std::time::UNIX_EPOCH)
205205+ .map(|d| d.as_secs() as i64)
206206+ .unwrap_or(0);
207207+208208+ // skip refresh if token_expiry is missing or more than 60s in the future
209209+ match cred.token_expiry {
210210+ Some(expiry) if expiry - now < 60 => {
211211+ tracing::info!("access token expired or expiring soon, refreshing...");
212212+ }
213213+ _ => return Ok(cred),
214214+ }
215215+216216+ // pick refresh strategy based on auth type
217217+ let refreshed = if cred.dpop_key.is_some() {
218218+ refresh_oauth_credential(&cred).await
219219+ } else {
220220+ refresh_bearer_credential(&cred).await
221221+ };
222222+223223+ match refreshed {
224224+ Ok(new_cred) => {
225225+ // persist the refreshed credential
226226+ let mut config = load_config()?;
227227+ config
228228+ .credentials
229229+ .insert(handle.to_string(), new_cred.clone());
230230+ save_config(&config)?;
231231+ tracing::info!("token refreshed successfully");
232232+ Ok(new_cred)
233233+ }
234234+ Err(e) => {
235235+ tracing::warn!("token refresh failed: {}", e);
236236+ // return the original credential — the caller will get an auth
237237+ // error from the PDS if the token is truly expired
238238+ Ok(cred)
239239+ }
240240+ }
241241+}
242242+243243+/// Refreshes a Bearer token credential via `com.atproto.server.refreshSession`.
244244+async fn refresh_bearer_credential(cred: &StoredCredential) -> Result<StoredCredential, String> {
245245+ let http = reqwest::Client::new();
246246+ let resp = PdsClient::refresh_session(&http, &cred.pds_url, &cred.refresh_jwt).await?;
247247+248248+ // conservative 90-minute TTL for bearer tokens
249249+ let token_expiry = std::time::SystemTime::now()
250250+ .duration_since(std::time::UNIX_EPOCH)
251251+ .map(|d| d.as_secs() as i64 + 90 * 60)
252252+ .ok();
253253+254254+ Ok(StoredCredential {
255255+ pds_url: cred.pds_url.clone(),
256256+ handle: cred.handle.clone(),
257257+ did: resp.did,
258258+ access_jwt: resp.access_jwt,
259259+ refresh_jwt: resp.refresh_jwt,
260260+ dpop_key: None,
261261+ signing_key: None,
262262+ token_expiry,
263263+ })
264264+}
265265+266266+/// Refreshes an OAuth/DPoP credential via the authorization server's token endpoint.
267267+async fn refresh_oauth_credential(cred: &StoredCredential) -> Result<StoredCredential, String> {
268268+ use atproto_oauth::resources;
269269+ use atproto_oauth::workflow;
270270+271271+ let dpop_key_json = cred
272272+ .dpop_key
273273+ .as_deref()
274274+ .ok_or("OAuth credential missing DPoP key")?;
275275+ let dpop_key_data = dpop::parse_dpop_key(dpop_key_json)?;
276276+277277+ // build loopback client matching the one used during login
278278+ let oauth_client = oauth_login::build_loopback_client(8271);
279279+280280+ // discover authorization server from PDS
281281+ let http = reqwest::Client::new();
282282+ let (_resource, auth_server) = resources::pds_resources(&http, &cred.pds_url)
283283+ .await
284284+ .map_err(|e| format!("failed to discover auth server for refresh: {}", e))?;
285285+286286+ // exchange refresh token for new tokens
287287+ let token_response = workflow::oauth_refresh_with_auth_server(
288288+ &http,
289289+ &oauth_client,
290290+ &dpop_key_data,
291291+ &cred.refresh_jwt,
292292+ &auth_server,
293293+ )
294294+ .await
295295+ .map_err(|e| format!("OAuth token refresh failed: {}", e))?;
296296+297297+ // compute new token expiry
298298+ let token_expiry = std::time::SystemTime::now()
299299+ .duration_since(std::time::UNIX_EPOCH)
300300+ .map(|d| d.as_secs() as i64 + token_response.expires_in as i64)
301301+ .ok();
302302+303303+ Ok(StoredCredential {
304304+ pds_url: cred.pds_url.clone(),
305305+ handle: cred.handle.clone(),
306306+ did: token_response.sub.unwrap_or_else(|| cred.did.clone()),
307307+ access_jwt: token_response.access_token,
308308+ refresh_jwt: token_response
309309+ .refresh_token
310310+ .unwrap_or_else(|| cred.refresh_jwt.clone()),
311311+ dpop_key: cred.dpop_key.clone(),
312312+ signing_key: cred.signing_key.clone(),
313313+ token_expiry,
314314+ })
315315+}
316316+182317/// Removes a stored credential by handle.
183318pub fn logout(handle: &str) -> Result<bool, String> {
184319 let mut config = load_config()?;
···224359 let json = "{}";
225360 let config: AuthConfig = serde_json::from_str(json).unwrap();
226361 assert!(config.credentials.is_empty());
362362+ }
363363+364364+ #[test]
365365+ fn token_expiry_serialization() {
366366+ let cred = StoredCredential {
367367+ pds_url: "http://localhost:3000".to_string(),
368368+ handle: "alice.test".to_string(),
369369+ did: "did:plc:abc123".to_string(),
370370+ access_jwt: "jwt-access".to_string(),
371371+ refresh_jwt: "jwt-refresh".to_string(),
372372+ dpop_key: Some(r#"{"kty":"EC","crv":"P-256"}"#.to_string()),
373373+ signing_key: None,
374374+ token_expiry: Some(1700000000),
375375+ };
376376+377377+ let json = serde_json::to_string(&cred).unwrap();
378378+ assert!(json.contains("\"token_expiry\":1700000000"));
379379+380380+ let parsed: StoredCredential = serde_json::from_str(&json).unwrap();
381381+ assert_eq!(parsed.token_expiry, Some(1700000000));
382382+ assert!(parsed.dpop_key.is_some());
383383+ }
384384+385385+ #[test]
386386+ fn missing_token_expiry_defaults_to_none() {
387387+ let json = r#"{"pds_url":"http://localhost","handle":"test","did":"did:plc:x","access_jwt":"a","refresh_jwt":"r"}"#;
388388+ let cred: StoredCredential = serde_json::from_str(json).unwrap();
389389+ assert_eq!(cred.token_expiry, None);
390390+ assert_eq!(cred.dpop_key, None);
227391 }
228392}
+24-18
src/oauth_login.rs
···2121use atproto_oauth::resources;
2222use atproto_oauth::workflow::{self, OAuthClient, OAuthRequest, OAuthRequestState};
23232424+/// Builds a loopback OAuthClient for AT Protocol OAuth.
2525+///
2626+/// AT Protocol uses `http://localhost` as a special loopback client_id.
2727+/// The PDS generates virtual client metadata for it — no need to serve our own.
2828+/// Scopes are specified via query parameter on the client_id URL.
2929+/// Redirect URI must be `http://127.0.0.1/` (root path, any port is accepted).
3030+pub fn build_loopback_client(port: u16) -> OAuthClient {
3131+ let scope = "atproto transition:generic";
3232+ let redirect_uri = format!("http://127.0.0.1:{}/", port);
3333+ let client_id = format!(
3434+ "http://localhost?scope={}&redirect_uri={}",
3535+ percent_encode(scope),
3636+ percent_encode(&redirect_uri),
3737+ );
3838+ OAuthClient {
3939+ redirect_uri,
4040+ client_id,
4141+ // public client — no signing key, no client_assertion
4242+ private_signing_key_data: None,
4343+ }
4444+}
4545+2446/// Runs the full OAuth login flow for a handle.
2547///
2648/// Starts a localhost server, opens the browser, waits for the user to
···4365 .map_err(|e| format!("failed to generate DPoP key: {}", e))?;
44664567 // 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).
6868+ let oauth_client = build_loopback_client(port);
5069 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- };
64706571 // 5. generate PKCE challenge
6672 let (code_verifier, code_challenge) = pkce::generate();
···9197 let auth_url = format!(
9298 "{}?client_id={}&request_uri={}",
9399 auth_server.authorization_endpoint,
9494- percent_encode(&client_id),
100100+ percent_encode(&oauth_client.client_id),
95101 percent_encode(&par_response.request_uri),
96102 );
97103
+42
src/pds_client.rs
···9595 pub handle: String,
9696}
97979898+/// Response from `com.atproto.server.refreshSession`.
9999+#[derive(Debug, Deserialize)]
100100+pub struct RefreshSessionResponse {
101101+ pub did: String,
102102+ #[serde(rename = "accessJwt")]
103103+ pub access_jwt: String,
104104+ #[serde(rename = "refreshJwt")]
105105+ pub refresh_jwt: String,
106106+ pub handle: String,
107107+}
108108+98109/// Error response from PDS XRPC endpoints.
99110#[derive(Debug, Deserialize)]
100111pub struct XrpcError {
···190201191202 self.auth = AuthMode::Bearer(session.access_jwt.clone());
192203 Ok(session)
204204+ }
205205+206206+ /// Refreshes a Bearer session using a refresh JWT.
207207+ ///
208208+ /// Calls `com.atproto.server.refreshSession` with the refresh token
209209+ /// in the Authorization header. Returns new access and refresh JWTs.
210210+ pub async fn refresh_session(
211211+ http: &reqwest::Client,
212212+ base_url: &str,
213213+ refresh_jwt: &str,
214214+ ) -> Result<RefreshSessionResponse, String> {
215215+ let url = format!(
216216+ "{}/xrpc/com.atproto.server.refreshSession",
217217+ base_url.trim_end_matches('/')
218218+ );
219219+220220+ let resp = http
221221+ .post(&url)
222222+ .bearer_auth(refresh_jwt)
223223+ .send()
224224+ .await
225225+ .map_err(|e| format!("refreshSession request failed: {}", e))?;
226226+227227+ if !resp.status().is_success() {
228228+ let err = parse_xrpc_error(resp).await;
229229+ return Err(format!("refreshSession failed: {}", err));
230230+ }
231231+232232+ resp.json()
233233+ .await
234234+ .map_err(|e| format!("failed to parse refreshSession response: {}", e))
193235 }
194236195237 /// Fetches a record from the PDS.
+78
tests/e2e_tests.rs
···8899use std::path::Path;
10101111+use pds_git_remote::auth::{self, AuthConfig, StoredCredential};
1112use pds_git_remote::fetch::{FetchResult, clone_repo, fetch_repo};
1213use pds_git_remote::pds_client::PdsClient;
1314use pds_git_remote::push::{PushResult, push};
···557558 assert_eq!(readme, "# Remote Helper Test");
558559 let app_rs = read_file(dest.path(), "src/app.rs").await;
559560 assert_eq!(app_rs, "fn app() {}");
561561+}
562562+563563+/// Bearer token refresh: stores a credential with an expired token_expiry,
564564+/// calls resolve_auth, verifies the token was refreshed and still works.
565565+#[tokio::test]
566566+async fn e2e_bearer_token_refresh() {
567567+ require_pds!();
568568+569569+ // login to get valid tokens including a refresh_jwt
570570+ let mut client = PdsClient::new(PDS_URL);
571571+ let session = client.login(TEST_HANDLE, TEST_PASSWORD).await.unwrap();
572572+ let original_access_jwt = session.access_jwt.clone();
573573+574574+ // set up an isolated config directory so we don't touch real config
575575+ let config_dir = tempfile::tempdir().unwrap();
576576+ let original_home = std::env::var("HOME").ok();
577577+ unsafe { std::env::set_var("HOME", config_dir.path()) };
578578+579579+ // store credential with a token_expiry in the past (forces refresh)
580580+ let expired_cred = StoredCredential {
581581+ pds_url: PDS_URL.to_string(),
582582+ handle: TEST_HANDLE.to_string(),
583583+ did: session.did.clone(),
584584+ access_jwt: original_access_jwt.clone(),
585585+ refresh_jwt: session.refresh_jwt.clone(),
586586+ dpop_key: None,
587587+ signing_key: None,
588588+ token_expiry: Some(1_000_000_000), // 2001 — well in the past
589589+ };
590590+591591+ let mut config = AuthConfig::default();
592592+ config
593593+ .credentials
594594+ .insert(TEST_HANDLE.to_string(), expired_cred);
595595+ auth::save_config(&config).unwrap();
596596+597597+ // resolve_auth should detect the expired token and refresh it
598598+ let resolved = auth::resolve_auth(TEST_HANDLE, PDS_URL).await.unwrap();
599599+600600+ // the access_jwt should be different (refreshed)
601601+ assert_ne!(
602602+ resolved.access_jwt, original_access_jwt,
603603+ "token should have been refreshed"
604604+ );
605605+ assert!(
606606+ resolved.dpop_key.is_none(),
607607+ "bearer refresh should not produce a DPoP key"
608608+ );
609609+610610+ // verify the refreshed token actually works on the PDS
611611+ let refreshed_client = PdsClient::with_auth(PDS_URL, &resolved.access_jwt);
612612+ let blob_result = refreshed_client
613613+ .upload_blob(b"bearer refresh test".to_vec())
614614+ .await;
615615+ assert!(
616616+ blob_result.is_ok(),
617617+ "refreshed token should work for PDS requests: {:?}",
618618+ blob_result.err()
619619+ );
620620+621621+ // verify the stored credential was updated on disk
622622+ let updated_config = auth::load_config().unwrap();
623623+ let updated_cred = updated_config.credentials.get(TEST_HANDLE).unwrap();
624624+ assert_ne!(
625625+ updated_cred.access_jwt, original_access_jwt,
626626+ "stored access_jwt should be updated"
627627+ );
628628+ assert!(
629629+ updated_cred.token_expiry.unwrap() > 1_000_000_000,
630630+ "stored token_expiry should be updated to the future"
631631+ );
632632+633633+ // restore HOME
634634+ match original_home {
635635+ Some(h) => unsafe { std::env::set_var("HOME", h) },
636636+ None => unsafe { std::env::remove_var("HOME") },
637637+ }
560638}
561639562640/// Fetch with no new commits returns AlreadyUpToDate.