diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index 7d7a429..5e62a84 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -8,10 +8,9 @@ use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use serde::de::DeserializeOwned; use std::time::{Duration, Instant}; -// Refresh 5 minutes before expiry to avoid hitting 401 +// Legacy JWT refresh constants (used only when PAT is not available) const REFRESH_BUFFER: Duration = Duration::from_secs(300); -// Supabase default token lifetime -const TOKEN_LIFETIME: Duration = Duration::from_secs(28800); +const TOKEN_LIFETIME: Duration = Duration::from_secs(3600); // Supabase default: 1 hour pub struct ApiClient { client: reqwest::Client, @@ -28,29 +27,37 @@ impl ApiClient { client, base_url: cfg.effective_api_url(), creds, - token_acquired_at: None, // unknown age — will refresh on first API call + token_acquired_at: None, }) } fn auth_headers(&self) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", self.creds.access_token)) { + let token_str = if let Some(pat) = &self.creds.pat { + format!("Bearer {}", pat) + } else { + format!("Bearer {}", self.creds.access_token) + }; + if let Ok(val) = HeaderValue::from_str(&token_str) { headers.insert(AUTHORIZATION, val); } headers } - /// Proactively refresh if the token is known to be near expiry. - /// For unknown-age tokens (loaded from disk), skip proactive refresh - /// and let the 401 retry handle it — avoids wiping fresh tokens. + fn uses_pat(&self) -> bool { + self.creds.pat.is_some() + } + + /// Proactively refresh if using legacy JWT and the token is known to be near expiry. async fn ensure_fresh_token(&mut self) -> Result<()> { + if self.uses_pat() { + return Ok(()); + } if let Some(acquired) = self.token_acquired_at { if acquired.elapsed() + REFRESH_BUFFER >= TOKEN_LIFETIME { self.do_refresh().await?; } } - // If token_acquired_at is None, we don't know the age. - // Try the token as-is; the 401 handler in get/post will refresh if needed. Ok(()) } @@ -61,6 +68,16 @@ impl ApiClient { Ok(()) } + async fn handle_unauthorized(&mut self) -> Result<()> { + if self.uses_pat() { + let _ = config::clear_credentials(); + return Err(anyhow!( + "Token revoked or invalid. Run `lag login` to sign in again." + )); + } + self.do_refresh().await + } + pub async fn get(&mut self, path: &str) -> Result { self.ensure_fresh_token().await?; let url = format!("{}{}", self.base_url, path); @@ -72,7 +89,7 @@ impl ApiClient { .await?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.do_refresh().await?; + self.handle_unauthorized().await?; let resp = self .client .get(&url) @@ -82,8 +99,7 @@ impl ApiClient { return parse_response(resp).await; } - // Token works — mark acquisition time if unknown - if self.token_acquired_at.is_none() { + if self.token_acquired_at.is_none() && !self.uses_pat() { self.token_acquired_at = Some(Instant::now()); } @@ -106,7 +122,7 @@ impl ApiClient { .await?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.do_refresh().await?; + self.handle_unauthorized().await?; let resp = self .client .post(&url) @@ -131,7 +147,7 @@ impl ApiClient { .await?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.do_refresh().await?; + self.handle_unauthorized().await?; let resp = self .client .delete(&url) @@ -155,7 +171,11 @@ impl ApiClient { } pub fn access_token(&self) -> &str { - &self.creds.access_token + if let Some(pat) = &self.creds.pat { + pat + } else { + &self.creds.access_token + } } pub fn base_url(&self) -> &str { diff --git a/crates/cli/src/auth.rs b/crates/cli/src/auth.rs index 12eda48..f06954c 100644 --- a/crates/cli/src/auth.rs +++ b/crates/cli/src/auth.rs @@ -37,12 +37,23 @@ pub fn is_token_expired(token: &str) -> bool { pub async fn ensure_auth() -> Result { if let Some(creds) = config::load_credentials() { + // PAT-based auth - no expiry to check + if creds.pat.is_some() { + return Ok(creds); + } + // Legacy JWT-based auth if !is_token_expired(&creds.access_token) { return Ok(creds); } - // Access token expired — try refreshing + // Access token expired - try refreshing then exchanging for PAT match refresh_token(&creds.refresh_token).await { - Ok(refreshed) => return Ok(refreshed), + Ok(refreshed) => { + // Try to upgrade to PAT + match exchange_for_pat(&refreshed.access_token).await { + Ok(pat_creds) => return Ok(pat_creds), + Err(_) => return Ok(refreshed), + } + } Err(_) => { let _ = config::clear_credentials(); } @@ -65,13 +76,56 @@ pub async fn login_flow() -> Result { } println!("Waiting for authentication..."); - let creds = wait_for_callback(server, &state)?; + let supabase_creds = wait_for_callback(server, &state)?; + + // Exchange Supabase tokens for a long-lived PAT + let creds = match exchange_for_pat(&supabase_creds.access_token).await { + Ok(pat_creds) => pat_creds, + Err(e) => { + eprintln!( + "Warning: Could not create long-lived token ({}). Using session token.", + e + ); + supabase_creds + } + }; config::save_credentials(&creds)?; println!("Logged in successfully."); Ok(creds) } +/// Exchange a short-lived Supabase JWT for a long-lived Personal Access Token. +async fn exchange_for_pat(access_token: &str) -> Result { + let cfg = config::load_config(); + let api_url = cfg.effective_api_url(); + let client = reqwest::Client::new(); + + let resp = client + .post(format!("{}/tokens/cli", api_url)) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("PAT creation failed ({}): {}", status, body)); + } + + let body: serde_json::Value = resp.json().await?; + let pat = body["token"] + .as_str() + .ok_or_else(|| anyhow!("No token in response"))? + .to_string(); + + Ok(Credentials { + access_token: String::new(), + refresh_token: String::new(), + pat: Some(pat), + }) +} + fn generate_state() -> String { let mut rng = rand::thread_rng(); (0..32) @@ -140,6 +194,7 @@ fn wait_for_callback(server: tiny_http::Server, expected_state: &str) -> Result< return Ok(Credentials { access_token: access_token.clone(), refresh_token: refresh_token.clone(), + pat: None, }); } @@ -190,6 +245,7 @@ pub async fn refresh_token(refresh_token: &str) -> Result { let creds = Credentials { access_token, refresh_token: new_refresh, + pat: None, }; config::save_credentials(&creds)?; Ok(creds) diff --git a/crates/cli/src/commands/login.rs b/crates/cli/src/commands/login.rs index e1824e2..3d91c36 100644 --- a/crates/cli/src/commands/login.rs +++ b/crates/cli/src/commands/login.rs @@ -8,11 +8,15 @@ use anyhow::Result; pub async fn run() -> Result<()> { if let Some(creds) = config::load_credentials() { + if creds.pat.is_some() { + println!("Already logged in. Use `lag logout` first to switch accounts."); + return Ok(()); + } 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 + // Access token expired - try refreshing match auth::refresh_token(&creds.refresh_token).await { Ok(_) => { println!("Session refreshed. You are logged in."); diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 5ce9a45..67a89fb 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -44,8 +44,12 @@ impl CliConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Credentials { + #[serde(default)] pub access_token: String, + #[serde(default)] pub refresh_token: String, + #[serde(default)] + pub pat: Option, } pub fn config_dir() -> PathBuf { @@ -124,11 +128,36 @@ mod tests { let creds = Credentials { access_token: "access_123".to_string(), refresh_token: "refresh_456".to_string(), + pat: None, }; let json = serde_json::to_string(&creds).unwrap(); let restored: Credentials = serde_json::from_str(&json).unwrap(); assert_eq!(restored.access_token, "access_123"); assert_eq!(restored.refresh_token, "refresh_456"); + assert!(restored.pat.is_none()); + } + + #[test] + fn credentials_serde_with_pat() { + let creds = Credentials { + access_token: String::new(), + refresh_token: String::new(), + pat: Some("lag_pat_abcd1234_secret".to_string()), + }; + let json = serde_json::to_string(&creds).unwrap(); + let restored: Credentials = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.pat.as_deref(), Some("lag_pat_abcd1234_secret")); + assert!(restored.access_token.is_empty()); + } + + #[test] + fn credentials_backward_compat_no_pat_field() { + // Old credential files without the pat field should deserialize fine + let json = r#"{"access_token":"abc","refresh_token":"def"}"#; + let creds: Credentials = serde_json::from_str(json).unwrap(); + assert_eq!(creds.access_token, "abc"); + assert_eq!(creds.refresh_token, "def"); + assert!(creds.pat.is_none()); } #[test] diff --git a/flake.nix b/flake.nix index 6ccaa45..3089b08 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ clippy rustc rustfmt + rust-analyzer ]; linuxNativeBuildInputs = with pkgs; [ @@ -107,12 +108,19 @@ devShells.default = pkgs.mkShell { inputsFrom = [ package ]; - packages = nativeBuildInputs; + packages = nativeBuildInputs ++ [ + pkgs.git + ]; LK_CUSTOM_WEBRTC = webrtcPrebuilt; shellHook = '' export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}" + echo "" + echo " Lag CLI dev environment loaded" + echo " $(rustc --version)" + echo " WebRTC prebuilt: ${webrtcTag}" + echo "" ''; }; }