use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
static TOKEN_CACHE: Mutex<Option<CachedToken>> = Mutex::new(None);
const FABRIC_RESOURCE: &str = "https://analysis.windows.net/powerbi/api";
#[derive(Debug, Deserialize, Clone)]
pub struct AzToken {
#[serde(rename = "accessToken")]
pub access_token: String,
#[serde(rename = "expiresOn")]
pub expires_on: String,
#[serde(rename = "expires_on", default)]
pub expires_on_epoch: Option<i64>,
pub tenant: Option<String>,
pub subscription: Option<String>,
}
#[derive(Clone)]
struct CachedToken {
token: String,
expires_at: u64,
}
#[derive(Debug)]
pub struct AuthStatus {
pub tenant: String,
pub subscription: String,
pub token_valid: bool,
pub expires_on: String,
}
fn az_executable() -> Result<PathBuf> {
static AZ_PATH: OnceLock<PathBuf> = OnceLock::new();
if let Some(path) = AZ_PATH.get() {
return Ok(path.clone());
}
let path = which::which("az")
.context("Failed to locate 'az' CLI on PATH; is Azure CLI installed?")?;
let _ = AZ_PATH.set(path.clone());
Ok(path)
}
fn az_get_token(resource: &str) -> Result<AzToken> {
let az = az_executable()?;
let output = Command::new(&az)
.args([
"account",
"get-access-token",
"--resource",
resource,
"--output",
"json",
])
.output()
.context("Failed to execute az CLI")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("az account get-access-token failed: {}", stderr.trim());
}
let token: AzToken =
serde_json::from_slice(&output.stdout).context("Failed to parse az CLI token output")?;
Ok(token)
}
fn token_expiry_epoch(token: &AzToken) -> u64 {
token.expires_on_epoch.filter(|&e| e > 0).unwrap_or(0) as u64
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn get_fabric_token() -> Result<String> {
get_cached_token(&TOKEN_CACHE, FABRIC_RESOURCE)
}
fn get_cached_token(cache: &Mutex<Option<CachedToken>>, resource: &str) -> Result<String> {
let mut lock = cache.lock().unwrap();
if let Some(ref cached) = *lock {
if now_epoch() + 60 < cached.expires_at {
return Ok(cached.token.clone());
}
}
let az = az_get_token(resource)?;
let expires_at = token_expiry_epoch(&az);
let token = az.access_token.clone();
*lock = Some(CachedToken {
token: token.clone(),
expires_at,
});
Ok(token)
}
pub fn check_auth_status() -> Result<AuthStatus> {
let az = az_get_token(FABRIC_RESOURCE)?;
let expiry = token_expiry_epoch(&az);
Ok(AuthStatus {
tenant: az.tenant.unwrap_or_else(|| "unknown".into()),
subscription: az.subscription.unwrap_or_else(|| "unknown".into()),
token_valid: expiry > now_epoch(),
expires_on: az.expires_on,
})
}