use std::{
fs::{read_to_string, remove_file, write},
path::PathBuf,
};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use crate::constants::get_iam_api_url;
pub(crate) const API_KEY_PREFIX: &str = "flk_";
pub(crate) fn exchange_api_key(api_key: &str) -> Result<(String, i64)> {
let client = reqwest::blocking::Client::new();
let response = client
.get(format!("{}/api/auth/token", get_iam_api_url()))
.header("x-api-key", api_key)
.header("Accept", "application/json")
.send()?;
let status = response.status();
if !status.is_success() {
bail!(
"The API key was refused ({}). Check that it is current, and that it \
belongs to the environment this CLI points at.",
status
);
}
#[derive(Deserialize)]
struct JwtTokenResponse {
token: String,
#[serde(rename = "expiresIn")]
expires_in: Option<i64>,
}
let jwt: JwtTokenResponse = response.json()?;
let expires_at = token_expires_at(&jwt.token, jwt.expires_in);
Ok((jwt.token, expires_at))
}
const DEFAULT_JWT_LIFETIME_SECS: i64 = 7 * 24 * 60 * 60;
fn token_expires_at(token: &str, expires_in: Option<i64>) -> i64 {
match expires_in {
Some(secs) => chrono::Utc::now().timestamp() + secs,
None => jwt_expiry(token)
.unwrap_or_else(|| chrono::Utc::now().timestamp() + DEFAULT_JWT_LIFETIME_SECS),
}
}
#[derive(Debug, Serialize, Deserialize)]
struct TokenData {
access_token: String,
refresh_token: String,
expires_at: i64,
}
pub(crate) fn get_token_path() -> Result<PathBuf> {
Ok(super::accounts::selection()?.token_path)
}
fn is_token_expired(expires_at: i64) -> bool {
let now = chrono::Utc::now().timestamp();
expires_at <= now + 60
}
fn refresh_token(current_token: &str) -> Result<TokenData> {
if current_token.starts_with(API_KEY_PREFIX) {
let (access_token, expires_at) = exchange_api_key(current_token)?;
return Ok(TokenData {
access_token,
refresh_token: current_token.to_string(),
expires_at,
});
}
let api_url = get_iam_api_url();
let client = reqwest::blocking::Client::new();
let response = client
.get(format!("{}/api/auth/token", api_url))
.header(
"Cookie",
format!("better-auth.session_token={}", current_token),
)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.send()?;
if !response.status().is_success() {
bail!("Failed to refresh token: {}", response.status());
}
#[derive(Deserialize)]
struct JwtTokenResponse {
token: String,
#[serde(rename = "refreshToken")]
refresh_token: Option<String>,
#[serde(rename = "expiresIn")]
expires_in: Option<i64>,
}
let jwt_data: JwtTokenResponse = response.json()?;
let expires_at = token_expires_at(&jwt_data.token, jwt_data.expires_in);
let refresh_token = jwt_data
.refresh_token
.filter(|t| !t.is_empty())
.unwrap_or_else(|| current_token.to_string());
Ok(TokenData {
access_token: jwt_data.token,
refresh_token,
expires_at,
})
}
fn save_token_data(token_data: &TokenData) -> Result<()> {
let token_path = get_token_path()?;
let toml_content = toml::to_string(token_data)?;
write(&token_path, toml_content)?;
Ok(())
}
pub(crate) fn force_refresh_token() -> anyhow::Result<String> {
let token_path = get_token_path()?;
let toml_content = read_to_string(&token_path)?;
let token_data: TokenData = toml::from_str(&toml_content)?;
if token_data.refresh_token.is_empty() {
bail!("No refresh token stored");
}
let new_token_data = refresh_token(&token_data.refresh_token)?;
save_token_data(&new_token_data)?;
Ok(new_token_data.access_token)
}
pub(crate) fn jwt_expiry(token: &str) -> Option<i64> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let payload = token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
claims.get("exp")?.as_i64()
}
pub(crate) fn get_token() -> anyhow::Result<String> {
let token_path = get_token_path()?;
if !token_path.exists() {
bail!(
"{}",
super::accounts::missing_login_message(&super::accounts::selection()?)
);
}
let toml_content = read_to_string(&token_path)?;
let mut token_data: TokenData = toml::from_str(&toml_content).map_err(|e| {
anyhow::anyhow!(
"Failed to parse token file: {}. Please run `forklaunch login` again",
e
)
})?;
if is_token_expired(token_data.expires_at) {
if token_data.refresh_token.is_empty() {
let _ = remove_file(&token_path);
bail!(
"Your API token expired. Generate a new one in the dashboard and run `forklaunch login --token <token>`"
);
}
match refresh_token(&token_data.refresh_token) {
Ok(new_token_data) => {
save_token_data(&new_token_data)?;
token_data = new_token_data;
}
Err(_) => {
let _ = remove_file(&token_path);
bail!("Authentication expired. Please run `forklaunch login` to re-authenticate");
}
}
}
Ok(token_data.access_token)
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt_with(claims: &str) -> String {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
format!(
"{}.{}.sig",
URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#),
URL_SAFE_NO_PAD.encode(claims)
)
}
#[test]
fn api_key_prefix_never_matches_a_jwt() {
assert!("flk_abc123".starts_with(API_KEY_PREFIX));
assert!(!jwt_with(r#"{"sub":"u","exp":1}"#).starts_with(API_KEY_PREFIX));
assert!(!"eyJhbGciOiJSUzI1NiJ9.e30.sig".starts_with(API_KEY_PREFIX));
}
#[test]
fn jwt_expiry_reads_exp_claim() {
assert_eq!(
jwt_expiry(&jwt_with(r#"{"sub":"u","exp":1789701966}"#)),
Some(1789701966)
);
}
#[test]
fn token_expiry_uses_exp_claim_when_response_has_no_expires_in() {
let token = jwt_with(r#"{"sub":"u","exp":1789701966}"#);
assert_eq!(token_expires_at(&token, None), 1789701966);
}
#[test]
fn token_expiry_prefers_explicit_expires_in() {
let token = jwt_with(r#"{"sub":"u","exp":1}"#);
let now = chrono::Utc::now().timestamp();
let at = token_expires_at(&token, Some(60));
assert!(at >= now + 60 && at <= now + 61);
}
#[test]
fn token_expiry_falls_back_to_seven_days() {
let now = chrono::Utc::now().timestamp();
let at = token_expires_at("not-a-jwt", None);
assert!(at >= now + DEFAULT_JWT_LIFETIME_SECS && at <= now + DEFAULT_JWT_LIFETIME_SECS + 1);
}
#[test]
fn token_response_without_expires_in_parses() {
#[derive(Deserialize)]
struct JwtTokenResponse {
#[allow(dead_code)]
token: String,
#[serde(rename = "expiresIn")]
expires_in: Option<i64>,
}
let parsed: JwtTokenResponse = serde_json::from_str(r#"{"token":"eyJ.x.y"}"#).unwrap();
assert_eq!(parsed.expires_in, None);
}
#[test]
fn jwt_expiry_is_none_for_tokens_without_exp_or_not_jwts() {
assert_eq!(jwt_expiry(&jwt_with(r#"{"sub":"u"}"#)), None);
assert_eq!(jwt_expiry("not-a-jwt"), None);
assert_eq!(jwt_expiry("a.%%%.c"), None);
}
}