use super::b64;
use super::key::AgentKey;
use super::sig::{self, SigKey};
use crate::net::http::{self, Url};
use serde::Deserialize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const REFRESH_SKEW: Duration = Duration::from_secs(60);
const DEFAULT_TTL: Duration = Duration::from_secs(3600);
#[derive(Debug, Clone)]
pub struct ApdConfig {
pub base_url: String,
pub enrollment_token: Option<String>,
pub enroll_assertion_file: Option<String>,
pub person_server: Option<String>,
pub platform: String,
}
#[derive(Deserialize)]
struct EnrollResp {
agent: String,
}
#[derive(Deserialize)]
struct TokenResp {
agent_token: String,
#[serde(default)]
expires_in: Option<u64>,
#[serde(default)]
agent: Option<String>,
}
struct Cached {
token: String,
good_until: Instant,
}
pub struct ApdClient {
config: ApdConfig,
key: AgentKey,
timeout: Duration,
agent_id: Mutex<Option<String>>,
cached: Mutex<Option<Cached>>,
}
impl ApdClient {
pub fn new(config: ApdConfig, key: AgentKey, timeout: Duration) -> ApdClient {
ApdClient {
config,
key,
timeout,
agent_id: Mutex::new(None),
cached: Mutex::new(None),
}
}
pub fn agent_id(&self) -> Option<String> {
self.agent_id
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub(super) fn key(&self) -> &AgentKey {
&self.key
}
pub fn token(&self) -> Result<String, String> {
{
let cache = self.cached.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = cache.as_ref()
&& Instant::now() < c.good_until
{
return Ok(c.token.clone());
}
}
self.enroll_if_needed()?;
let fresh = self.fetch_token()?;
let token = fresh.token.clone();
*self.cached.lock().unwrap_or_else(|e| e.into_inner()) = Some(fresh);
Ok(token)
}
fn enroll_if_needed(&self) -> Result<(), String> {
if self.agent_id().is_some() {
return Ok(());
}
let mut body = serde_json::json!({ "platform": self.config.platform });
if let Some(t) = &self.config.enrollment_token {
body["enrollment_token"] = serde_json::Value::String(t.clone());
}
if let Some(path) = &self.config.enroll_assertion_file {
let assertion = std::fs::read_to_string(path)
.map_err(|e| format!("aauth: enrollment assertion file {path}: {e}"))?;
let assertion = assertion.trim();
if assertion.is_empty() {
return Err(format!("aauth: enrollment assertion file {path} is empty"));
}
body["enrollment_assertion"] = serde_json::Value::String(assertion.to_string());
}
if let Some(ps) = &self.config.person_server {
body["ps"] = serde_json::Value::String(ps.clone());
}
let resp: EnrollResp = self.signed_post("/enroll", &body)?;
*self.agent_id.lock().unwrap_or_else(|e| e.into_inner()) = Some(resp.agent);
Ok(())
}
pub(super) fn verify_provider_metadata(&self) -> Result<(), String> {
super::discover::fetch_agent_provider(&self.config.base_url, self.timeout).map(|_| ())
}
fn fetch_token(&self) -> Result<Cached, String> {
let resp: TokenResp = self.signed_post("/agent-token", &serde_json::json!({}))?;
if let Some(agent) = resp.agent {
*self.agent_id.lock().unwrap_or_else(|e| e.into_inner()) = Some(agent);
}
let exp = inspect_agent_token(
&resp.agent_token,
&self.key,
Some(&self.config.base_url),
self.config.person_server.as_deref(),
)?;
let ttl = exp
.map(|e| Duration::from_secs(e.saturating_sub(sig::now_secs())))
.or_else(|| resp.expires_in.map(Duration::from_secs))
.unwrap_or(DEFAULT_TTL);
Ok(Cached {
token: resp.agent_token,
good_until: Instant::now() + ttl.saturating_sub(REFRESH_SKEW),
})
}
fn signed_post<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, String> {
let full = format!("{}{path}", self.config.base_url.trim_end_matches('/'));
let url = Url::parse(&full).map_err(|e| format!("aauth: apd url {full}: {e}"))?;
let bytes = serde_json::to_vec(body).unwrap_or_default();
let digest = sig::content_digest(&bytes);
let owned = sig::sign_request(
&self.key,
"POST",
&url.host_header(),
&url.path,
SigKey::Hwk,
sig::now_secs(),
Some(&digest),
);
let mut headers: Vec<(&str, &str)> = vec![("Content-Type", "application/json")];
for (k, v) in &owned {
headers.push((k.as_str(), v.as_str()));
}
let mut stream = connect(&url, self.timeout)?;
let resp = http::send(
stream.as_mut(),
&url.host_header(),
"POST",
&url.path,
&headers,
&bytes,
)
.map_err(|e| format!("aauth: apd {path}: {e}"))?;
if !resp.is_success() {
return Err(format!(
"aauth: apd {path} returned HTTP {} ({})",
resp.status,
resp.header("signature-error")
.or_else(|| resp.header("aauth-error"))
.unwrap_or("no detail")
));
}
serde_json::from_slice(&resp.body)
.map_err(|e| format!("aauth: apd {path}: bad response: {e}"))
}
}
fn connect(url: &Url, timeout: Duration) -> Result<Box<dyn http::Stream>, String> {
let tcp = http::connect_tcp(&url.host, url.port, timeout)
.map_err(|e| format!("aauth: connect {}: {e}", url.host))?;
if url.is_tls() {
#[cfg(feature = "tls")]
{
let s = crate::net::tls::connect(tcp, &url.host, None)
.map_err(|e| format!("aauth: tls {}: {e}", url.host))?;
Ok(Box::new(s))
}
#[cfg(not(feature = "tls"))]
{
Err("aauth: https apd requires --features tls".to_string())
}
} else {
Ok(Box::new(tcp))
}
}
fn inspect_agent_token(
token: &str,
key: &AgentKey,
expected_iss: Option<&str>,
expected_ps: Option<&str>,
) -> Result<Option<u64>, String> {
let Some(payload_b64) = token.split('.').nth(1) else {
return Ok(None); };
let Ok(bytes) = b64::url_decode(payload_b64) else {
return Ok(None);
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return Ok(None);
};
if let (Some(want), Some(iss)) = (expected_iss, claims.get("iss").and_then(|v| v.as_str()))
&& !super::discover::issuer_matches(iss, want)
{
return Err(format!(
"aauth: agent token iss {iss:?} is not the configured provider {want:?}"
));
}
if let (Some(want), Some(ps)) = (expected_ps, claims.get("ps").and_then(|v| v.as_str()))
&& !super::discover::issuer_matches(ps, want)
{
return Err(format!(
"aauth: agent token ps {ps:?} is not the configured person server {want:?}"
));
}
if let Some(cnf) = claims.get("cnf").and_then(|c| c.get("jwk")) {
let ours = key.public_jwk();
let matches = ["kty", "crv", "x"]
.iter()
.all(|f| cnf.get(*f) == ours.get(*f));
if !matches {
return Err("aauth: agent token cnf.jwk does not match the signing key".into());
}
}
Ok(claims.get("exp").and_then(|e| e.as_u64()))
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt(claims: serde_json::Value) -> String {
let payload = b64::url_nopad(serde_json::to_vec(&claims).unwrap().as_slice());
format!("e30.{payload}.sig") }
fn test_key() -> AgentKey {
AgentKey::from_seed(&[9u8; 32]).unwrap()
}
const AP: Option<&str> = Some("https://ap.example");
const PS: Option<&str> = Some("https://ps.example");
#[test]
fn exp_is_read_from_the_token() {
let key = test_key();
let tok = jwt(serde_json::json!({ "exp": 1_800_000_000u64 }));
assert_eq!(
inspect_agent_token(&tok, &key, AP, PS).unwrap(),
Some(1_800_000_000)
);
}
#[test]
fn matching_cnf_passes_and_absent_cnf_is_fine() {
let key = test_key();
let tok = jwt(serde_json::json!({ "exp": 42u64, "cnf": { "jwk": key.public_jwk() } }));
assert_eq!(inspect_agent_token(&tok, &key, AP, PS).unwrap(), Some(42));
let tok = jwt(serde_json::json!({ "exp": 42u64 }));
assert_eq!(inspect_agent_token(&tok, &key, AP, PS).unwrap(), Some(42));
}
#[test]
fn mismatched_cnf_is_a_hard_error() {
let key = test_key();
let other = AgentKey::from_seed(&[1u8; 32]).unwrap();
let tok = jwt(serde_json::json!({ "exp": 42u64, "cnf": { "jwk": other.public_jwk() } }));
let err = inspect_agent_token(&tok, &key, AP, PS).unwrap_err();
assert!(err.contains("cnf.jwk"), "{err}");
}
#[test]
fn matching_iss_passes_mismatched_iss_hard_errors() {
let key = test_key();
let tok = jwt(serde_json::json!({ "exp": 42u64, "iss": "https://ap.example/" }));
assert_eq!(inspect_agent_token(&tok, &key, AP, PS).unwrap(), Some(42));
let tok = jwt(serde_json::json!({ "exp": 42u64, "iss": "https://evil.example" }));
let err = inspect_agent_token(&tok, &key, AP, PS).unwrap_err();
assert!(err.contains("iss"), "{err}");
assert_eq!(inspect_agent_token(&tok, &key, None, PS).unwrap(), Some(42));
}
#[test]
fn matching_ps_passes_mismatched_ps_hard_errors() {
let key = test_key();
let tok = jwt(serde_json::json!({ "exp": 42u64, "ps": "https://ps.example" }));
assert_eq!(inspect_agent_token(&tok, &key, AP, PS).unwrap(), Some(42));
let tok = jwt(serde_json::json!({ "exp": 42u64, "ps": "https://other-ps.example" }));
let err = inspect_agent_token(&tok, &key, AP, PS).unwrap_err();
assert!(err.contains("ps"), "{err}");
assert_eq!(inspect_agent_token(&tok, &key, AP, None).unwrap(), Some(42));
}
#[test]
fn opaque_or_unparseable_token_is_legacy_none() {
let key = test_key();
assert_eq!(
inspect_agent_token("opaque-token", &key, AP, PS).unwrap(),
None
);
assert_eq!(inspect_agent_token("a.!!!.c", &key, AP, PS).unwrap(), None);
let tok = jwt(serde_json::json!({ "sub": "aauth:x@ap" }));
assert_eq!(inspect_agent_token(&tok, &key, AP, PS).unwrap(), None);
}
}