Skip to main content

atd_server_http/
sse_refresh.rs

1//! SP-token-broker-phase2 §4.7 — SSE long-connection bearer refresh.
2//!
3//! For HTTP routes that hold a stream open (Celia's `/chat/stream`,
4//! healthkit's bulk-export progress, etc.), the bearer must be
5//! re-validated periodically — otherwise consent revocations propagate
6//! only at the next request, which on a long-lived stream might be
7//! never. This helper spawns a background task that polls
8//! [`TokenBroker::resolve_bearer`] on a heartbeat and emits
9//! [`RefreshEvent`]s the adopter consumes from their SSE handler.
10//!
11//! ## Cadence (spec §4.7)
12//!
13//! - Default cadence is `60s` — matches the bound under which "consent
14//!   withdrawals get honoured" the spec contracts for.
15//! - If `BearerIdentity.expires_at` is `Some(t)`, the next sleep is
16//!   `min(t - now, cadence)` so we never sleep past the advertised TTL.
17//! - The cadence is recomputed every iteration from the most recent
18//!   identity (a shrunk `expires_at` from a revalidation immediately
19//!   tightens the loop).
20//!
21//! ## What the helper does NOT do
22//!
23//! - It does not cancel in-flight tool dispatches. Per spec §4.7, a
24//!   tool call that began authorised must complete with that authority.
25//!   Adopters propagate the cap change to the *next* request only.
26//! - It does not emit SSE bytes. The adopter's route handler maps
27//!   [`RefreshEvent::AuthLost`] to its own application-level frame
28//!   (Celia chose `event: auth_lost` per spec §4.7).
29//! - It does not auto-reconnect. When the helper emits `AuthLost`,
30//!   the loop terminates; the adopter route closes the stream; the
31//!   client decides whether to reconnect with a fresh bearer.
32
33use std::sync::Arc;
34use std::time::{Duration, SystemTime};
35
36use atd_runtime::secrets::{BearerIdentity, BrokerError, TokenBroker};
37use tokio::sync::mpsc;
38use tokio::task::JoinHandle;
39
40/// Default heartbeat cadence per spec §4.7 ("matches the typical SSE
41/// keep-alive ping cadence; revocation window adopters accept").
42pub const DEFAULT_REFRESH_CADENCE: Duration = Duration::from_secs(60);
43
44/// Channel buffer for [`RefreshEvent`]s. Adopters typically read
45/// one-at-a-time inside an SSE select! arm; 8 absorbs bursts without
46/// back-pressuring the broker.
47const CHANNEL_BUFFER: usize = 8;
48
49/// Events the refresh task emits to the adopter's SSE route.
50#[derive(Debug, Clone)]
51#[non_exhaustive]
52pub enum RefreshEvent {
53    /// Broker re-validated the bearer. `caps` is the latest authorised
54    /// capability set — may be the same as before, or smaller (shrunk
55    /// by a partial revoke). The adopter updates its per-stream cap
56    /// reference for future requests but **does not** cancel in-flight
57    /// tool dispatches.
58    Refreshed {
59        caps: Vec<String>,
60        expires_at: Option<SystemTime>,
61    },
62    /// Bearer is no longer valid (expired, revoked, or
63    /// broker-recognised-bad). Adopter route should emit its
64    /// application-level `auth_lost` frame and close the stream. The
65    /// refresh task exits after sending this; the channel closes.
66    AuthLost { reason: AuthLostReason },
67}
68
69/// Why the bearer was invalidated. Maps 1:1 to the broker error
70/// variants per spec §4.4 — adopter UX can surface "session expired"
71/// vs "agent revoked" vs "server hiccup" distinctly.
72#[derive(Debug, Clone, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum AuthLostReason {
75    Expired,
76    Revoked(String),
77    /// Broker started returning `Ok(None)` — bearer no longer
78    /// recognised (admin deleted the pairing? token rotation gone
79    /// wrong?). Treated the same as expired/revoked at the client.
80    Unknown,
81    /// Broker hard error (`Err(Internal)` or `Err(Lookup)`). Includes
82    /// the broker's reason string. Distinct from `Expired`/`Revoked`
83    /// so the adopter can decide whether to retry the validation
84    /// (transient) or close the stream (persistent).
85    BrokerError {
86        reason: String,
87        transient: bool,
88    },
89}
90
91/// Spawn a background task that periodically re-validates `bearer` via
92/// `broker` and emits [`RefreshEvent`]s on the returned channel. The
93/// task exits after emitting one [`RefreshEvent::AuthLost`]; dropping
94/// the [`JoinHandle`] aborts it sooner.
95///
96/// Per spec §4.7, the initial `resolve_bearer` call already happened at
97/// stream open (so the route handler has the live `BearerIdentity`);
98/// this task picks up *after* that. The caller supplies `initial` so
99/// the helper can compute the first sleep duration from the recorded
100/// `expires_at`.
101pub fn spawn_bearer_refresh(
102    broker: Arc<dyn TokenBroker>,
103    bearer: String,
104    initial: BearerIdentity,
105    cadence: Duration,
106) -> (JoinHandle<()>, mpsc::Receiver<RefreshEvent>) {
107    let (tx, rx) = mpsc::channel(CHANNEL_BUFFER);
108
109    let handle = tokio::spawn(async move {
110        let mut current_expires_at = initial.expires_at;
111        loop {
112            let sleep_for = next_sleep_duration(cadence, current_expires_at);
113            tokio::time::sleep(sleep_for).await;
114
115            match broker.resolve_bearer(&bearer).await {
116                Ok(Some(id)) => {
117                    current_expires_at = id.expires_at;
118                    let evt = RefreshEvent::Refreshed {
119                        caps: id.granted_capabilities,
120                        expires_at: id.expires_at,
121                    };
122                    if tx.send(evt).await.is_err() {
123                        // Adopter dropped the receiver; stream is gone.
124                        break;
125                    }
126                }
127                Ok(None) => {
128                    let _ = tx
129                        .send(RefreshEvent::AuthLost {
130                            reason: AuthLostReason::Unknown,
131                        })
132                        .await;
133                    break;
134                }
135                Err(BrokerError::Expired) => {
136                    let _ = tx
137                        .send(RefreshEvent::AuthLost {
138                            reason: AuthLostReason::Expired,
139                        })
140                        .await;
141                    break;
142                }
143                Err(BrokerError::Revoked(msg)) => {
144                    let _ = tx
145                        .send(RefreshEvent::AuthLost {
146                            reason: AuthLostReason::Revoked(msg),
147                        })
148                        .await;
149                    break;
150                }
151                Err(BrokerError::Lookup(msg)) => {
152                    // Transient — emit AuthLost(transient) but exit
153                    // anyway; adopter decides whether to reconnect.
154                    // SSE long-connection design (spec §4.7) prefers
155                    // close-and-reconnect over silent retry.
156                    let _ = tx
157                        .send(RefreshEvent::AuthLost {
158                            reason: AuthLostReason::BrokerError {
159                                reason: msg,
160                                transient: true,
161                            },
162                        })
163                        .await;
164                    break;
165                }
166                Err(BrokerError::Internal(msg)) => {
167                    let _ = tx
168                        .send(RefreshEvent::AuthLost {
169                            reason: AuthLostReason::BrokerError {
170                                reason: msg,
171                                transient: false,
172                            },
173                        })
174                        .await;
175                    break;
176                }
177                Err(BrokerError::NotConfigured) => {
178                    let _ = tx
179                        .send(RefreshEvent::AuthLost {
180                            reason: AuthLostReason::BrokerError {
181                                reason: "broker NotConfigured".into(),
182                                transient: false,
183                            },
184                        })
185                        .await;
186                    break;
187                }
188            }
189        }
190    });
191
192    (handle, rx)
193}
194
195/// Compute the next sleep duration: `min(expires_at - now, cadence)`.
196/// If `expires_at` is `None`, sleeps the full cadence. If `expires_at`
197/// is in the past, returns the cadence (the next `resolve_bearer` will
198/// confirm the expiry); we don't burn CPU on a zero-duration sleep loop.
199fn next_sleep_duration(cadence: Duration, expires_at: Option<SystemTime>) -> Duration {
200    match expires_at {
201        Some(t) => {
202            let now = SystemTime::now();
203            match t.duration_since(now) {
204                Ok(until_expiry) => cadence.min(until_expiry),
205                Err(_) => cadence, // already expired; next tick discovers
206            }
207        }
208        None => cadence,
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use atd_runtime::secrets::{ResolveBearerFuture, ResolveFuture};
216    use std::sync::Mutex;
217
218    /// Broker whose `resolve_bearer` response is mutated by tests.
219    /// Wrapped in `Arc<Mutex<...>>` so the test thread can flip the
220    /// behaviour mid-refresh and the spawned task picks it up on its
221    /// next iteration.
222    #[derive(Debug, Default)]
223    struct MutBroker {
224        state: Mutex<MutState>,
225    }
226    #[derive(Default)]
227    enum MutState {
228        #[default]
229        Ok,
230        OkShrunk,
231        Expired,
232        Revoked(String),
233        Unknown,
234        Internal(String),
235        Lookup(String),
236    }
237    // Impl Debug for the enum so `derive(Debug)` on the broker works.
238    impl std::fmt::Debug for MutState {
239        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240            match self {
241                Self::Ok => write!(f, "Ok"),
242                Self::OkShrunk => write!(f, "OkShrunk"),
243                Self::Expired => write!(f, "Expired"),
244                Self::Revoked(s) => write!(f, "Revoked({s})"),
245                Self::Unknown => write!(f, "Unknown"),
246                Self::Internal(s) => write!(f, "Internal({s})"),
247                Self::Lookup(s) => write!(f, "Lookup({s})"),
248            }
249        }
250    }
251    impl MutBroker {
252        fn set(&self, st: MutState) {
253            *self.state.lock().unwrap() = st;
254        }
255    }
256    impl TokenBroker for MutBroker {
257        fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
258            Box::pin(async { Ok(None) })
259        }
260        fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
261            let st = std::mem::take(&mut *self.state.lock().unwrap());
262            // After read, default back to `Ok` so subsequent ticks find
263            // a fresh state if the test hasn't reset it explicitly.
264            // (Tests that need a sticky state reset before each tick.)
265            *self.state.lock().unwrap() = match &st {
266                MutState::Ok => MutState::Ok,
267                MutState::OkShrunk => MutState::OkShrunk,
268                _ => MutState::default(),
269            };
270            // Snapshot value we'll respond with
271            let snapshot = st;
272            Box::pin(async move {
273                match snapshot {
274                    MutState::Ok => Ok(Some(BearerIdentity {
275                        caller_id: "agent-A".into(),
276                        granted_capabilities: vec!["records:read".into(), "summary:read".into()],
277                        secrets: None,
278                        expires_at: None,
279                        cache_until: None,
280                    })),
281                    MutState::OkShrunk => Ok(Some(BearerIdentity {
282                        caller_id: "agent-A".into(),
283                        granted_capabilities: vec!["records:read".into()],
284                        secrets: None,
285                        expires_at: None,
286                        cache_until: None,
287                    })),
288                    MutState::Expired => Err(BrokerError::Expired),
289                    MutState::Revoked(s) => Err(BrokerError::Revoked(s)),
290                    MutState::Unknown => Ok(None),
291                    MutState::Internal(s) => Err(BrokerError::Internal(s)),
292                    MutState::Lookup(s) => Err(BrokerError::Lookup(s)),
293                }
294            })
295        }
296    }
297
298    fn ok_identity() -> BearerIdentity {
299        BearerIdentity {
300            caller_id: "agent-A".into(),
301            granted_capabilities: vec!["records:read".into(), "summary:read".into()],
302            secrets: None,
303            expires_at: None,
304            cache_until: None,
305        }
306    }
307
308    // ---- next_sleep_duration ----
309
310    #[test]
311    fn no_expires_at_sleeps_full_cadence() {
312        let dur = next_sleep_duration(Duration::from_secs(60), None);
313        assert_eq!(dur, Duration::from_secs(60));
314    }
315
316    #[test]
317    fn expires_at_in_the_past_returns_cadence_not_zero() {
318        let past = SystemTime::now() - Duration::from_secs(100);
319        let dur = next_sleep_duration(Duration::from_secs(60), Some(past));
320        assert_eq!(dur, Duration::from_secs(60));
321    }
322
323    #[test]
324    fn expires_at_sooner_than_cadence_clamps_sleep() {
325        let soon = SystemTime::now() + Duration::from_secs(5);
326        let dur = next_sleep_duration(Duration::from_secs(60), Some(soon));
327        // 5 +/- 1s scheduling slack
328        assert!(dur < Duration::from_secs(7));
329        assert!(dur > Duration::from_secs(3));
330    }
331
332    #[test]
333    fn expires_at_far_in_future_keeps_cadence() {
334        let far = SystemTime::now() + Duration::from_secs(3600);
335        let dur = next_sleep_duration(Duration::from_secs(60), Some(far));
336        assert_eq!(dur, Duration::from_secs(60));
337    }
338
339    // ---- spawn_bearer_refresh ----
340    //
341    // Tests use very short cadences (50-200ms) so the suite stays fast.
342
343    #[tokio::test]
344    async fn happy_refresh_emits_refreshed_with_current_caps() {
345        let broker: Arc<dyn TokenBroker> = Arc::new(MutBroker::default());
346        let (handle, mut rx) = spawn_bearer_refresh(
347            broker.clone(),
348            "tok".into(),
349            ok_identity(),
350            Duration::from_millis(50),
351        );
352
353        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
354            .await
355            .expect("event within 500ms")
356            .expect("non-closed channel");
357
358        match evt {
359            RefreshEvent::Refreshed { caps, .. } => {
360                assert!(caps.contains(&"records:read".to_string()));
361                assert!(caps.contains(&"summary:read".to_string()));
362            }
363            other => panic!("expected Refreshed, got {other:?}"),
364        }
365        handle.abort();
366    }
367
368    #[tokio::test]
369    async fn shrunk_caps_propagate_via_refreshed_event() {
370        let broker_concrete = Arc::new(MutBroker::default());
371        broker_concrete.set(MutState::OkShrunk);
372        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
373
374        let (handle, mut rx) = spawn_bearer_refresh(
375            broker,
376            "tok".into(),
377            ok_identity(), // initial = 2 caps
378            Duration::from_millis(50),
379        );
380
381        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
382            .await
383            .expect("event within 500ms")
384            .expect("non-closed channel");
385
386        match evt {
387            RefreshEvent::Refreshed { caps, .. } => {
388                assert_eq!(caps, vec!["records:read".to_string()]);
389            }
390            other => panic!("expected Refreshed(shrunk), got {other:?}"),
391        }
392        handle.abort();
393    }
394
395    #[tokio::test]
396    async fn mid_stream_revoke_emits_auth_lost_revoked_and_closes_channel() {
397        let broker_concrete = Arc::new(MutBroker::default());
398        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
399
400        let (handle, mut rx) = spawn_bearer_refresh(
401            broker,
402            "tok".into(),
403            ok_identity(),
404            Duration::from_millis(50),
405        );
406
407        // First tick should be a happy Refreshed.
408        let evt1 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
409            .await
410            .expect("first event")
411            .expect("non-closed");
412        assert!(matches!(evt1, RefreshEvent::Refreshed { .. }));
413
414        // Now flip the broker to revoked; next tick should emit AuthLost.
415        broker_concrete.set(MutState::Revoked("user revoked".into()));
416
417        let evt2 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
418            .await
419            .expect("second event")
420            .expect("non-closed before AuthLost");
421        match evt2 {
422            RefreshEvent::AuthLost {
423                reason: AuthLostReason::Revoked(msg),
424            } => assert_eq!(msg, "user revoked"),
425            other => panic!("expected AuthLost(Revoked), got {other:?}"),
426        }
427
428        // Channel should close after AuthLost — next recv returns None.
429        let evt3 = tokio::time::timeout(Duration::from_millis(500), rx.recv())
430            .await
431            .expect("channel-close detection within 500ms");
432        assert!(evt3.is_none(), "channel must close after AuthLost");
433
434        // Task exited; handle is await-able to confirm completion.
435        let _ = handle.await;
436    }
437
438    #[tokio::test]
439    async fn mid_stream_expired_emits_auth_lost_expired() {
440        let broker_concrete = Arc::new(MutBroker::default());
441        broker_concrete.set(MutState::Expired);
442        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
443
444        let (handle, mut rx) = spawn_bearer_refresh(
445            broker,
446            "tok".into(),
447            ok_identity(),
448            Duration::from_millis(50),
449        );
450
451        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
452            .await
453            .expect("event within 500ms")
454            .expect("non-closed");
455        assert!(matches!(
456            evt,
457            RefreshEvent::AuthLost {
458                reason: AuthLostReason::Expired
459            }
460        ));
461        handle.abort();
462    }
463
464    #[tokio::test]
465    async fn broker_internal_error_emits_persistent_auth_lost() {
466        let broker_concrete = Arc::new(MutBroker::default());
467        broker_concrete.set(MutState::Internal("oh no".into()));
468        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
469
470        let (handle, mut rx) = spawn_bearer_refresh(
471            broker,
472            "tok".into(),
473            ok_identity(),
474            Duration::from_millis(50),
475        );
476
477        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
478            .await
479            .expect("event")
480            .expect("non-closed");
481        match evt {
482            RefreshEvent::AuthLost {
483                reason: AuthLostReason::BrokerError { transient, .. },
484            } => assert!(!transient),
485            other => panic!("expected persistent BrokerError, got {other:?}"),
486        }
487        handle.abort();
488    }
489
490    #[tokio::test]
491    async fn broker_lookup_error_emits_transient_auth_lost() {
492        let broker_concrete = Arc::new(MutBroker::default());
493        broker_concrete.set(MutState::Lookup("sqlite locked".into()));
494        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
495
496        let (handle, mut rx) = spawn_bearer_refresh(
497            broker,
498            "tok".into(),
499            ok_identity(),
500            Duration::from_millis(50),
501        );
502
503        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
504            .await
505            .expect("event")
506            .expect("non-closed");
507        match evt {
508            RefreshEvent::AuthLost {
509                reason: AuthLostReason::BrokerError { transient, .. },
510            } => assert!(transient),
511            other => panic!("expected transient BrokerError, got {other:?}"),
512        }
513        handle.abort();
514    }
515
516    #[tokio::test]
517    async fn unknown_bearer_emits_auth_lost_unknown() {
518        let broker_concrete = Arc::new(MutBroker::default());
519        broker_concrete.set(MutState::Unknown);
520        let broker: Arc<dyn TokenBroker> = broker_concrete.clone();
521
522        let (handle, mut rx) = spawn_bearer_refresh(
523            broker,
524            "tok".into(),
525            ok_identity(),
526            Duration::from_millis(50),
527        );
528
529        let evt = tokio::time::timeout(Duration::from_millis(500), rx.recv())
530            .await
531            .expect("event")
532            .expect("non-closed");
533        assert!(matches!(
534            evt,
535            RefreshEvent::AuthLost {
536                reason: AuthLostReason::Unknown
537            }
538        ));
539        handle.abort();
540    }
541
542    #[tokio::test]
543    async fn dropping_receiver_aborts_refresh_loop() {
544        let broker: Arc<dyn TokenBroker> = Arc::new(MutBroker::default());
545        let (handle, rx) = spawn_bearer_refresh(
546            broker,
547            "tok".into(),
548            ok_identity(),
549            Duration::from_millis(50),
550        );
551
552        // Drop the receiver immediately.
553        drop(rx);
554        // Wait for the next tick, then the next send fails and the task exits.
555        let exited = tokio::time::timeout(Duration::from_millis(500), handle).await;
556        assert!(
557            exited.is_ok(),
558            "task must exit within ~1 cadence after receiver drop"
559        );
560    }
561}