Something went wrong. Try again.
⚘ use your pds as a git remote if you want to ⚘
Something went wrong. Try again.
15 kB · 439 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440//! Credential storage and authentication resolution.//!//! Stores AT Protocol credentials at `~/.config/git-remote-pds/auth.json`.//! Provides `resolve_auth` for the remote helper to find credentials from//! env vars or stored config.
use std::collections::HashMap;use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use atproto_identity::key::KeyData;
use crate::dpop;use crate::oauth_login;use crate::pds_client::PdsClient;
/// Stored credential for a single AT Protocol account.#[derive(Debug, Clone, Serialize, Deserialize)]pub struct StoredCredential { pub pds_url: String, pub handle: String, pub did: String, pub access_jwt: String, pub refresh_jwt: String, /// Serialized DPoP private key JWK (present for OAuth-based logins). #[serde(default, skip_serializing_if = "Option::is_none")] pub dpop_key: Option<String>, /// Serialized signing key JWK (for OAuth client assertions). #[serde(default, skip_serializing_if = "Option::is_none")] pub signing_key: Option<String>, /// Unix timestamp when the access token expires. #[serde(default, skip_serializing_if = "Option::is_none")] pub token_expiry: Option<i64>,}
/// Top-level auth config file.#[derive(Debug, Clone, Serialize, Deserialize, Default)]pub struct AuthConfig { #[serde(default)] pub credentials: HashMap<String, StoredCredential>,}
/// Resolved auth info needed for push operations.pub struct ResolvedAuth { pub pds_url: String, pub did: String, pub access_jwt: String, /// If set, the access token is DPoP-bound and requires proof headers. pub dpop_key: Option<KeyData>,}
/// Returns the path to the auth config file.fn config_path() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); PathBuf::from(home) .join(".config") .join("git-remote-pds") .join("auth.json")}
/// Loads the auth config from disk, returning a default if the file is missing.pub fn load_config() -> Result<AuthConfig, String> { let path = config_path(); if !path.exists() { return Ok(AuthConfig::default()); }
let data = std::fs::read_to_string(&path).map_err(|e| format!("failed to read auth config: {}", e))?;
serde_json::from_str(&data).map_err(|e| format!("failed to parse auth config: {}", e))}
/// Saves the auth config to disk, creating parent directories if needed.pub fn save_config(config: &AuthConfig) -> Result<(), String> { let path = config_path();
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create config directory: {}", e))?; }
let data = serde_json::to_string_pretty(config) .map_err(|e| format!("failed to serialize auth config: {}", e))?;
std::fs::write(&path, data).map_err(|e| format!("failed to write auth config: {}", e))}
/// Looks up a stored credential by handle.pub fn get_credential(handle: &str) -> Result<Option<StoredCredential>, String> { let config = load_config()?; Ok(config.credentials.get(handle).cloned())}
/// Logs in via createSession and stores the credential.pub async fn login_and_store( pds_url: &str, handle: &str, password: &str,) -> Result<StoredCredential, String> { let mut client = PdsClient::new(pds_url); let session = client.login(handle, password).await?;
// bearer tokens from createSession don't include expires_in, // so use a conservative 90-minute TTL let token_expiry = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64 + 90 * 60) .ok();
let cred = StoredCredential { pds_url: pds_url.to_string(), handle: session.handle.clone(), did: session.did.clone(), access_jwt: session.access_jwt, refresh_jwt: session.refresh_jwt, dpop_key: None, signing_key: None, token_expiry, };
// save to config let mut config = load_config()?; config.credentials.insert(handle.to_string(), cred.clone()); save_config(&config)?;
Ok(cred)}
/// Resolves authentication for a push operation.////// Checks sources in priority order:/// 1. `PDS_ACCESS_TOKEN` + `PDS_DID` env vars (direct, no network)/// 2. `PDS_HANDLE` + `PDS_PASSWORD` env vars (login on the fly)/// 3. Stored credential from auth.json/// 4. Error: not logged inpub async fn resolve_auth(handle: &str, pds_url: &str) -> Result<ResolvedAuth, String> { // 1. direct token from env if let (Ok(token), Ok(did)) = (std::env::var("PDS_ACCESS_TOKEN"), std::env::var("PDS_DID")) { let dpop_key = std::env::var("PDS_DPOP_KEY") .ok() .map(|k| crate::dpop::parse_dpop_key(&k)) .transpose()?;
return Ok(ResolvedAuth { pds_url: pds_url.to_string(), did, access_jwt: token, dpop_key, }); }
// 2. handle + password from env → login on the fly if let (Ok(env_handle), Ok(password)) = (std::env::var("PDS_HANDLE"), std::env::var("PDS_PASSWORD")) { let cred = login_and_store(pds_url, &env_handle, &password).await?; return Ok(ResolvedAuth { pds_url: cred.pds_url, did: cred.did, access_jwt: cred.access_jwt, dpop_key: None, }); }
// 3. stored credential (with auto-refresh if expired) if let Some(cred) = get_credential(handle)? { let cred = refresh_credential_if_expired(handle, cred).await?;
// if the credential has a DPoP key, use DPoP auth let dpop_key = cred .dpop_key .as_deref() .map(|k| dpop::parse_dpop_key(k)) .transpose()?;
return Ok(ResolvedAuth { pds_url: cred.pds_url, did: cred.did, access_jwt: cred.access_jwt, dpop_key, }); }
// 4. no credentials found Err(format!( "not logged in for '{}' — run: git-remote-pds auth login --handle {}", handle, handle ))}
/// How close to expiry a token may get before it is refreshed.////// Covers clock skew and the round trip of the request the token is about to be/// used for — a token valid for another second is not worth sending.const REFRESH_SKEW_SECS: i64 = 60;
/// Whether a credential must be refreshed before use.////// An unknown expiry means refresh: a token that turns out to be expired fails/// the push, and refreshing needlessly only costs a round trip.fn needs_refresh(token_expiry: Option<i64>, now: i64) -> bool { match token_expiry { Some(expiry) => expiry - now < REFRESH_SKEW_SECS, None => true, }}
/// Checks whether a stored credential needs refreshing and refreshes it if so.////// Returns the credential unchanged if the token is still valid, or refreshes it/// using the appropriate method:/// - DPoP credentials → OAuth token refresh via authorization server/// - Bearer credentials → `com.atproto.server.refreshSession`async fn refresh_credential_if_expired( handle: &str, cred: StoredCredential,) -> Result<StoredCredential, String> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0);
if !needs_refresh(cred.token_expiry, now) { return Ok(cred); } match cred.token_expiry { Some(_) => tracing::info!("access token expired or expiring soon, refreshing..."), None => tracing::info!("token expiry unknown, refreshing to be safe..."), }
// pick refresh strategy based on auth type let refreshed = if cred.dpop_key.is_some() { refresh_oauth_credential(&cred).await } else { refresh_bearer_credential(&cred).await };
match refreshed { Ok(new_cred) => { // persist the refreshed credential let mut config = load_config()?; config .credentials .insert(handle.to_string(), new_cred.clone()); save_config(&config)?; tracing::info!("token refreshed successfully"); Ok(new_cred) } Err(e) => { tracing::warn!("token refresh failed: {}", e); // return the original credential — the caller will get an auth // error from the PDS if the token is truly expired Ok(cred) } }}
/// Refreshes a Bearer token credential via `com.atproto.server.refreshSession`.async fn refresh_bearer_credential(cred: &StoredCredential) -> Result<StoredCredential, String> { let http = reqwest::Client::new(); let resp = PdsClient::refresh_session(&http, &cred.pds_url, &cred.refresh_jwt).await?;
// conservative 90-minute TTL for bearer tokens let token_expiry = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64 + 90 * 60) .ok();
Ok(StoredCredential { pds_url: cred.pds_url.clone(), handle: cred.handle.clone(), did: resp.did, access_jwt: resp.access_jwt, refresh_jwt: resp.refresh_jwt, dpop_key: None, signing_key: None, token_expiry, })}
/// Refreshes an OAuth/DPoP credential via the authorization server's token endpoint.async fn refresh_oauth_credential(cred: &StoredCredential) -> Result<StoredCredential, String> { use atproto_oauth::workflow;
let dpop_key_json = cred .dpop_key .as_deref() .ok_or("OAuth credential missing DPoP key")?; let dpop_key_data = dpop::parse_dpop_key(dpop_key_json)?;
// build loopback client matching the one used during login let oauth_client = oauth_login::build_loopback_client(8271);
// resolve the DID to a document (needed by oauth_refresh) let http = reqwest::Client::new(); let dns = atproto_identity::resolve::HickoryDnsResolver::create_resolver(&[]); let resolver = atproto_identity::resolve::InnerIdentityResolver { dns_resolver: std::sync::Arc::new(dns), http_client: http.clone(), plc_hostname: "plc.directory".to_string(), }; let document = resolver .resolve(&cred.did) .await .map_err(|e| format!("failed to resolve DID document for refresh: {}", e))?;
// exchange refresh token for new tokens let token_response = workflow::oauth_refresh( &http, &oauth_client, &dpop_key_data, &cred.refresh_jwt, &document, ) .await .map_err(|e| format!("OAuth token refresh failed: {}", e))?;
// compute new token expiry let token_expiry = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64 + token_response.expires_in as i64) .ok();
Ok(StoredCredential { pds_url: cred.pds_url.clone(), handle: cred.handle.clone(), did: token_response.sub.unwrap_or_else(|| cred.did.clone()), access_jwt: token_response.access_token, refresh_jwt: token_response .refresh_token .unwrap_or_else(|| cred.refresh_jwt.clone()), dpop_key: cred.dpop_key.clone(), signing_key: cred.signing_key.clone(), token_expiry, })}
/// Removes a stored credential by handle.pub fn logout(handle: &str) -> Result<bool, String> { let mut config = load_config()?; let removed = config.credentials.remove(handle).is_some(); if removed { save_config(&config)?; } Ok(removed)}
#[cfg(test)]mod tests { use super::*;
/// The refresh boundary: a token inside the skew window is refreshed rather /// than sent, and an unknown expiry always refreshes. #[test] fn needs_refresh_at_the_skew_boundary() { let now = 1_000_000;
// comfortably valid assert!(!needs_refresh(Some(now + 3600), now)); // exactly at the boundary — still worth using assert!(!needs_refresh(Some(now + REFRESH_SKEW_SECS), now)); // one second inside the window assert!(needs_refresh(Some(now + REFRESH_SKEW_SECS - 1), now)); // already expired assert!(needs_refresh(Some(now - 1), now)); assert!(needs_refresh(Some(0), now)); }
/// An unknown expiry must refresh — assuming validity is what left pushes /// failing against a token that had already expired. #[test] fn needs_refresh_when_expiry_is_unknown() { assert!(needs_refresh(None, 1_000_000)); }
#[test] fn auth_config_round_trip() { let mut config = AuthConfig::default(); config.credentials.insert( "alice.test".to_string(), StoredCredential { pds_url: "http://localhost:3000".to_string(), handle: "alice.test".to_string(), did: "did:plc:abc123".to_string(), access_jwt: "jwt-access".to_string(), refresh_jwt: "jwt-refresh".to_string(), dpop_key: None, signing_key: None, token_expiry: None, }, );
let json = serde_json::to_string(&config).unwrap(); let parsed: AuthConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.credentials.len(), 1); let cred = parsed.credentials.get("alice.test").unwrap(); assert_eq!(cred.did, "did:plc:abc123"); assert_eq!(cred.handle, "alice.test"); }
#[test] fn empty_config_deserializes() { let json = "{}"; let config: AuthConfig = serde_json::from_str(json).unwrap(); assert!(config.credentials.is_empty()); }
#[test] fn token_expiry_serialization() { let cred = StoredCredential { pds_url: "http://localhost:3000".to_string(), handle: "alice.test".to_string(), did: "did:plc:abc123".to_string(), access_jwt: "jwt-access".to_string(), refresh_jwt: "jwt-refresh".to_string(), dpop_key: Some(r#"{"kty":"EC","crv":"P-256"}"#.to_string()), signing_key: None, token_expiry: Some(1700000000), };
let json = serde_json::to_string(&cred).unwrap(); assert!(json.contains("\"token_expiry\":1700000000"));
let parsed: StoredCredential = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.token_expiry, Some(1700000000)); assert!(parsed.dpop_key.is_some()); }
#[test] fn missing_token_expiry_defaults_to_none() { let json = r#"{"pds_url":"http://localhost","handle":"test","did":"did:plc:x","access_jwt":"a","refresh_jwt":"r"}"#; let cred: StoredCredential = serde_json::from_str(json).unwrap(); assert_eq!(cred.token_expiry, None); assert_eq!(cred.dpop_key, None); }}