Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions crates/cli/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(())
}

Expand All @@ -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<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
self.ensure_fresh_token().await?;
let url = format!("{}{}", self.base_url, path);
Expand All @@ -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)
Expand All @@ -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());
}

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
62 changes: 59 additions & 3 deletions crates/cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,23 @@ pub fn is_token_expired(token: &str) -> bool {

pub async fn ensure_auth() -> Result<Credentials> {
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();
}
Expand All @@ -65,13 +76,56 @@ pub async fn login_flow() -> Result<Credentials> {
}

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<Credentials> {
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)
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -190,6 +245,7 @@ pub async fn refresh_token(refresh_token: &str) -> Result<Credentials> {
let creds = Credentials {
access_token,
refresh_token: new_refresh,
pat: None,
};
config::save_credentials(&creds)?;
Ok(creds)
Expand Down
6 changes: 5 additions & 1 deletion crates/cli/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
29 changes: 29 additions & 0 deletions crates/cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

pub fn config_dir() -> PathBuf {
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 9 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
clippy
rustc
rustfmt
rust-analyzer
];

linuxNativeBuildInputs = with pkgs; [
Expand Down Expand Up @@ -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 ""
'';
};
}
Expand Down
Loading