use std::sync::Arc;
use std::time::{Duration, SystemTime};
use atd_runtime::secrets::{BearerIdentity, BrokerError, TokenBroker};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub const DEFAULT_REFRESH_CADENCE: Duration = Duration::from_secs(60);
const CHANNEL_BUFFER: usize = 8;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum RefreshEvent {
Refreshed {
caps: Vec<String>,
expires_at: Option<SystemTime>,
},
AuthLost { reason: AuthLostReason },
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthLostReason {
Expired,
Revoked(String),
Unknown,
BrokerError {
reason: String,
transient: bool,
},
}
pub fn spawn_bearer_refresh(
broker: Arc<dyn TokenBroker>,
bearer: String,
initial: BearerIdentity,
cadence: Duration,
) -> (JoinHandle<()>, mpsc::Receiver<RefreshEvent>) {
let (tx, rx) = mpsc::channel(CHANNEL_BUFFER);
let handle = tokio::spawn(async move {
let mut current_expires_at = initial.expires_at;
loop {
let sleep_for = next_sleep_duration(cadence, current_expires_at);
tokio::time::sleep(sleep_for).await;
match broker.resolve_bearer(&bearer).await {
Ok(Some(id)) => {
current_expires_at = id.expires_at;
let evt = RefreshEvent::Refreshed {
caps: id.granted_capabilities,
expires_at: id.expires_at,
};
if tx.send(evt).await.is_err() {
break;
}
}
Ok(None) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::Unknown,
})
.await;
break;
}
Err(BrokerError::Expired) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::Expired,
})
.await;
break;
}
Err(BrokerError::Revoked(msg)) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::Revoked(msg),
})
.await;
break;
}
Err(BrokerError::Lookup(msg)) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::BrokerError {
reason: msg,
transient: true,
},
})
.await;
break;
}
Err(BrokerError::Internal(msg)) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::BrokerError {
reason: msg,
transient: false,
},
})
.await;
break;
}
Err(BrokerError::NotConfigured) => {
let _ = tx
.send(RefreshEvent::AuthLost {
reason: AuthLostReason::BrokerError {
reason: "broker NotConfigured".into(),
transient: false,
},
})
.await;
break;
}
}
}
});
(handle, rx)
}
fn next_sleep_duration(cadence: Duration, expires_at: Option<SystemTime>) -> Duration {
match expires_at {
Some(t) => {
let now = SystemTime::now();
match t.duration_since(now) {
Ok(until_expiry) => cadence.min(until_expiry),
Err(_) => cadence, }
}
None => cadence,
}
}
#[cfg(test)]
mod tests {
use super::*;
use atd_runtime::secrets::{ResolveBearerFuture, ResolveFuture};
use std::sync::Mutex;
#[derive(Debug, Default)]
struct MutBroker {
state: Mutex<MutState>,
}
#[derive(Default)]
enum MutState {
#[default]
Ok,
OkShrunk,
Expired,
Revoked(String),
Unknown,
Internal(String),
Lookup(String),
}
impl std::fmt::Debug for MutState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ok => write!(f, "Ok"),
Self::OkShrunk => write!(f, "OkShrunk"),
Self::Expired => write!(f, "Expired"),
Self::Revoked(s) => write!(f, "Revoked({s})"),
Self::Unknown => write!(f, "Unknown"),
Self::Internal(s) => write!(f, "Internal({s})"),
Self::Lookup(s) => write!(f, "Lookup({s})"),
}
}
}
impl MutBroker {
fn set(&self, st: MutState) {
*self.state.lock().unwrap() = st;
}
}
impl TokenBroker for MutBroker {
fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
Box::pin(async { Ok(None) })
}
fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
let st = std::mem::take(&mut *self.state.lock().unwrap());
*self.state.lock().unwrap() = match &st {
MutState::Ok => MutState::Ok,
MutState::OkShrunk => MutState::OkShrunk,
_ => MutState::default(),
};
let snapshot = st;
Box::pin(async move {
match snapshot {
MutState::Ok => Ok(Some(BearerIdentity {
caller_id: "agent-A".into(),
granted_capabilities: vec!["records:read".into(), "summary:read".into()],
secrets: None,
expires_at: None,
cache_until: None,
})),
MutState::OkShrunk => Ok(Some(BearerIdentity {
caller_id: "agent-A".into(),
granted_capabilities: vec!["records:read".into()],
secrets: None,
expires_at: None,
cache_until: None,
})),
MutState::Expired => Err(BrokerError::Expired),
MutState::Revoked(s) => Err(BrokerError::Revoked(s)),
MutState::Unknown => Ok(None),
MutState::Internal(s) => Err(BrokerError::Internal(s)),
MutState::Lookup(s) => Err(BrokerError::Lookup(s)),
}
})
}
}
fn ok_identity() -> BearerIdentity {
BearerIdentity {
caller_id: "agent-A".into(),
granted_capabilities: vec!["records:read".into(), "summary:read".into()],
secrets: None,
expires_at: None,
cache_until: None,
}
}
#[test]
fn no_expires_at_sleeps_full_cadence() {
let dur = next_sleep_duration(Duration::from_secs(60), None);
assert_eq!(dur, Duration::from_secs(60));
}
#[test]
fn expires_at_in_the_past_returns_cadence_not_zero() {
let past = SystemTime::now() - Duration::from_secs(100);
let dur = next_sleep_duration(Duration::from_secs(60), Some(past));
assert_eq!(dur, Duration::from_secs(60));
}
#[test]
fn expires_at_sooner_than_cadence_clamps_sleep() {
let soon = SystemTime::now() + Duration::from_secs(5);
let dur = next_sleep_duration(Duration::from_secs(60), Some(soon));
assert!(dur < Duration::from_secs(7));
assert!(dur > Duration::from_secs(3));
}
#[test]
fn expires_at_far_in_future_keeps_cadence() {
let far = SystemTime::now() + Duration::from_secs(3600);
let dur = next_sleep_duration(Duration::from_secs(60), Some(far));
assert_eq!(dur, Duration::from_secs(60));
}
#[tokio::test]
async fn happy_refresh_emits_refreshed_with_current_caps() {
let broker: Arc<dyn TokenBroker> = Arc::new(MutBroker::default());
let (handle, mut rx) = spawn_bearer_refresh(
broker.clone(),
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event within 500ms")
.expect("non-closed channel");
match evt {
RefreshEvent::Refreshed { caps, .. } => {
assert!(caps.contains(&"records:read".to_string()));
assert!(caps.contains(&"summary:read".to_string()));
}
other => panic!("expected Refreshed, got {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn shrunk_caps_propagate_via_refreshed_event() {
let broker_concrete = Arc::new(MutBroker::default());
broker_concrete.set(MutState::OkShrunk);
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(), Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event within 500ms")
.expect("non-closed channel");
match evt {
RefreshEvent::Refreshed { caps, .. } => {
assert_eq!(caps, vec!["records:read".to_string()]);
}
other => panic!("expected Refreshed(shrunk), got {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn mid_stream_revoke_emits_auth_lost_revoked_and_closes_channel() {
let broker_concrete = Arc::new(MutBroker::default());
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt1 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("first event")
.expect("non-closed");
assert!(matches!(evt1, RefreshEvent::Refreshed { .. }));
broker_concrete.set(MutState::Revoked("user revoked".into()));
let evt2 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("second event")
.expect("non-closed before AuthLost");
match evt2 {
RefreshEvent::AuthLost {
reason: AuthLostReason::Revoked(msg),
} => assert_eq!(msg, "user revoked"),
other => panic!("expected AuthLost(Revoked), got {other:?}"),
}
let evt3 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("channel-close detection within 500ms");
assert!(evt3.is_none(), "channel must close after AuthLost");
let _ = handle.await;
}
#[tokio::test]
async fn mid_stream_expired_emits_auth_lost_expired() {
let broker_concrete = Arc::new(MutBroker::default());
broker_concrete.set(MutState::Expired);
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event within 500ms")
.expect("non-closed");
assert!(matches!(
evt,
RefreshEvent::AuthLost {
reason: AuthLostReason::Expired
}
));
handle.abort();
}
#[tokio::test]
async fn broker_internal_error_emits_persistent_auth_lost() {
let broker_concrete = Arc::new(MutBroker::default());
broker_concrete.set(MutState::Internal("oh no".into()));
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event")
.expect("non-closed");
match evt {
RefreshEvent::AuthLost {
reason: AuthLostReason::BrokerError { transient, .. },
} => assert!(!transient),
other => panic!("expected persistent BrokerError, got {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn broker_lookup_error_emits_transient_auth_lost() {
let broker_concrete = Arc::new(MutBroker::default());
broker_concrete.set(MutState::Lookup("sqlite locked".into()));
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event")
.expect("non-closed");
match evt {
RefreshEvent::AuthLost {
reason: AuthLostReason::BrokerError { transient, .. },
} => assert!(transient),
other => panic!("expected transient BrokerError, got {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn unknown_bearer_emits_auth_lost_unknown() {
let broker_concrete = Arc::new(MutBroker::default());
broker_concrete.set(MutState::Unknown);
let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
let (handle, mut rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
.await
.expect("event")
.expect("non-closed");
assert!(matches!(
evt,
RefreshEvent::AuthLost {
reason: AuthLostReason::Unknown
}
));
handle.abort();
}
#[tokio::test]
async fn dropping_receiver_aborts_refresh_loop() {
let broker: Arc<dyn TokenBroker> = Arc::new(MutBroker::default());
let (handle, rx) = spawn_bearer_refresh(
broker,
"tok".into(),
ok_identity(),
Duration::from_millis(50),
);
drop(rx);
let exited = tokio::time::timeout(Duration::from_millis(500), handle).await;
assert!(
exited.is_ok(),
"task must exit within ~1 cadence after receiver drop"
);
}
}