diff --git a/Cargo.toml b/Cargo.toml index fc38a78..43a4e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ opt-level = 1 [workspace.package] -version = "0.0.7-alpha" +version = "0.0.8-alpha" edition = "2021" license = "MIT" repository = "https://github.com/lag-app/cli" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9e4f953..2c07300 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -35,6 +35,7 @@ tracing-subscriber = { workspace = true } parking_lot = "0.12" futures-util = "0.3" rand = "0.8" +base64 = "0.22" [target.'cfg(target_os = "macos")'.dependencies] objc = "0.2" diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index 6e3660f..09aa43e 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; // Refresh 5 minutes before expiry to avoid hitting 401 const REFRESH_BUFFER: Duration = Duration::from_secs(300); // Supabase default token lifetime -const TOKEN_LIFETIME: Duration = Duration::from_secs(3600); +const TOKEN_LIFETIME: Duration = Duration::from_secs(28800); pub struct ApiClient { client: reqwest::Client, diff --git a/crates/cli/src/auth.rs b/crates/cli/src/auth.rs index 2dd480f..12eda48 100644 --- a/crates/cli/src/auth.rs +++ b/crates/cli/src/auth.rs @@ -3,6 +3,7 @@ use crate::config::{self, Credentials}; use anyhow::{anyhow, Result}; +use base64::Engine; use rand::Rng; use url::Url; @@ -10,8 +11,44 @@ const SUPABASE_URL: &str = "https://base.trylag.com"; const SUPABASE_ANON_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InVmeXlsZGp5c3pjamtsYW5ucnRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzEzNjQxNjYsImV4cCI6MjA4Njk0MDE2Nn0.WntE5XNUuzNs5j-OnK0ZMG2sxrfPTSCGi8dgdfWlCrw"; const WEB_URL: &str = "https://trylag.com"; -pub fn require_auth() -> Result { - config::load_credentials().ok_or_else(|| anyhow!("Not logged in. Run `lag login` first.")) +pub fn is_token_expired(token: &str) -> bool { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return true; + } + let payload = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(bytes) => bytes, + Err(_) => return true, + }; + let json: serde_json::Value = match serde_json::from_slice(&payload) { + Ok(v) => v, + Err(_) => return true, + }; + let exp = match json.get("exp").and_then(|v| v.as_i64()) { + Some(e) => e, + None => return true, + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + now >= exp +} + +pub async fn ensure_auth() -> Result { + if let Some(creds) = config::load_credentials() { + if !is_token_expired(&creds.access_token) { + return Ok(creds); + } + // Access token expired — try refreshing + match refresh_token(&creds.refresh_token).await { + Ok(refreshed) => return Ok(refreshed), + Err(_) => { + let _ = config::clear_credentials(); + } + } + } + login_flow().await } pub async fn login_flow() -> Result { diff --git a/crates/cli/src/commands/chat.rs b/crates/cli/src/commands/chat.rs index dddf2cc..8b35fee 100644 --- a/crates/cli/src/commands/chat.rs +++ b/crates/cli/src/commands/chat.rs @@ -11,7 +11,7 @@ use crossterm::terminal; use std::io::Write; pub async fn run(action: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; match action { diff --git a/crates/cli/src/commands/dms.rs b/crates/cli/src/commands/dms.rs index a3f0d98..996e55b 100644 --- a/crates/cli/src/commands/dms.rs +++ b/crates/cli/src/commands/dms.rs @@ -11,7 +11,7 @@ use crossterm::terminal; use std::io::Write; pub async fn run(action: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; match action { diff --git a/crates/cli/src/commands/friends.rs b/crates/cli/src/commands/friends.rs index 130ba16..2b7910a 100644 --- a/crates/cli/src/commands/friends.rs +++ b/crates/cli/src/commands/friends.rs @@ -7,7 +7,7 @@ use crate::cli::FriendsAction; use anyhow::Result; pub async fn run(action: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; match action { diff --git a/crates/cli/src/commands/join.rs b/crates/cli/src/commands/join.rs index 746f65e..082367b 100644 --- a/crates/cli/src/commands/join.rs +++ b/crates/cli/src/commands/join.rs @@ -22,7 +22,7 @@ pub async fn run( output_device: Option, with_chat: bool, ) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; // Resolve server and room diff --git a/crates/cli/src/commands/login.rs b/crates/cli/src/commands/login.rs index 745bb78..e1824e2 100644 --- a/crates/cli/src/commands/login.rs +++ b/crates/cli/src/commands/login.rs @@ -7,11 +7,23 @@ use crate::config; use anyhow::Result; pub async fn run() -> Result<()> { - if config::load_credentials().is_some() { - println!("Already logged in. Use `lag logout` first to switch accounts."); - return Ok(()); + if let Some(creds) = config::load_credentials() { + if !auth::is_token_expired(&creds.access_token) { + println!("Already logged in. Use `lag logout` first to switch accounts."); + return Ok(()); + } + // Access token expired — try refreshing + match auth::refresh_token(&creds.refresh_token).await { + Ok(_) => { + println!("Session refreshed. You are logged in."); + return Ok(()); + } + Err(_) => { + let _ = config::clear_credentials(); + println!("Session expired. Logging in again..."); + } + } } - auth::login_flow().await?; Ok(()) } @@ -23,7 +35,7 @@ pub async fn logout() -> Result<()> { } pub async fn whoami() -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; let user: serde_json::Value = api.get("/users/me").await?; diff --git a/crates/cli/src/commands/servers.rs b/crates/cli/src/commands/servers.rs index ffe6f60..b1f4dd9 100644 --- a/crates/cli/src/commands/servers.rs +++ b/crates/cli/src/commands/servers.rs @@ -10,7 +10,7 @@ use crossterm::terminal; use std::io::{self, Write}; pub async fn run(name_or_id: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; match name_or_id { diff --git a/crates/cli/src/commands/status.rs b/crates/cli/src/commands/status.rs index 35ec6fb..45ebee7 100644 --- a/crates/cli/src/commands/status.rs +++ b/crates/cli/src/commands/status.rs @@ -7,7 +7,7 @@ use crate::ws::{WsClient, WsClientMessage}; use anyhow::Result; pub async fn run(status: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; match status { Some(s) => { diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs index 4256330..678fd90 100644 --- a/crates/cli/src/tui/mod.rs +++ b/crates/cli/src/tui/mod.rs @@ -17,7 +17,7 @@ use ratatui::prelude::*; use std::io; pub async fn run(_server: Option) -> Result<()> { - let creds = auth::require_auth()?; + let creds = auth::ensure_auth().await?; // Suggest setup on first run let settings_path = crate::config::config_dir().join("audio-settings.json");