Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
30 kB · 855 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856//! 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 `<state_dir>/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<Aes256Gcm> { 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<Vec<u8>> { 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<Vec<u8>> { 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<Self> { let signing_key = SigningKey::from_bytes(scalar.into()).context("invalid P-256 scalar")?; Ok(Self { signing_key }) }
pub fn to_bytes(&self) -> Vec<u8> { 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<String> { 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<KeyPair> { 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<serde_json::Value> { 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<String> { 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<String> { 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<u8>, 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<String>,}
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<Option<StoredSession>> { 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<String>,}
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::<serde_json::Value>().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<StoredSession> { 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<String> = 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<String> { 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<String> = None; let mut state: Option<String> = 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 RequestContent-Length: 13Connection: 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 RequestContent-Length: 12Connection: close
Missing code"; let _ = socket.write_all(resp.as_bytes()).await; continue; };
let body = r#"<!DOCTYPE html><html><body style="font-family:sans-serif;padding:3rem;"><h2>Authorization successful!</h2><p>You may return to your terminal.</p></body></html>"#; let resp = format!( "HTTP/1.1 200 OKContent-Type: text/html; charset=utf-8Content-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<bool> { 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<Option<StoredSession>> { 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<Option<StoredSession>> { 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<serde_json::Value> { 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(())}