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(Debug, 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/// Current unix time in milliseconds.
87fn now_millis() -> u64 {
88    SystemTime::now()
89        .duration_since(UNIX_EPOCH)
90        .map(|d| d.as_millis() as u64)
91        .unwrap_or(0)
92}
93
94/// True if `expires_ms` is already past, or within `margin_ms` of now.
95///
96/// The margin absorbs clock skew + request latency so a client refetches
97/// slightly early rather than presenting a token that dies mid-flight.
98pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
99    now_millis().saturating_add(margin_ms) >= expires_ms
100}
101
102/// Default refetch margin: 5 minutes (mirrors `is_token_expired`).
103pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
104
105// ── In-memory token cache ────────────────────────────────────────────────────
106
107/// Thread-safe, per-provider cache of broker access tokens. Cloneable handle
108/// over shared state. Holds ONLY short-lived access tokens, never a refresh
109/// token, never persisted to disk.
110#[derive(Clone, Default)]
111pub struct TokenCache {
112    inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
113}
114
115/// Redacting Debug — print only the cached provider names, never the tokens.
116impl std::fmt::Debug for TokenCache {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let providers: Vec<String> = self
119            .inner
120            .read()
121            .map(|m| m.keys().cloned().collect())
122            .unwrap_or_default();
123        f.debug_struct("TokenCache").field("providers", &providers).finish()
124    }
125}
126
127impl TokenCache {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Return the cached token for `provider` only if present AND not within
133    /// `margin_ms` of expiry. Otherwise `None` (caller should fetch).
134    pub fn get_fresh(&self, provider: &str, margin_ms: u64) -> Option<BrokerToken> {
135        let map = self.inner.read().ok()?;
136        let tok = map.get(provider)?;
137        if is_expired_with_margin(tok.expires, margin_ms) {
138            None
139        } else {
140            Some(tok.clone())
141        }
142    }
143
144    pub fn put(&self, provider: &str, token: BrokerToken) {
145        if let Ok(mut map) = self.inner.write() {
146            map.insert(provider.to_string(), token);
147        }
148    }
149
150    pub fn invalidate(&self, provider: &str) {
151        if let Ok(mut map) = self.inner.write() {
152            map.remove(provider);
153        }
154    }
155
156    /// Cached token if present and not PAST hard expiry (ignores the refetch
157    /// margin). Used as a degraded-mode fallback when the broker is unreachable:
158    /// a token slightly inside its refetch window is still better than failing.
159    pub fn get_unexpired(&self, provider: &str) -> Option<BrokerToken> {
160        let map = self.inner.read().ok()?;
161        let tok = map.get(provider)?;
162        if is_expired_with_margin(tok.expires, 0) {
163            None
164        } else {
165            Some(tok.clone())
166        }
167    }
168}
169
170// ── Fetcher abstraction + resolver ───────────────────────────────────────────
171
172/// "Get a fresh access token from somewhere." Abstracted so the cache/resolve
173/// logic is unit-testable without real HTTP. The real impl is `BrokerClient`.
174#[allow(async_fn_in_trait)]
175pub trait TokenFetcher {
176    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
177}
178
179/// Resolve a provider token via cache-or-fetch, returning the full
180/// `BrokerToken` (access + expiry). The runtime needs the expiry to drive its
181/// in-memory refresh trigger. NEVER touches a refresh token or `auth.json`.
182pub async fn resolve_remote<F: TokenFetcher>(
183    fetcher: &F,
184    cache: &TokenCache,
185    provider: &str,
186    margin_ms: u64,
187) -> Result<BrokerToken, String> {
188    if let Some(tok) = cache.get_fresh(provider, margin_ms) {
189        return Ok(tok);
190    }
191    match fetcher.fetch_token(provider).await {
192        Ok(tok) => {
193            cache.put(provider, tok.clone());
194            Ok(tok)
195        }
196        Err(e) => {
197            // Degraded mode: the broker is unreachable, but if we still hold a
198            // token that hasn't hit hard expiry, serve it rather than failing
199            // the turn. Self-heals on the next call once the broker is back.
200            if let Some(tok) = cache.get_unexpired(provider) {
201                return Ok(tok);
202            }
203            Err(e)
204        }
205    }
206}
207
208/// Like [`resolve_remote`] but returns only the access token string.
209pub async fn resolve_remote_token<F: TokenFetcher>(
210    fetcher: &F,
211    cache: &TokenCache,
212    provider: &str,
213    margin_ms: u64,
214) -> Result<String, String> {
215    Ok(resolve_remote(fetcher, cache, provider, margin_ms).await?.access_token)
216}
217
218// ── Broker HTTP client ───────────────────────────────────────────────────────
219
220/// HTTP client for a credential broker. Presents the machine's own bearer token
221/// (NOT the provider credential) and receives a short-lived access token.
222pub struct BrokerClient {
223    http: reqwest::Client,
224    endpoint: String,
225    machine_token: String,
226}
227
228impl BrokerClient {
229    pub fn new(endpoint: impl Into<String>, machine_token: impl Into<String>) -> Self {
230        // D1: bound the request so a hung/unreachable broker can't stall the
231        // caller's whole turn. (The runtime path uses `with_client` and inherits
232        // the runtime's configured client; this is the standalone fallback.)
233        let http = reqwest::Client::builder()
234            .connect_timeout(std::time::Duration::from_secs(5))
235            .timeout(std::time::Duration::from_secs(20))
236            .build()
237            .unwrap_or_default();
238        Self { http, endpoint: endpoint.into(), machine_token: machine_token.into() }
239    }
240
241    /// Like `new` but reuses an existing `reqwest::Client` (shared connection
242    /// pool) instead of building a fresh one per call. (#158 A5)
243    pub fn with_client(
244        endpoint: impl Into<String>,
245        machine_token: impl Into<String>,
246        http: reqwest::Client,
247    ) -> Self {
248        Self { http, endpoint: endpoint.into(), machine_token: machine_token.into() }
249    }
250
251    /// Build from a `CredentialSource`. `None` for `Local`.
252    pub fn from_source(source: &CredentialSource) -> Option<Self> {
253        match source {
254            CredentialSource::Remote { endpoint, machine_token } => {
255                Some(Self::new(endpoint.clone(), machine_token.clone()))
256            }
257            CredentialSource::Local => None,
258        }
259    }
260}
261
262/// Provider-aware token resolution honoring the credential source — the single
263/// entry point both the Anthropic and the OpenAI/codex paths use. (#158 C4)
264///
265/// - `Local`: refresh the provider's own local credential (`ensure_fresh_*`).
266/// - `Remote`: fetch a short-lived access token from the broker, keyed by
267///   provider. Reuses the caller's `http` client (no per-call pool — A5), and
268///   never holds a refresh token / never writes `auth.json` (invariant 1).
269pub async fn resolve_access_token(
270    provider: &str,
271    source: &CredentialSource,
272    cache: &TokenCache,
273    http: &reqwest::Client,
274) -> Result<String, String> {
275    match source {
276        CredentialSource::Remote { endpoint, machine_token } => {
277            let broker = BrokerClient::with_client(endpoint.clone(), machine_token.clone(), http.clone());
278            Ok(resolve_remote(&broker, cache, provider, DEFAULT_MARGIN_MS).await?.access_token)
279        }
280        CredentialSource::Local => {
281            let creds = if provider == "anthropic" {
282                super::ensure_fresh_token(http).await?
283            } else {
284                super::ensure_fresh_provider_token(http, provider).await?
285            };
286            Ok(creds.access)
287        }
288    }
289}
290
291impl TokenFetcher for BrokerClient {
292    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String> {
293        let url = format!("{}/token", self.endpoint);
294        let resp = self
295            .http
296            .get(&url)
297            .query(&[("provider", provider)])
298            .bearer_auth(&self.machine_token)
299            .send()
300            .await
301            .map_err(|e| format!("broker request failed: {e}"))?;
302        let status = resp.status();
303        if status == reqwest::StatusCode::UNAUTHORIZED {
304            return Err("broker rejected machine auth (401)".to_string());
305        }
306        if !status.is_success() {
307            return Err(format!("broker returned HTTP {status}"));
308        }
309        let mut tok = resp
310            .json::<BrokerToken>()
311            .await
312            .map_err(|e| format!("invalid broker token response: {e}"))?;
313        // C3: prefer the broker's relative TTL over its absolute clock.
314        if let Some(ttl) = tok.ttl_ms {
315            tok.expires = now_millis().saturating_add(ttl);
316        }
317        // C2: reject a malformed/dead token rather than caching it (which would
318        // cause a permanent refetch storm or a dud bearer).
319        if tok.access_token.is_empty() {
320            return Err("broker returned an empty access_token".to_string());
321        }
322        if tok.expires <= now_millis() {
323            return Err("broker returned an already-expired token".to_string());
324        }
325        Ok(tok)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn from_parts_local_when_no_endpoint() {
335        assert_eq!(CredentialSource::from_parts(None, None), CredentialSource::Local);
336        assert_eq!(
337            CredentialSource::from_parts(Some("   ".into()), Some("m".into())),
338            CredentialSource::Local
339        );
340    }
341
342    #[test]
343    fn from_parts_remote_when_endpoint_set() {
344        let s = CredentialSource::from_parts(Some("https://jade.jade:8181".into()), Some("tok".into()));
345        assert_eq!(
346            s,
347            CredentialSource::Remote {
348                endpoint: "https://jade.jade:8181".into(),
349                machine_token: "tok".into()
350            }
351        );
352        assert!(s.is_remote());
353    }
354
355    #[test]
356    fn from_parts_trims_trailing_slash_and_whitespace() {
357        let s = CredentialSource::from_parts(Some("  https://b/  ".into()), Some("  tok  ".into()));
358        assert_eq!(
359            s,
360            CredentialSource::Remote { endpoint: "https://b".into(), machine_token: "tok".into() }
361        );
362    }
363
364    #[test]
365    fn remote_with_missing_machine_token_defaults_empty() {
366        let s = CredentialSource::from_parts(Some("https://b".into()), None);
367        assert_eq!(
368            s,
369            CredentialSource::Remote { endpoint: "https://b".into(), machine_token: String::new() }
370        );
371    }
372
373    #[test]
374    fn local_is_not_remote() {
375        assert!(!CredentialSource::Local.is_remote());
376    }
377
378    #[test]
379    fn expiry_far_future_not_expired() {
380        let far = now_millis() + 60 * 60 * 1000; // +1h
381        assert!(!is_expired_with_margin(far, DEFAULT_MARGIN_MS));
382    }
383
384    #[test]
385    fn expiry_past_is_expired() {
386        let past = now_millis().saturating_sub(1000);
387        assert!(is_expired_with_margin(past, 0));
388    }
389
390    #[test]
391    fn expiry_within_margin_is_expired() {
392        // expires in 2 minutes, margin 5 minutes -> treated as expired (refetch early)
393        let soon = now_millis() + 2 * 60 * 1000;
394        assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
395        // ...but with a 1-minute margin it is NOT yet expired
396        assert!(!is_expired_with_margin(soon, 60 * 1000));
397    }
398
399    #[test]
400    fn broker_token_deserializes_without_refresh_field() {
401        let json = r#"{"access_token":"sk-abc","expires":1750000000000}"#;
402        let t: BrokerToken = serde_json::from_str(json).unwrap();
403        assert_eq!(t.access_token, "sk-abc");
404        assert_eq!(t.expires, 1_750_000_000_000);
405    }
406
407    // ── cache ────────────────────────────────────────────────────────────
408    fn tok(expires: u64) -> BrokerToken {
409        BrokerToken { access_token: "sk-live".into(), expires, ttl_ms: None }
410    }
411
412    #[test]
413    fn cache_get_fresh_returns_unexpired() {
414        let c = TokenCache::new();
415        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
416        assert!(c.get_fresh("anthropic", DEFAULT_MARGIN_MS).is_some());
417    }
418
419    #[test]
420    fn cache_get_fresh_none_when_expired() {
421        let c = TokenCache::new();
422        c.put("anthropic", tok(now_millis().saturating_sub(1000)));
423        assert!(c.get_fresh("anthropic", 0).is_none());
424    }
425
426    #[test]
427    fn cache_invalidate_removes() {
428        let c = TokenCache::new();
429        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
430        c.invalidate("anthropic");
431        assert!(c.get_fresh("anthropic", 0).is_none());
432    }
433
434    #[test]
435    fn cache_providers_isolated() {
436        let c = TokenCache::new();
437        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
438        assert!(c.get_fresh("openai", 0).is_none());
439    }
440
441    // ── resolve with a fake fetcher (counts calls) ───────────────────────
442    use std::sync::atomic::{AtomicUsize, Ordering};
443
444    struct FakeFetcher {
445        token: BrokerToken,
446        calls: AtomicUsize,
447    }
448    impl TokenFetcher for FakeFetcher {
449        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
450            self.calls.fetch_add(1, Ordering::SeqCst);
451            Ok(self.token.clone())
452        }
453    }
454
455    #[tokio::test]
456    async fn resolve_cache_hit_does_not_fetch() {
457        let cache = TokenCache::new();
458        cache.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
459        let f = FakeFetcher { token: tok(0), calls: AtomicUsize::new(0) };
460        let t = resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS).await.unwrap();
461        assert_eq!(t, "sk-live");
462        assert_eq!(f.calls.load(Ordering::SeqCst), 0, "must not fetch on a cache hit");
463    }
464
465    #[tokio::test]
466    async fn resolve_miss_fetches_then_caches() {
467        let cache = TokenCache::new();
468        let f = FakeFetcher { token: tok(now_millis() + 60 * 60 * 1000), calls: AtomicUsize::new(0) };
469        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS).await.unwrap();
470        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS).await.unwrap();
471        assert_eq!(f.calls.load(Ordering::SeqCst), 1, "second resolve should hit the cache");
472    }
473
474    // ── broker-down degradation ───────────────────────────────────────────
475    struct FailFetcher;
476    impl TokenFetcher for FailFetcher {
477        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
478            Err("broker unreachable".into())
479        }
480    }
481
482    #[tokio::test]
483    async fn resolve_serves_stale_cache_when_broker_down() {
484        // Token expires in 2 min; margin is 5 min -> get_fresh misses (would
485        // refetch), the broker is down, but it's not HARD-expired, so we serve it.
486        let cache = TokenCache::new();
487        cache.put("anthropic", tok(now_millis() + 2 * 60 * 1000));
488        let t = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS).await.unwrap();
489        assert_eq!(t.access_token, "sk-live");
490    }
491
492    #[tokio::test]
493    async fn resolve_errors_when_broker_down_and_no_cache() {
494        let cache = TokenCache::new();
495        let err = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS).await.unwrap_err();
496        assert!(err.contains("unreachable"), "got: {err}");
497    }
498
499    #[tokio::test]
500    async fn resolve_errors_when_broker_down_and_cache_hard_expired() {
501        let cache = TokenCache::new();
502        cache.put("anthropic", tok(now_millis().saturating_sub(1000))); // already expired
503        let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0).await.unwrap_err();
504        assert!(err.contains("unreachable"), "got: {err}");
505    }
506
507    // ── BrokerClient against a tiny in-test axum server ──────────────────
508    async fn spawn_broker(token_json: &'static str, require_token: &'static str) -> String {
509        use axum::{http::HeaderMap, http::StatusCode, routing::get, Router};
510        let app = Router::new().route(
511            "/token",
512            get(move |headers: HeaderMap| async move {
513                let auth = headers
514                    .get("authorization")
515                    .and_then(|v| v.to_str().ok())
516                    .unwrap_or("");
517                if auth == format!("Bearer {require_token}") {
518                    (StatusCode::OK, token_json.to_string())
519                } else {
520                    (StatusCode::UNAUTHORIZED, String::new())
521                }
522            }),
523        );
524        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
525        let addr = listener.local_addr().unwrap();
526        tokio::spawn(async move {
527            axum::serve(listener, app).await.unwrap();
528        });
529        format!("http://{addr}")
530    }
531
532    #[tokio::test]
533    async fn broker_client_fetches_and_parses() {
534        let url = spawn_broker(
535            r#"{"access_token":"sk-from-broker","expires":9999999999999}"#,
536            "machine-xyz",
537        )
538        .await;
539        let c = BrokerClient::new(url, "machine-xyz");
540        let t = c.fetch_token("anthropic").await.unwrap();
541        assert_eq!(t.access_token, "sk-from-broker");
542        assert_eq!(t.expires, 9_999_999_999_999);
543    }
544
545    #[tokio::test]
546    async fn broker_client_401_on_bad_machine_auth() {
547        let url = spawn_broker(
548            r#"{"access_token":"x","expires":9999999999999}"#,
549            "right-token",
550        )
551        .await;
552        let c = BrokerClient::new(url, "WRONG-token");
553        let err = c.fetch_token("anthropic").await.unwrap_err();
554        assert!(err.contains("401"), "expected 401 error, got: {err}");
555    }
556
557    #[tokio::test]
558    async fn fetch_token_recomputes_expiry_from_ttl_ms() {
559        // Broker sends a STALE absolute `expires` but a fresh `ttl_ms`; the
560        // client must recompute from its own clock (clock-skew defense, C3).
561        let url = spawn_broker(r#"{"access_token":"sk","expires":1,"ttl_ms":3600000}"#, "m").await;
562        let c = BrokerClient::new(url, "m");
563        let t = c.fetch_token("anthropic").await.unwrap();
564        assert!(t.expires > now_millis(), "expires must come from ttl_ms, got {}", t.expires);
565    }
566
567    #[tokio::test]
568    async fn fetch_token_rejects_empty_access_token() {
569        let url = spawn_broker(r#"{"access_token":"","expires":9999999999999}"#, "m").await;
570        let c = BrokerClient::new(url, "m");
571        assert!(c.fetch_token("anthropic").await.unwrap_err().contains("empty"));
572    }
573
574    #[tokio::test]
575    async fn fetch_token_rejects_already_expired() {
576        let url = spawn_broker(r#"{"access_token":"sk","expires":1}"#, "m").await;
577        let c = BrokerClient::new(url, "m");
578        assert!(c.fetch_token("anthropic").await.unwrap_err().contains("expired"));
579    }
580
581    #[tokio::test]
582    async fn resolve_access_token_remote_fetches_from_broker() {
583        let url = spawn_broker(
584            r#"{"access_token":"sk-broker","expires":9999999999999}"#,
585            "m",
586        )
587        .await;
588        let source = CredentialSource::Remote { endpoint: url, machine_token: "m".into() };
589        let cache = TokenCache::new();
590        let http = reqwest::Client::new();
591        let t = resolve_access_token("anthropic", &source, &cache, &http).await.unwrap();
592        assert_eq!(t, "sk-broker");
593        // second call hits the cache (shared http client reused, no new pool)
594        let t2 = resolve_access_token("anthropic", &source, &cache, &http).await.unwrap();
595        assert_eq!(t2, "sk-broker");
596    }
597
598    // ── invariant: a Remote client can NEVER hold a refresh token ─────────
599    #[test]
600    fn broker_token_structurally_drops_any_refresh_field() {
601        // Even a misbehaving/compromised broker that leaks a refresh token in
602        // the JSON cannot make a client hold one: BrokerToken has no field for
603        // it, so serde silently drops it. The "clients never hold a refresh
604        // token" invariant is structural, not a runtime check.
605        let json = r#"{"access_token":"a","expires":1,"refresh_token":"LEAK","refresh":"LEAK"}"#;
606        let t: BrokerToken = serde_json::from_str(json).unwrap();
607        assert_eq!(t.access_token, "a");
608        assert_eq!(t.expires, 1);
609        // There is no `refresh`/`refresh_token` field to even read — confirmed
610        // at compile time by the struct definition above.
611    }
612}