Rust CLI for tangled

Implement automatic OAuth token refresh

Add automatic token refresh to gracefully handle expired access tokens:

Token refresh implementation:
- Add refresh_session() method to TangledClient
- Calls com.atproto.server.refreshSession with refresh token
- Returns new Session with updated access and refresh tokens
- Create util module with session management helpers
- load_session(): Basic session loading
- refresh_session(): Refresh using refresh token and save
- load_session_with_refresh(): Smart loading with auto-refresh

Auto-refresh strategy:
- Check session age on every command invocation
- If session is older than 30 minutes, proactively refresh
- Falls back to old session if refresh fails (might still work)
- Preserves PDS URL and updates created_at timestamp

Update all spindle commands to use auto-refresh:
- Replace SessionManager::load() with util::load_session_with_refresh()
- Applies to: config, list, logs, and all secret operations
- Remove now-unused SessionManager imports

Dependencies:
- Add chrono to tangled-cli for timestamp comparison

Fixes ExpiredToken errors that occurred when access tokens expired
after initial login, requiring manual re-authentication.

Changed files
+96 -25
crates
tangled-api
src
tangled-cli
+1
Cargo.lock
··· 2089 2089 version = "0.1.0" 2090 2090 dependencies = [ 2091 2091 "anyhow", 2092 + "chrono", 2092 2093 "clap", 2093 2094 "colored", 2094 2095 "dialoguer",
+32
crates/tangled-api/src/client.rs
··· 149 149 }) 150 150 } 151 151 152 + pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> { 153 + #[derive(Deserialize)] 154 + struct Res { 155 + #[serde(rename = "accessJwt")] 156 + access_jwt: String, 157 + #[serde(rename = "refreshJwt")] 158 + refresh_jwt: String, 159 + did: String, 160 + handle: String, 161 + } 162 + let url = self.xrpc_url("com.atproto.server.refreshSession"); 163 + let client = reqwest::Client::new(); 164 + let res = client 165 + .post(url) 166 + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt)) 167 + .send() 168 + .await?; 169 + let status = res.status(); 170 + if !status.is_success() { 171 + let body = res.text().await.unwrap_or_default(); 172 + return Err(anyhow!("{}: {}", status, body)); 173 + } 174 + let res_data: Res = res.json().await?; 175 + Ok(Session { 176 + access_jwt: res_data.access_jwt, 177 + refresh_jwt: res_data.refresh_jwt, 178 + did: res_data.did, 179 + handle: res_data.handle, 180 + ..Default::default() 181 + }) 182 + } 183 + 152 184 pub async fn list_repos( 153 185 &self, 154 186 user: Option<&str>,
+1
crates/tangled-cli/Cargo.toml
··· 18 18 url = { workspace = true } 19 19 tokio-tungstenite = { workspace = true } 20 20 futures-util = { workspace = true } 21 + chrono = { workspace = true } 21 22 22 23 # Internal crates 23 24 tangled-config = { path = "../tangled-config" }
+6 -25
crates/tangled-cli/src/commands/spindle.rs
··· 4 4 }; 5 5 use anyhow::{anyhow, Result}; 6 6 use futures_util::StreamExt; 7 - use tangled_config::session::SessionManager; 8 7 use tokio_tungstenite::{connect_async, tungstenite::Message}; 9 8 10 9 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { ··· 18 17 } 19 18 20 19 async fn list(args: SpindleListArgs) -> Result<()> { 21 - let mgr = SessionManager::default(); 22 - let session = mgr 23 - .load()? 24 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 20 + let session = crate::util::load_session_with_refresh().await?; 25 21 26 22 let pds = session 27 23 .pds ··· 65 61 } 66 62 67 63 async fn config(args: SpindleConfigArgs) -> Result<()> { 68 - let mgr = SessionManager::default(); 69 - let session = mgr 70 - .load()? 71 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 64 + let session = crate::util::load_session_with_refresh().await?; 72 65 73 66 if args.enable && args.disable { 74 67 return Err(anyhow!("Cannot use --enable and --disable together")); ··· 138 131 (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) 139 132 } else if parts.len() == 1 { 140 133 // Use repo context - need to get repo info 141 - let mgr = SessionManager::default(); 142 - let session = mgr 143 - .load()? 144 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 134 + let session = crate::util::load_session_with_refresh().await?; 145 135 let pds = session 146 136 .pds 147 137 .clone() ··· 205 195 } 206 196 207 197 async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 208 - let mgr = SessionManager::default(); 209 - let session = mgr 210 - .load()? 211 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 198 + let session = crate::util::load_session_with_refresh().await?; 212 199 let pds = session 213 200 .pds 214 201 .clone() ··· 243 230 } 244 231 245 232 async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 246 - let mgr = SessionManager::default(); 247 - let session = mgr 248 - .load()? 249 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 233 + let session = crate::util::load_session_with_refresh().await?; 250 234 let pds = session 251 235 .pds 252 236 .clone() ··· 273 257 } 274 258 275 259 async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 276 - let mgr = SessionManager::default(); 277 - let session = mgr 278 - .load()? 279 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 260 + let session = crate::util::load_session_with_refresh().await?; 280 261 let pds = session 281 262 .pds 282 263 .clone()
+1
crates/tangled-cli/src/main.rs
··· 1 1 mod cli; 2 2 mod commands; 3 + mod util; 3 4 4 5 use anyhow::Result; 5 6 use clap::Parser;
+55
crates/tangled-cli/src/util.rs
··· 1 + use anyhow::{anyhow, Result}; 2 + use tangled_config::session::{Session, SessionManager}; 3 + 4 + /// Load session and automatically refresh if expired 5 + pub async fn load_session() -> Result<Session> { 6 + let mgr = SessionManager::default(); 7 + let session = mgr 8 + .load()? 9 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 10 + 11 + Ok(session) 12 + } 13 + 14 + /// Refresh the session using the refresh token 15 + pub async fn refresh_session(session: &Session) -> Result<Session> { 16 + let pds = session 17 + .pds 18 + .clone() 19 + .unwrap_or_else(|| "https://bsky.social".to_string()); 20 + 21 + let client = tangled_api::TangledClient::new(&pds); 22 + let mut new_session = client.refresh_session(&session.refresh_jwt).await?; 23 + 24 + // Preserve PDS from old session 25 + new_session.pds = session.pds.clone(); 26 + 27 + // Save the refreshed session 28 + let mgr = SessionManager::default(); 29 + mgr.save(&new_session)?; 30 + 31 + Ok(new_session) 32 + } 33 + 34 + /// Load session with automatic refresh on ExpiredToken 35 + pub async fn load_session_with_refresh() -> Result<Session> { 36 + let session = load_session().await?; 37 + 38 + // Check if session is older than 30 minutes - if so, proactively refresh 39 + let age = chrono::Utc::now() 40 + .signed_duration_since(session.created_at) 41 + .num_minutes(); 42 + 43 + if age > 30 { 44 + // Session is old, proactively refresh 45 + match refresh_session(&session).await { 46 + Ok(new_session) => return Ok(new_session), 47 + Err(_) => { 48 + // If refresh fails, try with the old session anyway 49 + // It might still work 50 + } 51 + } 52 + } 53 + 54 + Ok(session) 55 + }