//! atproto OAuth 2.1 client with permissioned scopes and DPoP. //! //! provides server-side OAuth for publishing sh.tangled.repo records to an //! owner's PDS without long-lived app passwords. //! //! - Client metadata: served statically at `/oauth/client-metadata.json`. //! - Scope: requested as `atproto repo:sh.tangled.repo?action=create&action=update`. //! falls back to `atproto transition:generic` if the authorization server does //! not support granular permission scopes. //! - Client authentication: `private_key_jwt` (ES256). //! - Proof-of-possession: DPoP (RFC 9449) on PAR, token endpoint, and PDS requests. //! - Secrets at rest: client private key and owner session tokens are sealed with //! the knot's MasterKey (AES-256-GCM) in `/oauth/`. use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use aes_gcm::aead::{Aead, AeadCore, OsRng}; use aes_gcm::{Aes256Gcm, KeyInit}; use anyhow::Context as _; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use knot_types::AccountDid; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::config::ValidatedConfig; const GRANULAR_SCOPE: &str = "atproto repo:sh.tangled.repo?action=create&action=update"; fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } fn random_hex(bytes: usize) -> String { use p256::elliptic_curve::rand_core::RngCore; let mut buf = vec![0u8; bytes]; OsRng.fill_bytes(&mut buf); buf.iter().map(|b| format!("{b:02x}")).collect() } fn b64u_encode(data: &[u8]) -> String { URL_SAFE_NO_PAD.encode(data) } // --------------------------------------------------------------------------- // Sealing secrets with MasterKey (AES-256-GCM) // --------------------------------------------------------------------------- fn derive_gcm_key(config: &ValidatedConfig) -> anyhow::Result { let master_key = config.master_key()?; let key_hash = Sha256::digest(master_key.as_bytes()); Aes256Gcm::new_from_slice(&key_hash).context("derive aes-gcm key") } fn seal_bytes(cipher: &Aes256Gcm, plaintext: &[u8]) -> anyhow::Result> { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext) .map_err(|e| anyhow::anyhow!("aes-gcm encryption error: {e}"))?; let mut out = Vec::with_capacity(12 + ciphertext.len()); out.extend_from_slice(nonce.as_slice()); out.extend_from_slice(&ciphertext); Ok(out) } fn unseal_bytes(cipher: &Aes256Gcm, sealed: &[u8]) -> anyhow::Result> { anyhow::ensure!(sealed.len() > 12, "sealed payload too short"); let (nonce_bytes, ciphertext) = sealed.split_at(12); let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); cipher .decrypt(nonce, ciphertext) .map_err(|e| anyhow::anyhow!("aes-gcm decryption error: {e}")) } // --------------------------------------------------------------------------- // P-256 Keys and JWT/DPoP signing // --------------------------------------------------------------------------- pub struct KeyPair { pub signing_key: SigningKey, } impl KeyPair { pub fn generate() -> Self { Self { signing_key: SigningKey::random(&mut OsRng), } } pub fn from_bytes(scalar: &[u8]) -> anyhow::Result { let signing_key = SigningKey::from_bytes(scalar.into()).context("invalid P-256 scalar")?; Ok(Self { signing_key }) } pub fn to_bytes(&self) -> Vec { self.signing_key.to_bytes().to_vec() } pub fn jwk(&self, kid: Option<&str>) -> serde_json::Value { let pt = self.signing_key.verifying_key().to_encoded_point(false); let x = b64u_encode(&pt.as_bytes()[1..33]); let y = b64u_encode(&pt.as_bytes()[33..65]); let mut map = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y, }); if let Some(kid) = kid { map["kid"] = serde_json::Value::String(kid.to_string()); map["use"] = serde_json::Value::String("sig".to_string()); map["alg"] = serde_json::Value::String("ES256".to_string()); } map } pub fn sign_jws(&self, header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { let h_b64 = b64u_encode(&serde_json::to_vec(&header)?); let p_b64 = b64u_encode(&serde_json::to_vec(&payload)?); let signing_input = format!("{h_b64}.{p_b64}"); let sig: Signature = self.signing_key.sign(signing_input.as_bytes()); let s_b64 = b64u_encode(&sig.to_bytes()); Ok(format!("{signing_input}.{s_b64}")) } } // --------------------------------------------------------------------------- // Client metadata and persistent client key // --------------------------------------------------------------------------- pub fn client_id(config: &ValidatedConfig) -> String { format!("https://{}/oauth/client-metadata.json", config.hostname.as_str()) } pub fn redirect_uri(config: &ValidatedConfig) -> String { format!("https://{}/oauth/callback", config.hostname.as_str()) } fn oauth_dir(config: &ValidatedConfig) -> PathBuf { config.oauth_dir() } pub fn ensure_client_key(config: &ValidatedConfig) -> anyhow::Result { let dir = oauth_dir(config); std::fs::create_dir_all(&dir).ok(); let key_file = dir.join("client_key.sealed"); let cipher = derive_gcm_key(config)?; if key_file.exists() { let sealed = std::fs::read(&key_file).context("read sealed client key")?; let plain = unseal_bytes(&cipher, &sealed).context("decrypt client key")?; return KeyPair::from_bytes(&plain); } let key = KeyPair::generate(); let sealed = seal_bytes(&cipher, &key.to_bytes())?; let tmp = dir.join("client_key.sealed.tmp"); std::fs::write(&tmp, sealed)?; std::fs::rename(&tmp, &key_file)?; Ok(key) } pub fn client_metadata_document(config: &ValidatedConfig) -> anyhow::Result { let client_key = ensure_client_key(config)?; let jwk = client_key.jwk(Some("knot-static-client")); let cid = client_id(config); let ruri = redirect_uri(config); Ok(serde_json::json!({ "client_id": cid, "client_name": format!("knot-static ({})", config.hostname.as_str()), "client_uri": format!("https://{}", config.hostname.as_str()), "application_type": "web", "grant_types": ["authorization_code", "refresh_token"], "scope": GRANULAR_SCOPE, "response_types": ["code"], "redirect_uris": [ruri], "dpop_bound_access_tokens": true, "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "jwks": { "keys": [jwk] } })) } // --------------------------------------------------------------------------- // Client Assertion & DPoP proofs // --------------------------------------------------------------------------- fn client_assertion( client_key: &KeyPair, config: &ValidatedConfig, audience: &str, ) -> anyhow::Result { let cid = client_id(config); let now = now_secs(); let header = serde_json::json!({ "alg": "ES256", "typ": "JWT", "kid": "knot-static-client", }); let payload = serde_json::json!({ "iss": cid, "sub": cid, "aud": audience, "jti": random_hex(16), "iat": now, "exp": now + 300, }); client_key.sign_jws(header, payload) } fn dpop_proof( dpop_key: &KeyPair, htm: &str, htu: &str, nonce: Option<&str>, ) -> anyhow::Result { let now = now_secs(); // htu must be normalized without query/hash let htu_clean = htu.split('?').next().unwrap_or(htu); let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": dpop_key.jwk(None), }); let mut payload = serde_json::json!({ "jti": random_hex(16), "htm": htm.to_uppercase(), "htu": htu_clean, "iat": now, }); if let Some(nonce) = nonce { payload["nonce"] = serde_json::Value::String(nonce.to_string()); } dpop_key.sign_jws(header, payload) } // --------------------------------------------------------------------------- // Stored OAuth Session (per owner DID) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredSession { pub owner_did: String, pub dpop_scalar: Vec, pub access_token: String, pub refresh_token: String, pub scope: String, pub token_endpoint: String, pub pds_url: String, pub expires_at: i64, pub dpop_nonce: Option, } fn session_file_path(config: &ValidatedConfig, owner_did: &str) -> PathBuf { let safe_name = owner_did.replace(':', "_") + ".sealed"; oauth_dir(config).join("sessions").join(safe_name) } pub fn load_session( config: &ValidatedConfig, owner_did: &str, ) -> anyhow::Result> { let file = session_file_path(config, owner_did); if !file.exists() { return Ok(None); } let cipher = derive_gcm_key(config)?; let sealed = std::fs::read(&file).context("read sealed session")?; let plain = unseal_bytes(&cipher, &sealed).context("decrypt session")?; let session: StoredSession = serde_json::from_slice(&plain).context("deserialize session")?; Ok(Some(session)) } pub fn save_session(config: &ValidatedConfig, session: &StoredSession) -> anyhow::Result<()> { let file = session_file_path(config, &session.owner_did); if let Some(parent) = file.parent() { std::fs::create_dir_all(parent).ok(); } let cipher = derive_gcm_key(config)?; let plain = serde_json::to_vec(session).context("serialize session")?; let sealed = seal_bytes(&cipher, &plain)?; let tmp = file.with_extension("tmp"); std::fs::write(&tmp, sealed)?; std::fs::rename(&tmp, file)?; Ok(()) } // --------------------------------------------------------------------------- // Server Metadata Discovery // --------------------------------------------------------------------------- #[derive(Debug, Clone)] struct AuthMetadata { issuer: String, par_endpoint: String, authorization_endpoint: String, token_endpoint: String, scopes_supported: Vec, } async fn discover_auth_metadata( http: &reqwest::Client, config: &ValidatedConfig, owner_did: &AccountDid, ) -> anyhow::Result<(String, AuthMetadata)> { // 1. Resolve owner DID to find PDS URL let atproto = knot_atproto::Atproto::new( knot_runtime::ReqwestHttp::new(knot_runtime::HttpLimits::default()) .context("build identity http transport")?, knot_runtime::SystemClock, config.knot_did.clone(), knot_atproto::PlcDirectory::new(config.plc_directory.clone())?, ); let identity = atproto .resolve_identity(owner_did) .await .context("resolve owner DID to find PDS")?; let pds_url = identity.pds.url().as_str().trim_end_matches('/').to_string(); // 2. Query OAuth protected resource metadata let res_meta_url = format!("{pds_url}/.well-known/oauth-protected-resource"); let mut auth_server_url = pds_url.clone(); if let Ok(resp) = http.get(&res_meta_url).send().await && resp.status().is_success() && let Ok(v) = resp.json::().await && let Some(arr) = v["authorization_servers"].as_array() && let Some(first) = arr.first().and_then(|s| s.as_str()) { auth_server_url = first.trim_end_matches('/').to_string(); } // 3. Query OAuth authorization server metadata let auth_meta_url = format!("{auth_server_url}/.well-known/oauth-authorization-server"); let resp = http .get(&auth_meta_url) .send() .await .context("fetch oauth authorization server metadata")?; anyhow::ensure!( resp.status().is_success(), "auth server metadata failed with status {}", resp.status() ); let v: serde_json::Value = resp.json().await.context("parse auth server metadata")?; let issuer = v["issuer"] .as_str() .context("auth server metadata missing issuer")? .to_string(); let par_endpoint = v["pushed_authorization_request_endpoint"] .as_str() .context("auth server metadata missing pushed_authorization_request_endpoint")? .to_string(); let authorization_endpoint = v["authorization_endpoint"] .as_str() .context("auth server metadata missing authorization_endpoint")? .to_string(); let token_endpoint = v["token_endpoint"] .as_str() .context("auth server metadata missing token_endpoint")? .to_string(); let scopes_supported = v["scopes_supported"] .as_array() .map(|arr| { arr.iter() .filter_map(|s| s.as_str().map(ToString::to_string)) .collect() }) .unwrap_or_default(); Ok(( pds_url, AuthMetadata { issuer, par_endpoint, authorization_endpoint, token_endpoint, scopes_supported, }, )) } // --------------------------------------------------------------------------- // PAR and Interactive Authorization // --------------------------------------------------------------------------- pub async fn run_interactive_auth( config: &ValidatedConfig, owner_did: &AccountDid, ) -> anyhow::Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; let client_key = ensure_client_key(config)?; let dpop_key = KeyPair::generate(); let (pds_url, meta) = discover_auth_metadata(&http, config, owner_did).await?; let scope = GRANULAR_SCOPE; // PKCE let code_verifier = random_hex(32); let challenge_hash = Sha256::digest(code_verifier.as_bytes()); let code_challenge = b64u_encode(&challenge_hash); let state = random_hex(16); let ruri = redirect_uri(config); let cid = client_id(config); // Build PAR request let mut nonce: Option = None; let request_uri = loop { let assertion = client_assertion(&client_key, config, &meta.issuer)?; let proof = dpop_proof(&dpop_key, "POST", &meta.par_endpoint, nonce.as_deref())?; let params = [ ("client_id", cid.as_str()), ("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), ("client_assertion", &assertion), ("response_type", "code"), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("state", &state), ("redirect_uri", &ruri), ("scope", scope), ("login_hint", owner_did.as_str()), ]; let resp = http .post(&meta.par_endpoint) .header("DPoP", proof) .form(¶ms) .send() .await .context("send PAR request")?; if resp.status().as_u16() == 400 || resp.status().as_u16() == 401 { if let Some(new_nonce) = resp.headers().get("DPoP-Nonce").and_then(|h| h.to_str().ok()) { if nonce.as_deref() != Some(new_nonce) { nonce = Some(new_nonce.to_string()); continue; } } } anyhow::ensure!( resp.status().is_success(), "PAR failed with HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default() ); let par_body: serde_json::Value = resp.json().await.context("parse PAR response")?; let r_uri = par_body["request_uri"] .as_str() .context("PAR response missing request_uri")? .to_string(); break r_uri; }; let mut auth_url = url::Url::parse(&meta.authorization_endpoint)?; auth_url.query_pairs_mut() .append_pair("client_id", &cid) .append_pair("request_uri", &request_uri); eprintln!(" ============================================================"); eprintln!("knot-static: to authorize publishing sh.tangled.repo records,"); eprintln!("open this URL in your browser: "); eprintln!(" {auth_url} "); eprintln!("waiting for authorization callback (timeout 180s)..."); eprintln!("============================================================ "); // Bind temporary loopback listener on 127.0.0.1:8918 let listener = tokio::net::TcpListener::bind("127.0.0.1:8918") .await .context("bind oauth callback listener on 127.0.0.1:8918 (is another flow in progress?)")?; let code = tokio::select! { res = accept_oauth_code(listener, &state) => res?, _ = tokio::time::sleep(Duration::from_secs(180)) => { anyhow::bail!("timed out waiting for OAuth callback"); } }; eprintln!("authorization code received; completing token exchange..."); // Token exchange let mut tok_nonce = nonce; let token_data = loop { let assertion = client_assertion(&client_key, config, &meta.issuer)?; let proof = dpop_proof(&dpop_key, "POST", &meta.token_endpoint, tok_nonce.as_deref())?; let params = [ ("grant_type", "authorization_code"), ("code", code.as_str()), ("redirect_uri", &ruri), ("code_verifier", &code_verifier), ("client_id", &cid), ("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), ("client_assertion", &assertion), ]; let resp = http .post(&meta.token_endpoint) .header("DPoP", proof) .form(¶ms) .send() .await .context("token exchange request")?; if resp.status().as_u16() == 400 || resp.status().as_u16() == 401 { if let Some(new_nonce) = resp.headers().get("DPoP-Nonce").and_then(|h| h.to_str().ok()) { if tok_nonce.as_deref() != Some(new_nonce) { tok_nonce = Some(new_nonce.to_string()); continue; } } } anyhow::ensure!( resp.status().is_success(), "token exchange failed with HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default() ); let v: serde_json::Value = resp.json().await.context("parse token response")?; break v; }; let access_token = token_data["access_token"] .as_str() .context("missing access_token")? .to_string(); let refresh_token = token_data["refresh_token"] .as_str() .context("missing refresh_token")? .to_string(); let granted_scope = token_data["scope"].as_str().unwrap_or(scope).to_string(); let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600); let sub = token_data["sub"].as_str().unwrap_or_default(); if !sub.is_empty() && sub != owner_did.as_str() { anyhow::bail!("authenticated account ({sub}) does not match expected owner ({owner_did})"); } let session = StoredSession { owner_did: owner_did.as_str().to_string(), dpop_scalar: dpop_key.to_bytes(), access_token, refresh_token, scope: granted_scope, token_endpoint: meta.token_endpoint, pds_url, expires_at: now_secs() + expires_in, dpop_nonce: tok_nonce, }; save_session(config, &session)?; eprintln!("OAuth authorization successful! Tokens saved for {owner_did}. "); Ok(session) } async fn accept_oauth_code( listener: tokio::net::TcpListener, expected_state: &str, ) -> anyhow::Result { use tokio::io::{AsyncReadExt, AsyncWriteExt}; loop { let (mut socket, _) = listener.accept().await?; let mut buf = [0u8; 4096]; let n = socket.read(&mut buf).await?; let req_str = String::from_utf8_lossy(&buf[..n]); let Some(first_line) = req_str.lines().next() else { continue; }; let parts: Vec<&str> = first_line.split_whitespace().collect(); if parts.len() < 2 || parts[0] != "GET" { continue; } let path_and_query = parts[1]; let dummy_url = format!("http://localhost{path_and_query}"); let Ok(parsed) = url::Url::parse(&dummy_url) else { continue; }; let mut code: Option = None; let mut state: Option = None; for (k, v) in parsed.query_pairs() { if k == "code" { code = Some(v.to_string()); } else if k == "state" { state = Some(v.to_string()); } } if state.as_deref() != Some(expected_state) { let resp = "HTTP/1.1 400 Bad Request Content-Length: 13 Connection: close Invalid state"; let _ = socket.write_all(resp.as_bytes()).await; continue; } let Some(auth_code) = code else { let resp = "HTTP/1.1 400 Bad Request Content-Length: 12 Connection: close Missing code"; let _ = socket.write_all(resp.as_bytes()).await; continue; }; let body = r#"

Authorization successful!

You may return to your terminal.

"#; let resp = format!( "HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: {} Connection: close {}", body.len(), body ); let _ = socket.write_all(resp.as_bytes()).await; return Ok(auth_code); } } // --------------------------------------------------------------------------- // Token Refresh // --------------------------------------------------------------------------- pub async fn refresh_session( config: &ValidatedConfig, session: &mut StoredSession, ) -> anyhow::Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; let client_key = ensure_client_key(config)?; let dpop_key = KeyPair::from_bytes(&session.dpop_scalar)?; let cid = client_id(config); // Find issuer for audience (or use token_endpoint origin) let audience = url::Url::parse(&session.token_endpoint)? .origin() .ascii_serialization(); let mut nonce = session.dpop_nonce.clone(); let token_data = loop { let assertion = client_assertion(&client_key, config, &audience)?; let proof = dpop_proof(&dpop_key, "POST", &session.token_endpoint, nonce.as_deref())?; let params = [ ("grant_type", "refresh_token"), ("refresh_token", session.refresh_token.as_str()), ("client_id", cid.as_str()), ("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), ("client_assertion", &assertion), ]; let resp = http .post(&session.token_endpoint) .header("DPoP", proof) .form(¶ms) .send() .await .context("token refresh request")?; if resp.status().as_u16() == 400 || resp.status().as_u16() == 401 { if let Some(new_nonce) = resp.headers().get("DPoP-Nonce").and_then(|h| h.to_str().ok()) { if nonce.as_deref() != Some(new_nonce) { nonce = Some(new_nonce.to_string()); continue; } } } if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); eprintln!("warning: token refresh failed ({status}): {text}"); return Ok(false); } let v: serde_json::Value = resp.json().await.context("parse refresh response")?; break v; }; if let Some(new_acc) = token_data["access_token"].as_str() { session.access_token = new_acc.to_string(); } if let Some(new_ref) = token_data["refresh_token"].as_str() { session.refresh_token = new_ref.to_string(); } let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600); session.expires_at = now_secs() + expires_in; session.dpop_nonce = nonce; save_session(config, session)?; Ok(true) } // --------------------------------------------------------------------------- // High-Level Session Resolution // --------------------------------------------------------------------------- pub async fn get_or_refresh_session( config: &ValidatedConfig, owner_did: &AccountDid, ) -> anyhow::Result> { let Some(mut session) = load_session(config, owner_did.as_str())? else { return Ok(None); }; // Refresh if within 5 minutes of expiring if now_secs() + 300 >= session.expires_at { if !refresh_session(config, &mut session).await? { return Ok(None); } } Ok(Some(session)) } pub async fn ensure_auth_for_owner( config: &ValidatedConfig, owner_did: &AccountDid, interactive: bool, ) -> anyhow::Result> { if let Some(session) = get_or_refresh_session(config, owner_did).await? { return Ok(Some(session)); } if !interactive { return Ok(None); } let session = run_interactive_auth(config, owner_did).await?; Ok(Some(session)) } // --------------------------------------------------------------------------- // putRecord with DPoP // --------------------------------------------------------------------------- pub async fn put_record_oauth( session: &mut StoredSession, collection: &str, rkey: &str, record: &serde_json::Value, ) -> anyhow::Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; let dpop_key = KeyPair::from_bytes(&session.dpop_scalar)?; let put_url = format!("{}/xrpc/com.atproto.repo.putRecord", session.pds_url.trim_end_matches('/')); let body = serde_json::json!({ "repo": session.owner_did, "collection": collection, "rkey": rkey, "record": record, }); let mut nonce = session.dpop_nonce.clone(); loop { let proof = dpop_proof(&dpop_key, "POST", &put_url, nonce.as_deref())?; let resp = http .post(&put_url) .header("Authorization", format!("DPoP {}", session.access_token)) .header("DPoP", proof) .header("Content-Type", "application/json") .json(&body) .send() .await .context("send putRecord request")?; if resp.status().as_u16() == 400 || resp.status().as_u16() == 401 { if let Some(new_nonce) = resp.headers().get("DPoP-Nonce").and_then(|h| h.to_str().ok()) { if nonce.as_deref() != Some(new_nonce) { nonce = Some(new_nonce.to_string()); session.dpop_nonce = nonce.clone(); continue; } } } let status = resp.status(); anyhow::ensure!( status.is_success(), "putRecord failed with HTTP {status}: {}", resp.text().await.unwrap_or_default() ); let v: serde_json::Value = resp.json().await.context("parse putRecord response")?; return Ok(v); } } pub async fn print_status(config: &ValidatedConfig) -> anyhow::Result<()> { println!("Knot OAuth client ID: {}", client_id(config)); println!("Knot OAuth redirect URI: {}", redirect_uri(config)); let dir = oauth_dir(config); println!("OAuth state directory: {}", dir.display()); let client_key_file = dir.join("client_key.sealed"); println!("Client key present: {}", client_key_file.exists()); let sessions_dir = dir.join("sessions"); if !sessions_dir.exists() { println!("No OAuth sessions found."); return Ok(()); } println!("\nSessions:"); for entry in std::fs::read_dir(&sessions_dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("sealed") { continue; } let safe_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); let owner_did = safe_name.replace('_', ":"); match load_session(config, &owner_did) { Ok(Some(session)) => { let exp_in = session.expires_at - now_secs(); let status = if exp_in > 0 { format!("valid ({exp_in}s remaining)") } else { format!("access token expired (refreshable: {})", !session.refresh_token.is_empty()) }; println!(" - {}: scope={:?}, status={}", session.owner_did, session.scope, status); } Ok(None) => println!(" - {owner_did}: not found"), Err(e) => println!(" - {owner_did}: error reading session ({e:#})"), } } Ok(()) }