use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CliProvider {
pub binary: String,
pub login_args: Vec<String>,
pub token_args: Vec<String>,
pub header: String,
#[serde(default)]
pub value_template: Option<String>,
pub url: String,
}
pub fn format_value(template: Option<&str>, token: &str) -> String {
match template {
Some(t) if t.contains("{token}") => t.replace("{token}", token),
_ => token.to_string(),
}
}
#[derive(Clone)]
pub struct CachedToken {
pub header: String,
pub value: String,
pub expires_at: Option<SystemTime>,
}
impl CachedToken {
pub fn valid(&self) -> bool {
match self.expires_at {
Some(exp) => SystemTime::now() + Duration::from_secs(30) < exp,
None => true, }
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResult {
pub expires_at: Option<u64>,
}
pub fn substitute(args: &[String], url: &str) -> Vec<String> {
args.iter().map(|a| a.replace("{url}", url)).collect()
}
pub fn jwt_expiry(token: &str) -> Option<SystemTime> {
let payload = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim())
.ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let exp = claims.get("exp")?.as_u64()?;
Some(UNIX_EPOCH + Duration::from_secs(exp))
}
pub async fn run_cli(binary: &str, args: &[String], timeout: Duration) -> Result<String, String> {
let run = tokio::process::Command::new(binary)
.args(args)
.kill_on_drop(true)
.output();
let output = tokio::time::timeout(timeout, run)
.await
.map_err(|_| format!("`{binary}` timed out after {}s.", timeout.as_secs()))?
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
format!("`{binary}` was not found. Is it installed and on your PATH?")
} else {
format!("Failed to run `{binary}`: {e}")
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = stderr.trim();
return Err(if msg.is_empty() {
format!("`{binary}` exited with a non-zero status.")
} else {
format!("`{binary}` failed: {msg}")
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn wrap_token(provider: &CliProvider, raw: &str) -> CachedToken {
CachedToken {
header: provider.header.clone(),
value: format_value(provider.value_template.as_deref(), raw),
expires_at: jwt_expiry(raw),
}
}
pub async fn fetch_token(provider: &CliProvider) -> Result<CachedToken, String> {
let token = run_cli(
&provider.binary,
&substitute(&provider.token_args, &provider.url),
Duration::from_secs(30),
)
.await?;
if token.is_empty() {
return Err(format!("`{}` returned an empty token.", provider.binary));
}
Ok(wrap_token(provider, &token))
}
pub async fn login(provider: &CliProvider) -> Result<CachedToken, String> {
let token = run_cli(
&provider.binary,
&substitute(&provider.login_args, &provider.url),
Duration::from_secs(300),
)
.await?;
if token.is_empty() {
return Err(format!(
"`{}` did not return a token after login.",
provider.binary
));
}
Ok(wrap_token(provider, &token))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitutes_url_placeholder() {
let args = vec![
"access".to_string(),
"token".to_string(),
"--app".to_string(),
"{url}".to_string(),
];
assert_eq!(
substitute(&args, "https://x.hanko.io/admin"),
vec!["access", "token", "--app", "https://x.hanko.io/admin"]
);
}
#[test]
fn formats_header_value() {
assert_eq!(format_value(None, "abc"), "abc");
assert_eq!(format_value(Some("Bearer {token}"), "abc"), "Bearer abc");
assert_eq!(format_value(Some("static"), "abc"), "abc");
}
#[test]
fn parses_jwt_exp_claim() {
let token = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDI0NDQ4MDB9.c2ln";
let exp = jwt_expiry(token).expect("should parse exp");
let secs = exp.duration_since(UNIX_EPOCH).unwrap().as_secs();
assert_eq!(secs, 4_102_444_800);
}
#[test]
fn missing_exp_yields_none() {
let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMifQ.c2ln";
assert!(jwt_expiry(token).is_none());
}
#[test]
fn cached_token_validity() {
let future = CachedToken {
header: "cf-access-token".into(),
value: "t".into(),
expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
};
assert!(future.valid());
let past = CachedToken {
header: "cf-access-token".into(),
value: "t".into(),
expires_at: Some(SystemTime::now()),
};
assert!(!past.valid());
let no_exp = CachedToken {
header: "cf-access-token".into(),
value: "t".into(),
expires_at: None,
};
assert!(no_exp.valid());
}
}