use crate::auth_di;
use crate::credentials::CredentialBundle;
use crate::error::VtaError;
use reqwest::Client;
const TRUST_TASK_HEADER: &str = "Trust-Task";
#[derive(Clone)]
pub struct AuthResult {
pub access_token: String,
pub access_expires_at: u64,
pub refresh_token: Option<String>,
pub refresh_expires_at: Option<u64>,
}
impl std::fmt::Debug for AuthResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthResult")
.field("access_token", &"<redacted>")
.field("access_expires_at", &self.access_expires_at)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("refresh_expires_at", &self.refresh_expires_at)
.finish()
}
}
pub async fn challenge_response_light(
http: &Client,
base_url: &str,
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
) -> Result<AuthResult, crate::error::VtaError> {
let trust_tasks_url = format!("{base_url}/trust-tasks");
let challenge_body = auth_di::build_challenge_doc(client_did, vta_did, client_did)
.map_err(|e| VtaError::Validation(e.to_string()))?;
let challenge_resp = http
.post(&trust_tasks_url)
.header("content-type", "application/json")
.header(
TRUST_TASK_HEADER,
crate::trust_tasks::TASK_AUTH_CHALLENGE_0_1,
)
.body(challenge_body)
.send()
.await?;
if !challenge_resp.status().is_success() {
return Err(VtaError::from_response(challenge_resp).await);
}
let server_date = response_date(challenge_resp.headers());
let challenge = auth_di::parse_challenge_response(&challenge_resp.text().await?)
.map_err(|e| VtaError::Validation(e.to_string()))?;
let deadline = authenticate_deadline(
&challenge.expires_at,
server_date.as_deref(),
chrono::Utc::now(),
);
let auth_text = redeem_challenge(
http,
&trust_tasks_url,
client_did,
private_key_multibase,
vta_did,
&challenge,
deadline,
)
.await?;
let auth_data = auth_di::parse_auth_response(&auth_text)
.map_err(|e| VtaError::Validation(e.to_string()))?;
let access_expires_at = auth_data.access_expires_at_epoch().ok_or_else(|| {
VtaError::Validation(format!(
"VTA returned unparseable session.issuedAt: '{}'",
auth_data.session.issued_at
))
})?;
let refresh_expires_at = auth_data.refresh_expires_at_epoch();
Ok(AuthResult {
access_token: auth_data.tokens.access_token,
access_expires_at,
refresh_token: auth_data.tokens.refresh_token,
refresh_expires_at,
})
}
pub(crate) async fn redeem_challenge(
http: &Client,
trust_tasks_url: &str,
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
challenge: &crate::protocols::auth::ChallengeResponse,
deadline: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<String, VtaError> {
loop {
let body = auth_di::sign_authenticate_doc(
client_did,
private_key_multibase,
vta_did,
&challenge.challenge,
&challenge.session_id,
)
.await
.map_err(|e| VtaError::Validation(e.to_string()))?;
let resp = http
.post(trust_tasks_url)
.header("content-type", "application/json")
.header(
TRUST_TASK_HEADER,
crate::trust_tasks::TASK_AUTH_AUTHENTICATE_0_2,
)
.body(body)
.send()
.await?;
if resp.status().is_success() {
return Ok(resp.text().await?);
}
let err = VtaError::from_response(resp).await;
match authenticate_retry_wait(&err, deadline, chrono::Utc::now(), retry_jitter()) {
Some(wait) => {
tracing::info!(
wait_ms = wait.as_millis() as u64,
"authenticate rate limited; redeeming the same challenge again"
);
tokio::time::sleep(wait).await;
}
None => return Err(err),
}
}
}
pub(crate) fn response_date(headers: &reqwest::header::HeaderMap) -> Option<String> {
headers
.get(reqwest::header::DATE)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
const MAX_AUTHENTICATE_RETRY_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
const MAX_AUTHENTICATE_RETRY_JITTER_MS: u64 = 1_000;
const MIN_AUTHENTICATE_RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
pub(crate) fn authenticate_deadline(
expires_at: &str,
server_date: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<chrono::DateTime<chrono::Utc>> {
let expiry = chrono::DateTime::parse_from_rfc3339(expires_at)
.ok()?
.with_timezone(&chrono::Utc);
let window = chrono::Duration::from_std(MAX_AUTHENTICATE_RETRY_WINDOW).ok()?;
let lifetime = match server_date.and_then(|d| chrono::DateTime::parse_from_rfc2822(d).ok()) {
Some(answered) => {
expiry - answered.with_timezone(&chrono::Utc) - chrono::Duration::seconds(1)
}
None => expiry - now,
};
Some(now + lifetime.min(window))
}
fn retry_jitter() -> std::time::Duration {
use std::hash::{BuildHasher, Hasher};
let r = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
std::time::Duration::from_millis(r % MAX_AUTHENTICATE_RETRY_JITTER_MS)
}
fn authenticate_retry_wait(
err: &VtaError,
deadline: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
jitter: std::time::Duration,
) -> Option<std::time::Duration> {
use crate::rate_limit::RateLimitSource;
let VtaError::RateLimited {
limited_by: RateLimitSource::Vta | RateLimitSource::Vtc,
retry_after,
..
} = err
else {
return None;
};
let wait = retry_after
.and_then(|at| (at - now).to_std().ok())
.unwrap_or_default()
.max(MIN_AUTHENTICATE_RETRY_WAIT)
+ jitter;
let resume = now + chrono::Duration::from_std(wait).ok()?;
(resume < deadline?).then_some(wait)
}
pub async fn refresh_token_light(
http: &Client,
base_url: &str,
client_did: &str,
vta_did: &str,
refresh_token: &str,
) -> Result<AuthResult, crate::error::VtaError> {
let body = auth_di::build_refresh_doc(client_did, vta_did, refresh_token)
.map_err(|e| VtaError::Validation(e.to_string()))?;
let refresh_url = format!("{base_url}/trust-tasks");
let resp = http
.post(&refresh_url)
.header("content-type", "application/json")
.header(TRUST_TASK_HEADER, crate::trust_tasks::TASK_AUTH_REFRESH_0_2)
.body(body)
.send()
.await?;
if !resp.status().is_success() {
return Err(VtaError::from_response(resp).await);
}
let auth_data = auth_di::parse_auth_response(&resp.text().await?)
.map_err(|e| VtaError::Validation(e.to_string()))?;
let access_expires_at = auth_data.access_expires_at_epoch().ok_or_else(|| {
VtaError::Validation(format!(
"VTA returned unparseable session.issuedAt: '{}'",
auth_data.session.issued_at
))
})?;
let refresh_expires_at = auth_data.refresh_expires_at_epoch();
Ok(AuthResult {
access_token: auth_data.tokens.access_token,
access_expires_at,
refresh_token: auth_data.tokens.refresh_token,
refresh_expires_at,
})
}
pub async fn authenticate_with_credential(
credential: &CredentialBundle,
url_override: Option<&str>,
) -> Result<(AuthResult, CredentialBundle, Client), crate::error::VtaError> {
let url = url_override
.or(credential.vta_url.as_deref())
.ok_or_else(|| {
VtaError::Validation("no VTA URL in credential and no override provided".into())
})?;
let http = Client::new();
let result = challenge_response_light(
&http,
url,
&credential.did,
&credential.private_key_multibase,
&credential.vta_did,
)
.await?;
Ok((result, credential.clone(), http))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rate_limit::RateLimitSource;
use chrono::{Duration, TimeZone, Utc};
fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}
fn limited(limited_by: RateLimitSource, after_secs: Option<i64>) -> VtaError {
VtaError::RateLimited {
limited_by,
retry_after: after_secs.map(|s| now() + Duration::seconds(s)),
limiter: Some("auth-authenticate".into()),
url: None,
}
}
fn secs(s: u64) -> Option<std::time::Duration> {
Some(std::time::Duration::from_secs(s))
}
const NO_JITTER: std::time::Duration = std::time::Duration::ZERO;
#[test]
fn a_long_queue_is_retried_until_the_challenge_expires() {
let expires = Some(now() + Duration::seconds(300));
for elapsed in [0, 60, 200, 294] {
let at = now() + Duration::seconds(elapsed);
let err = VtaError::RateLimited {
limited_by: RateLimitSource::Vta,
retry_after: Some(at + Duration::seconds(5)),
limiter: None,
url: None,
};
assert_eq!(
authenticate_retry_wait(&err, expires, at, NO_JITTER),
secs(5),
"{elapsed} s in"
);
}
}
#[test]
fn jitter_is_added_and_counted_against_the_deadline() {
let err = limited(RateLimitSource::Vta, Some(5));
let jitter = std::time::Duration::from_millis(700);
let roomy = Some(now() + Duration::seconds(60));
assert_eq!(
authenticate_retry_wait(&err, roomy, now(), jitter),
Some(std::time::Duration::from_millis(5_700))
);
let tight = Some(now() + Duration::milliseconds(5_500));
assert_eq!(authenticate_retry_wait(&err, tight, now(), jitter), None);
}
#[test]
fn retry_jitter_stays_under_its_bound() {
for _ in 0..1_000 {
assert!(
retry_jitter() < std::time::Duration::from_millis(MAX_AUTHENTICATE_RETRY_JITTER_MS)
);
}
}
#[test]
fn a_vta_rate_limit_is_waited_out_while_the_challenge_lives() {
let expires = Some(now() + Duration::seconds(60));
let err = limited(RateLimitSource::Vta, Some(5));
assert_eq!(
authenticate_retry_wait(&err, expires, now(), NO_JITTER),
secs(5)
);
}
#[test]
fn a_wait_that_outlives_the_challenge_is_not_taken() {
let expires = Some(now() + Duration::seconds(5));
let err = limited(RateLimitSource::Vta, Some(5));
assert_eq!(
authenticate_retry_wait(&err, expires, now(), NO_JITTER),
None
);
assert_eq!(
authenticate_retry_wait(&err, None, now(), NO_JITTER),
None,
"unknown expiry"
);
}
#[test]
fn a_long_wait_that_fits_the_challenge_is_taken() {
let expires = Some(now() + Duration::seconds(300));
let err = limited(RateLimitSource::Vta, Some(60));
assert_eq!(
authenticate_retry_wait(&err, expires, now(), NO_JITTER),
secs(60)
);
let err = limited(RateLimitSource::Vta, Some(301));
assert_eq!(
authenticate_retry_wait(&err, expires, now(), NO_JITTER),
None
);
}
#[test]
fn the_deadline_is_the_expiry_capped_by_the_retry_window() {
let soon = (now() + Duration::seconds(300)).to_rfc3339();
assert_eq!(
authenticate_deadline(&soon, None, now()),
Some(now() + Duration::seconds(300))
);
let late = (now() + Duration::hours(1)).to_rfc3339();
assert_eq!(
authenticate_deadline(&late, None, now()),
Some(now() + Duration::seconds(600))
);
assert_eq!(authenticate_deadline("not a time", None, now()), None);
}
#[test]
fn the_deadline_follows_the_servers_clock_not_ours() {
for skew in [Duration::hours(1), Duration::hours(-1)] {
let server_now = now() + skew;
let expires = (server_now + Duration::seconds(300)).to_rfc3339();
let date = server_now.to_rfc2822();
assert_eq!(
authenticate_deadline(&expires, Some(&date), now()),
Some(now() + Duration::seconds(299)),
"server clock {skew} from ours"
);
}
let expires = (now() + Duration::hours(2)).to_rfc3339();
assert_eq!(
authenticate_deadline(&expires, Some(&now().to_rfc2822()), now()),
Some(now() + Duration::seconds(600)),
"still capped by the retry window"
);
}
#[test]
fn a_zero_or_missing_wait_still_waits_a_second() {
let expires = Some(now() + Duration::seconds(60));
for after in [Some(0), Some(-10), None] {
let err = limited(RateLimitSource::Vta, after);
assert_eq!(
authenticate_retry_wait(&err, expires, now(), NO_JITTER),
secs(1),
"{after:?}"
);
}
}
#[test]
fn only_the_services_own_rate_limit_is_retried() {
let expires = Some(now() + Duration::seconds(60));
let upstream = limited(RateLimitSource::Upstream, Some(5));
assert_eq!(
authenticate_retry_wait(&upstream, expires, now(), NO_JITTER),
None
);
let auth = VtaError::Auth("authentication failed".into());
assert_eq!(
authenticate_retry_wait(&auth, expires, now(), NO_JITTER),
None
);
}
}