Skip to main content

agent_core/core/auth/
credential_source.rs

1//! Remote credential source — Option C (task #157, epic #155).
2//!
3//! A Synaps client can resolve its provider **access token** from a broker over
4//! the network instead of the local `auth.json`. This lets many machines share
5//! one OAuth credential without copying the secret to each disk.
6//!
7//! INVARIANT (the whole point — enforced by construction + tests):
8//! the `Remote` path NEVER reads or holds a refresh token, NEVER writes
9//! `auth.json`, and NEVER refreshes client-side. It only fetches short-lived
10//! access tokens from the broker and caches them in memory. The single
11//! refresher is the broker (Anthropic rotates the refresh token on every
12//! refresh, so exactly one party may refresh).
13
14use std::collections::HashMap;
15use std::sync::{Arc, RwLock};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18/// Where a client gets its provider credentials.
19#[derive(Clone, PartialEq, Eq, Default)]
20pub enum CredentialSource {
21    /// Read + refresh the local `auth.json` (default — unchanged behavior).
22    #[default]
23    Local,
24    /// Fetch short-lived access tokens from a broker over the network.
25    Remote {
26        /// Broker base URL, e.g. `https://jade.jade:8181` (no trailing slash).
27        endpoint: String,
28        /// Per-machine bearer presented TO the broker. This is the machine's own
29        /// identity, NOT the provider credential.
30        machine_token: String,
31    },
32}
33
34impl CredentialSource {
35    /// Build from explicit config values. Returns `Remote` iff a non-empty
36    /// endpoint is given; otherwise `Local`. Trailing slashes on the endpoint
37    /// are trimmed so callers can join paths uniformly.
38    pub fn from_parts(endpoint: Option<String>, machine_token: Option<String>) -> Self {
39        match endpoint {
40            Some(e) if !e.trim().is_empty() => CredentialSource::Remote {
41                endpoint: e.trim().trim_end_matches('/').to_string(),
42                machine_token: machine_token.unwrap_or_default().trim().to_string(),
43            },
44            _ => CredentialSource::Local,
45        }
46    }
47
48    pub fn is_remote(&self) -> bool {
49        matches!(self, CredentialSource::Remote { .. })
50    }
51}
52
53/// Redacting Debug — never print the machine token (board M3/B3).
54impl std::fmt::Debug for CredentialSource {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            CredentialSource::Local => write!(f, "Local"),
58            CredentialSource::Remote { endpoint, .. } => f
59                .debug_struct("Remote")
60                .field("endpoint", endpoint)
61                .field("machine_token", &"***")
62                .finish(),
63        }
64    }
65}
66
67/// An access token as returned by the broker's `GET /token`.
68///
69/// Deliberately has **no** refresh-token field: a Remote client must never
70/// receive or hold one. This is the invariant made structural — there is no
71/// place to put a refresh token even if the broker mistakenly sent one.
72#[derive(Clone, serde::Deserialize)]
73pub struct BrokerToken {
74    pub access_token: String,
75    /// Absolute expiry, unix-epoch **milliseconds** (matches
76    /// `OAuthCredentials.expires`). When `ttl_ms` is present the client
77    /// overwrites this with its own clock + ttl to defeat clock skew.
78    pub expires: u64,
79    /// Optional relative TTL in ms. When the broker sends it, the client
80    /// recomputes `expires = client_now + ttl_ms`, eliminating broker↔client
81    /// clock skew on suspend/resume VMs (board C3). Absent → use `expires`.
82    #[serde(default)]
83    pub ttl_ms: Option<u64>,
84}
85
86/// Redacting Debug — never print access_token (review finding H2).
87impl std::fmt::Debug for BrokerToken {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("BrokerToken")
90            .field("access_token", &"[REDACTED]")
91            .field("expires", &self.expires)
92            .field("ttl_ms", &self.ttl_ms)
93            .finish()
94    }
95}
96
97/// Current unix time in milliseconds.
98fn now_millis() -> u64 {
99    SystemTime::now()
100        .duration_since(UNIX_EPOCH)
101        .map(|d| d.as_millis() as u64)
102        .unwrap_or(0)
103}
104
105/// True if `expires_ms` is already past, or within `margin_ms` of now.
106///
107/// The margin absorbs clock skew + request latency so a client refetches
108/// slightly early rather than presenting a token that dies mid-flight.
109pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
110    now_millis().saturating_add(margin_ms) >= expires_ms
111}
112
113/// Default refetch margin: 5 minutes (mirrors `is_token_expired`).
114pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
115
116// ── In-memory token cache ────────────────────────────────────────────────────
117
118/// Thread-safe, per-provider cache of broker access tokens. Cloneable handle
119/// over shared state. Holds ONLY short-lived access tokens, never a refresh
120/// token, never persisted to disk.
121#[derive(Clone, Default)]
122pub struct TokenCache {
123    inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
124}
125
126/// Redacting Debug — print only the cached provider names, never the tokens.
127impl std::fmt::Debug for TokenCache {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let providers: Vec<String> = self
130            .inner
131            .read()
132            .map(|m| m.keys().cloned().collect())
133            .unwrap_or_default();
134        f.debug_struct("TokenCache")
135            .field("providers", &providers)
136            .finish()
137    }
138}
139
140impl TokenCache {
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Return the cached token for `provider` only if present AND not within
146    /// `margin_ms` of expiry. Otherwise `None` (caller should fetch).
147    pub fn get_fresh(&self, provider: &str, margin_ms: u64) -> Option<BrokerToken> {
148        let map = self.inner.read().ok()?;
149        let tok = map.get(provider)?;
150        if is_expired_with_margin(tok.expires, margin_ms) {
151            None
152        } else {
153            Some(tok.clone())
154        }
155    }
156
157    pub fn put(&self, provider: &str, token: BrokerToken) {
158        if let Ok(mut map) = self.inner.write() {
159            map.insert(provider.to_string(), token);
160        }
161    }
162
163    pub fn invalidate(&self, provider: &str) {
164        if let Ok(mut map) = self.inner.write() {
165            map.remove(provider);
166        }
167    }
168
169    /// Cached token if present and not PAST hard expiry (ignores the refetch
170    /// margin). Used as a degraded-mode fallback when the broker is unreachable:
171    /// a token slightly inside its refetch window is still better than failing.
172    pub fn get_unexpired(&self, provider: &str) -> Option<BrokerToken> {
173        let map = self.inner.read().ok()?;
174        let tok = map.get(provider)?;
175        if is_expired_with_margin(tok.expires, 0) {
176            None
177        } else {
178            Some(tok.clone())
179        }
180    }
181}
182
183// ── Fetcher abstraction + resolver ───────────────────────────────────────────
184
185/// "Get a fresh access token from somewhere." Abstracted so the cache/resolve
186/// logic is unit-testable without real HTTP. The real impl is `BrokerClient`.
187#[allow(async_fn_in_trait)]
188pub trait TokenFetcher {
189    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
190}
191
192/// Resolve a provider token via cache-or-fetch, returning the full
193/// `BrokerToken` (access + expiry). The runtime needs the expiry to drive its
194/// in-memory refresh trigger. NEVER touches a refresh token or `auth.json`.
195pub async fn resolve_remote<F: TokenFetcher>(
196    fetcher: &F,
197    cache: &TokenCache,
198    provider: &str,
199    margin_ms: u64,
200) -> Result<BrokerToken, String> {
201    if let Some(tok) = cache.get_fresh(provider, margin_ms) {
202        return Ok(tok);
203    }
204    match fetcher.fetch_token(provider).await {
205        Ok(tok) => {
206            cache.put(provider, tok.clone());
207            Ok(tok)
208        }
209        Err(e) => {
210            // Degraded mode: the broker is unreachable, but if we still hold a
211            // token that hasn't hit hard expiry, serve it rather than failing
212            // the turn. Self-heals on the next call once the broker is back.
213            if let Some(tok) = cache.get_unexpired(provider) {
214                return Ok(tok);
215            }
216            Err(e)
217        }
218    }
219}
220
221/// Like [`resolve_remote`] but returns only the access token string.
222pub async fn resolve_remote_token<F: TokenFetcher>(
223    fetcher: &F,
224    cache: &TokenCache,
225    provider: &str,
226    margin_ms: u64,
227) -> Result<String, String> {
228    Ok(resolve_remote(fetcher, cache, provider, margin_ms)
229        .await?
230        .access_token)
231}
232
233// ── Broker HTTP client ───────────────────────────────────────────────────────
234
235/// HTTP client for a credential broker. Presents the machine's own bearer token
236/// (NOT the provider credential) and receives a short-lived access token.
237pub struct BrokerClient {
238    http: reqwest::Client,
239    endpoint: String,
240    machine_token: String,
241}
242
243impl BrokerClient {
244    pub fn new(endpoint: impl Into<String>, machine_token: impl Into<String>) -> Self {
245        // D1: bound the request so a hung/unreachable broker can't stall the
246        // caller's whole turn. (The runtime path uses `with_client` and inherits
247        // the runtime's configured client; this is the standalone fallback.)
248        let http = reqwest::Client::builder()
249            .connect_timeout(std::time::Duration::from_secs(5))
250            .timeout(std::time::Duration::from_secs(20))
251            .build()
252            .unwrap_or_default();
253        Self {
254            http,
255            endpoint: endpoint.into(),
256            machine_token: machine_token.into(),
257        }
258    }
259
260    /// Like `new` but reuses an existing `reqwest::Client` (shared connection
261    /// pool) instead of building a fresh one per call. (#158 A5)
262    pub fn with_client(
263        endpoint: impl Into<String>,
264        machine_token: impl Into<String>,
265        http: reqwest::Client,
266    ) -> Self {
267        Self {
268            http,
269            endpoint: endpoint.into(),
270            machine_token: machine_token.into(),
271        }
272    }
273
274    /// Build from a `CredentialSource`. `None` for `Local`.
275    pub fn from_source(source: &CredentialSource) -> Option<Self> {
276        match source {
277            CredentialSource::Remote {
278                endpoint,
279                machine_token,
280            } => Some(Self::new(endpoint.clone(), machine_token.clone())),
281            CredentialSource::Local => None,
282        }
283    }
284}
285
286/// Provider-aware token resolution honoring the credential source — kept as a
287/// compatibility adapter for CLI callers (`synaps status`). Delegates to the
288/// typed credential broker: the local in-process broker or the authenticated
289/// remote transport. Vends the access token only.
290pub async fn resolve_access_token(
291    provider: &str,
292    source: &CredentialSource,
293    cache: &TokenCache,
294    http: &reqwest::Client,
295) -> Result<String, String> {
296    let provider: super::provider::OAuthProviderId = provider.try_into()?;
297    super::broker::broker_from_source(source, cache, http.clone())
298        .access_token(provider)
299        .await
300        .map(|t| t.token)
301        .map_err(|e| e.to_string())
302}
303
304impl TokenFetcher for BrokerClient {
305    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String> {
306        let url = format!("{}/token", self.endpoint);
307        let resp = self
308            .http
309            .get(&url)
310            .query(&[("provider", provider)])
311            .bearer_auth(&self.machine_token)
312            .send()
313            .await
314            .map_err(|e| format!("broker request failed: {e}"))?;
315        let status = resp.status();
316        if status == reqwest::StatusCode::UNAUTHORIZED {
317            return Err("broker rejected machine auth (401)".to_string());
318        }
319        if !status.is_success() {
320            return Err(format!("broker returned HTTP {status}"));
321        }
322        let mut tok = resp
323            .json::<BrokerToken>()
324            .await
325            .map_err(|e| format!("invalid broker token response: {e}"))?;
326        // C3: prefer the broker's relative TTL over its absolute clock.
327        if let Some(ttl) = tok.ttl_ms {
328            tok.expires = now_millis().saturating_add(ttl);
329        }
330        // C2: reject a malformed/dead token rather than caching it (which would
331        // cause a permanent refetch storm or a dud bearer).
332        if tok.access_token.is_empty() {
333            return Err("broker returned an empty access_token".to_string());
334        }
335        if tok.expires <= now_millis() {
336            return Err("broker returned an already-expired token".to_string());
337        }
338        Ok(tok)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn from_parts_local_when_no_endpoint() {
348        assert_eq!(
349            CredentialSource::from_parts(None, None),
350            CredentialSource::Local
351        );
352        assert_eq!(
353            CredentialSource::from_parts(Some("   ".into()), Some("m".into())),
354            CredentialSource::Local
355        );
356    }
357
358    #[test]
359    fn from_parts_remote_when_endpoint_set() {
360        let s =
361            CredentialSource::from_parts(Some("https://jade.jade:8181".into()), Some("tok".into()));
362        assert_eq!(
363            s,
364            CredentialSource::Remote {
365                endpoint: "https://jade.jade:8181".into(),
366                machine_token: "tok".into()
367            }
368        );
369        assert!(s.is_remote());
370    }
371
372    #[test]
373    fn from_parts_trims_trailing_slash_and_whitespace() {
374        let s = CredentialSource::from_parts(Some("  https://b/  ".into()), Some("  tok  ".into()));
375        assert_eq!(
376            s,
377            CredentialSource::Remote {
378                endpoint: "https://b".into(),
379                machine_token: "tok".into()
380            }
381        );
382    }
383
384    #[test]
385    fn remote_with_missing_machine_token_defaults_empty() {
386        let s = CredentialSource::from_parts(Some("https://b".into()), None);
387        assert_eq!(
388            s,
389            CredentialSource::Remote {
390                endpoint: "https://b".into(),
391                machine_token: String::new()
392            }
393        );
394    }
395
396    #[test]
397    fn local_is_not_remote() {
398        assert!(!CredentialSource::Local.is_remote());
399    }
400
401    #[test]
402    fn expiry_far_future_not_expired() {
403        let far = now_millis() + 60 * 60 * 1000; // +1h
404        assert!(!is_expired_with_margin(far, DEFAULT_MARGIN_MS));
405    }
406
407    #[test]
408    fn expiry_past_is_expired() {
409        let past = now_millis().saturating_sub(1000);
410        assert!(is_expired_with_margin(past, 0));
411    }
412
413    #[test]
414    fn expiry_within_margin_is_expired() {
415        // expires in 2 minutes, margin 5 minutes -> treated as expired (refetch early)
416        let soon = now_millis() + 2 * 60 * 1000;
417        assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
418        // ...but with a 1-minute margin it is NOT yet expired
419        assert!(!is_expired_with_margin(soon, 60 * 1000));
420    }
421
422    #[test]
423    fn broker_token_deserializes_without_refresh_field() {
424        let json = r#"{"access_token":"sk-abc","expires":1750000000000}"#;
425        let t: BrokerToken = serde_json::from_str(json).unwrap();
426        assert_eq!(t.access_token, "sk-abc");
427        assert_eq!(t.expires, 1_750_000_000_000);
428    }
429
430    // ── cache ────────────────────────────────────────────────────────────
431    fn tok(expires: u64) -> BrokerToken {
432        BrokerToken {
433            access_token: "sk-live".into(),
434            expires,
435            ttl_ms: None,
436        }
437    }
438
439    #[test]
440    fn cache_get_fresh_returns_unexpired() {
441        let c = TokenCache::new();
442        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
443        assert!(c.get_fresh("anthropic", DEFAULT_MARGIN_MS).is_some());
444    }
445
446    #[test]
447    fn cache_get_fresh_none_when_expired() {
448        let c = TokenCache::new();
449        c.put("anthropic", tok(now_millis().saturating_sub(1000)));
450        assert!(c.get_fresh("anthropic", 0).is_none());
451    }
452
453    #[test]
454    fn cache_invalidate_removes() {
455        let c = TokenCache::new();
456        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
457        c.invalidate("anthropic");
458        assert!(c.get_fresh("anthropic", 0).is_none());
459    }
460
461    #[test]
462    fn cache_providers_isolated() {
463        let c = TokenCache::new();
464        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
465        assert!(c.get_fresh("openai", 0).is_none());
466    }
467
468    // ── resolve with a fake fetcher (counts calls) ───────────────────────
469    use std::sync::atomic::{AtomicUsize, Ordering};
470
471    struct FakeFetcher {
472        token: BrokerToken,
473        calls: AtomicUsize,
474    }
475    impl TokenFetcher for FakeFetcher {
476        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
477            self.calls.fetch_add(1, Ordering::SeqCst);
478            Ok(self.token.clone())
479        }
480    }
481
482    #[tokio::test]
483    async fn resolve_cache_hit_does_not_fetch() {
484        let cache = TokenCache::new();
485        cache.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
486        let f = FakeFetcher {
487            token: tok(0),
488            calls: AtomicUsize::new(0),
489        };
490        let t = resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
491            .await
492            .unwrap();
493        assert_eq!(t, "sk-live");
494        assert_eq!(
495            f.calls.load(Ordering::SeqCst),
496            0,
497            "must not fetch on a cache hit"
498        );
499    }
500
501    #[tokio::test]
502    async fn resolve_miss_fetches_then_caches() {
503        let cache = TokenCache::new();
504        let f = FakeFetcher {
505            token: tok(now_millis() + 60 * 60 * 1000),
506            calls: AtomicUsize::new(0),
507        };
508        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
509            .await
510            .unwrap();
511        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
512            .await
513            .unwrap();
514        assert_eq!(
515            f.calls.load(Ordering::SeqCst),
516            1,
517            "second resolve should hit the cache"
518        );
519    }
520
521    // ── broker-down degradation ───────────────────────────────────────────
522    struct FailFetcher;
523    impl TokenFetcher for FailFetcher {
524        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
525            Err("broker unreachable".into())
526        }
527    }
528
529    #[tokio::test]
530    async fn resolve_serves_stale_cache_when_broker_down() {
531        // Token expires in 2 min; margin is 5 min -> get_fresh misses (would
532        // refetch), the broker is down, but it's not HARD-expired, so we serve it.
533        let cache = TokenCache::new();
534        cache.put("anthropic", tok(now_millis() + 2 * 60 * 1000));
535        let t = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS)
536            .await
537            .unwrap();
538        assert_eq!(t.access_token, "sk-live");
539    }
540
541    #[tokio::test]
542    async fn resolve_errors_when_broker_down_and_no_cache() {
543        let cache = TokenCache::new();
544        let err = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS)
545            .await
546            .unwrap_err();
547        assert!(err.contains("unreachable"), "got: {err}");
548    }
549
550    #[tokio::test]
551    async fn resolve_errors_when_broker_down_and_cache_hard_expired() {
552        let cache = TokenCache::new();
553        cache.put("anthropic", tok(now_millis().saturating_sub(1000))); // already expired
554        let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0)
555            .await
556            .unwrap_err();
557        assert!(err.contains("unreachable"), "got: {err}");
558    }
559
560    // ── BrokerClient against a tiny in-test axum server ──────────────────
561    async fn spawn_broker(token_json: &'static str, require_token: &'static str) -> String {
562        use axum::{http::HeaderMap, http::StatusCode, routing::get, Router};
563        let app = Router::new().route(
564            "/token",
565            get(move |headers: HeaderMap| async move {
566                let auth = headers
567                    .get("authorization")
568                    .and_then(|v| v.to_str().ok())
569                    .unwrap_or("");
570                if auth == format!("Bearer {require_token}") {
571                    (StatusCode::OK, token_json.to_string())
572                } else {
573                    (StatusCode::UNAUTHORIZED, String::new())
574                }
575            }),
576        );
577        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
578        let addr = listener.local_addr().unwrap();
579        tokio::spawn(async move {
580            axum::serve(listener, app).await.unwrap();
581        });
582        format!("http://{addr}")
583    }
584
585    #[tokio::test]
586    async fn broker_client_fetches_and_parses() {
587        let url = spawn_broker(
588            r#"{"access_token":"sk-from-broker","expires":9999999999999}"#,
589            "machine-xyz",
590        )
591        .await;
592        let c = BrokerClient::new(url, "machine-xyz");
593        let t = c.fetch_token("anthropic").await.unwrap();
594        assert_eq!(t.access_token, "sk-from-broker");
595        assert_eq!(t.expires, 9_999_999_999_999);
596    }
597
598    #[tokio::test]
599    async fn broker_client_401_on_bad_machine_auth() {
600        let url = spawn_broker(
601            r#"{"access_token":"x","expires":9999999999999}"#,
602            "right-token",
603        )
604        .await;
605        let c = BrokerClient::new(url, "WRONG-token");
606        let err = c.fetch_token("anthropic").await.unwrap_err();
607        assert!(err.contains("401"), "expected 401 error, got: {err}");
608    }
609
610    #[tokio::test]
611    async fn fetch_token_recomputes_expiry_from_ttl_ms() {
612        // Broker sends a STALE absolute `expires` but a fresh `ttl_ms`; the
613        // client must recompute from its own clock (clock-skew defense, C3).
614        let url = spawn_broker(r#"{"access_token":"sk","expires":1,"ttl_ms":3600000}"#, "m").await;
615        let c = BrokerClient::new(url, "m");
616        let t = c.fetch_token("anthropic").await.unwrap();
617        assert!(
618            t.expires > now_millis(),
619            "expires must come from ttl_ms, got {}",
620            t.expires
621        );
622    }
623
624    #[tokio::test]
625    async fn fetch_token_rejects_empty_access_token() {
626        let url = spawn_broker(r#"{"access_token":"","expires":9999999999999}"#, "m").await;
627        let c = BrokerClient::new(url, "m");
628        assert!(c
629            .fetch_token("anthropic")
630            .await
631            .unwrap_err()
632            .contains("empty"));
633    }
634
635    #[tokio::test]
636    async fn fetch_token_rejects_already_expired() {
637        let url = spawn_broker(r#"{"access_token":"sk","expires":1}"#, "m").await;
638        let c = BrokerClient::new(url, "m");
639        assert!(c
640            .fetch_token("anthropic")
641            .await
642            .unwrap_err()
643            .contains("expired"));
644    }
645
646    #[tokio::test]
647    async fn resolve_access_token_remote_fetches_from_broker() {
648        let url = spawn_broker(
649            r#"{"access_token":"sk-broker","expires":9999999999999}"#,
650            "m",
651        )
652        .await;
653        let source = CredentialSource::Remote {
654            endpoint: url,
655            machine_token: "m".into(),
656        };
657        let cache = TokenCache::new();
658        let http = reqwest::Client::new();
659        let t = resolve_access_token("anthropic", &source, &cache, &http)
660            .await
661            .unwrap();
662        assert_eq!(t, "sk-broker");
663        // second call hits the cache (shared http client reused, no new pool)
664        let t2 = resolve_access_token("anthropic", &source, &cache, &http)
665            .await
666            .unwrap();
667        assert_eq!(t2, "sk-broker");
668    }
669
670    // ── invariant: a Remote client can NEVER hold a refresh token ─────────
671    #[test]
672    fn broker_token_structurally_drops_any_refresh_field() {
673        // Even a misbehaving/compromised broker that leaks a refresh token in
674        // the JSON cannot make a client hold one: BrokerToken has no field for
675        // it, so serde silently drops it. The "clients never hold a refresh
676        // token" invariant is structural, not a runtime check.
677        let json = r#"{"access_token":"a","expires":1,"refresh_token":"LEAK","refresh":"LEAK"}"#;
678        let t: BrokerToken = serde_json::from_str(json).unwrap();
679        assert_eq!(t.access_token, "a");
680        assert_eq!(t.expires, 1);
681        // There is no `refresh`/`refresh_token` field to even read — confirmed
682        // at compile time by the struct definition above.
683    }
684}