use crate::auth::token::{AccessToken, CachedToken};
use crate::error::AzureError;
pub struct AzureCliCredential {
cache: CachedToken,
}
impl Default for AzureCliCredential {
fn default() -> Self {
Self::new()
}
}
impl AzureCliCredential {
pub fn new() -> Self {
Self {
cache: CachedToken::new(),
}
}
pub async fn get_token(&self) -> Result<AccessToken, AzureError> {
if let Some(cached) = self.cache.get().await {
return Ok(cached);
}
let token = self.fetch_token("https://management.azure.com/").await?;
self.cache.set(token.clone()).await;
Ok(token)
}
pub(crate) async fn get_token_for_scope(&self, scope: &str) -> Result<AccessToken, AzureError> {
let resource = scope.trim_end_matches("/.default");
let resource = if resource.ends_with('/') {
resource.to_string()
} else {
format!("{resource}/")
};
self.fetch_token(&resource).await
}
async fn fetch_token(&self, resource: &str) -> Result<AccessToken, AzureError> {
let output = tokio::process::Command::new("az")
.args([
"account",
"get-access-token",
"--resource",
resource,
"--output",
"json",
])
.output()
.await
.map_err(|e| AzureError::Auth {
message: format!("Failed to run 'az account get-access-token': {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AzureError::Auth {
message: format!("az account get-access-token failed: {stderr}"),
});
}
let body = String::from_utf8_lossy(&output.stdout);
parse_cli_token_response(&body)
}
}
fn parse_cli_token_response(body: &str) -> Result<AccessToken, AzureError> {
let val: serde_json::Value =
serde_json::from_str(body.trim()).map_err(|e| AzureError::InvalidResponse {
message: format!("Failed to parse az CLI token response: {e}"),
body: Some(body.to_string()),
})?;
let token = val
.get("accessToken")
.and_then(|v| v.as_str())
.ok_or_else(|| AzureError::InvalidResponse {
message: "az CLI token response missing accessToken".into(),
body: Some(body.to_string()),
})?
.to_string();
let expires_at = if let Some(ts) = val.get("expires_on").and_then(|v| v.as_str()) {
ts.parse::<u64>().unwrap_or_else(|_| fallback_expiry())
} else if let Some(datetime_str) = val.get("expiresOn").and_then(|v| v.as_str()) {
parse_expires_on(datetime_str)
} else {
fallback_expiry()
};
Ok(AccessToken::new(token, expires_at))
}
fn parse_expires_on(s: &str) -> u64 {
let s = s.trim();
let date_time = s.split('.').next().unwrap_or(s);
let parts: Vec<&str> = date_time.split_whitespace().collect();
if parts.len() != 2 {
return fallback_expiry();
}
let date_parts: Vec<u32> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
let time_parts: Vec<u32> = parts[1].split(':').filter_map(|p| p.parse().ok()).collect();
if date_parts.len() != 3 || time_parts.len() != 3 {
return fallback_expiry();
}
let (year, month, day) = (date_parts[0], date_parts[1], date_parts[2]);
let (hour, min, sec) = (time_parts[0], time_parts[1], time_parts[2]);
let days = days_since_epoch(year, month, day);
days * 86400 + hour as u64 * 3600 + min as u64 * 60 + sec as u64
}
fn days_since_epoch(year: u32, month: u32, day: u32) -> u64 {
let y = year as i64;
let m = month as i64;
let d = day as i64;
let a = (14 - m) / 12;
let yr = y + 4800 - a;
let mr = m + 12 * a - 3;
let jdn = d + (153 * mr + 2) / 5 + 365 * yr + yr / 4 - yr / 100 + yr / 400 - 32045;
let epoch_jdn: i64 = 2_440_588;
(jdn - epoch_jdn).max(0) as u64
}
fn fallback_expiry() -> u64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now + 55 * 60 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cli_response_with_expires_on_timestamp() {
let body =
r#"{"accessToken": "tok123", "expires_on": "9999999999", "tokenType": "Bearer"}"#;
let tok = parse_cli_token_response(body).unwrap();
assert_eq!(tok.token, "tok123");
assert_eq!(tok.expires_at, 9_999_999_999);
}
#[test]
fn parse_cli_response_with_expires_on_datetime() {
let body = r#"{"accessToken": "tok456", "expiresOn": "2099-01-01 12:00:00.000000", "tokenType": "Bearer"}"#;
let tok = parse_cli_token_response(body).unwrap();
assert_eq!(tok.token, "tok456");
assert!(tok.expires_at > 0);
assert!(tok.seconds_remaining() > 0);
}
#[test]
fn parse_cli_response_missing_token() {
let body = r#"{"expiresOn": "2099-01-01 12:00:00"}"#;
let err = parse_cli_token_response(body).unwrap_err();
assert!(matches!(err, AzureError::InvalidResponse { .. }));
}
#[test]
fn days_since_epoch_known_dates() {
assert_eq!(days_since_epoch(1970, 1, 1), 0);
assert_eq!(days_since_epoch(1970, 1, 2), 1);
assert_eq!(days_since_epoch(1970, 2, 1), 31);
}
#[test]
fn parse_expires_on_known_date() {
let ts = parse_expires_on("1970-01-01 00:00:00.000000");
assert_eq!(ts, 0);
let ts = parse_expires_on("1970-01-01 01:00:00.000000");
assert_eq!(ts, 3600);
}
}