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
18use super::account::{Account, CredentialRef};
19
20/// Where a client gets its provider credentials.
21#[derive(Clone, PartialEq, Eq, Default)]
22pub enum CredentialSource {
23    /// Read + refresh the local `auth.json` (default — unchanged behavior).
24    #[default]
25    Local,
26    /// Fetch short-lived access tokens from a broker over the network.
27    Remote {
28        /// Broker base URL, e.g. `https://jade.jade:8181` (no trailing slash).
29        endpoint: String,
30        /// Per-machine bearer presented TO the broker. This is the machine's own
31        /// identity, NOT the provider credential.
32        machine_token: String,
33    },
34}
35
36impl CredentialSource {
37    /// Build from explicit config values. Returns `Remote` iff a non-empty
38    /// endpoint is given; otherwise `Local`. Trailing slashes on the endpoint
39    /// are trimmed so callers can join paths uniformly.
40    pub fn from_parts(endpoint: Option<String>, machine_token: Option<String>) -> Self {
41        match endpoint {
42            Some(e) if !e.trim().is_empty() => CredentialSource::Remote {
43                endpoint: e.trim().trim_end_matches('/').to_string(),
44                machine_token: machine_token.unwrap_or_default().trim().to_string(),
45            },
46            _ => CredentialSource::Local,
47        }
48    }
49
50    pub fn is_remote(&self) -> bool {
51        matches!(self, CredentialSource::Remote { .. })
52    }
53}
54
55/// Redacting Debug — never print the machine token (board M3/B3).
56impl std::fmt::Debug for CredentialSource {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            CredentialSource::Local => write!(f, "Local"),
60            CredentialSource::Remote { endpoint, .. } => f
61                .debug_struct("Remote")
62                .field("endpoint", endpoint)
63                .field("machine_token", &"***")
64                .finish(),
65        }
66    }
67}
68
69/// An access token as returned by the broker's `GET /token`.
70///
71/// Deliberately has **no** refresh-token field: a Remote client must never
72/// receive or hold one. This is the invariant made structural — there is no
73/// place to put a refresh token even if the broker mistakenly sent one.
74#[derive(Clone, serde::Deserialize)]
75pub struct BrokerToken {
76    pub access_token: String,
77    /// Absolute expiry, unix-epoch **milliseconds** (matches
78    /// `OAuthCredentials.expires`). When `ttl_ms` is present the client
79    /// overwrites this with its own clock + ttl to defeat clock skew.
80    pub expires: u64,
81    /// Optional relative TTL in ms. When the broker sends it, the client
82    /// recomputes `expires = client_now + ttl_ms`, eliminating broker↔client
83    /// clock skew on suspend/resume VMs (board C3). Absent → use `expires`.
84    #[serde(default)]
85    pub ttl_ms: Option<u64>,
86}
87
88/// Redacting Debug — never print access_token (review finding H2).
89impl std::fmt::Debug for BrokerToken {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("BrokerToken")
92            .field("access_token", &"[REDACTED]")
93            .field("expires", &self.expires)
94            .field("ttl_ms", &self.ttl_ms)
95            .finish()
96    }
97}
98
99/// Current unix time in milliseconds.
100fn now_millis() -> u64 {
101    SystemTime::now()
102        .duration_since(UNIX_EPOCH)
103        .map(|d| d.as_millis() as u64)
104        .unwrap_or(0)
105}
106
107/// True if `expires_ms` is already past, or within `margin_ms` of now.
108///
109/// The margin absorbs clock skew + request latency so a client refetches
110/// slightly early rather than presenting a token that dies mid-flight.
111pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
112    now_millis().saturating_add(margin_ms) >= expires_ms
113}
114
115/// Default refetch margin: 5 minutes (mirrors `is_token_expired`).
116pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
117
118// ── In-memory token cache ────────────────────────────────────────────────────
119
120type LocalBrokerMap = std::collections::BTreeMap<
121    (std::path::PathBuf, std::path::PathBuf),
122    Arc<super::broker::LocalBroker>,
123>;
124
125/// Thread-safe cache of broker access tokens. Cloneable handle over shared
126/// state. Holds ONLY short-lived access tokens, never a refresh token, never
127/// persisted to disk.
128///
129/// Keys are either a bare provider id (legacy callers) or a scoped key built
130/// by [`TokenCache::scoped_key`]: `"<storage_key>|<source scope>"`, where
131/// the scope identifies the broker endpoint AND the machine principal (a
132/// digest of the machine token — never the token itself) so a re-pointed or
133/// re-keyed client can never reuse another principal's cached token.
134#[derive(Clone, Default)]
135pub struct TokenCache {
136    inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
137    /// Local runtime authority retained across adapter construction. Clones
138    /// share cooldown/current-seat state; profiles have separate entries.
139    pub(crate) local_brokers: Arc<std::sync::Mutex<LocalBrokerMap>>,
140}
141
142/// Opaque, log-safe identity of a remote credential source.
143pub fn source_scope(endpoint: &str, machine_token: &str) -> String {
144    use sha2::{Digest, Sha256};
145    let principal = if machine_token.is_empty() {
146        "anon".to_string()
147    } else {
148        let digest = Sha256::digest(machine_token.as_bytes());
149        hex_prefix(&digest, 16)
150    };
151    format!("{}#{principal}", endpoint.trim_end_matches('/'))
152}
153
154fn hex_prefix(bytes: &[u8], chars: usize) -> String {
155    let mut out = String::with_capacity(chars);
156    for b in bytes {
157        if out.len() >= chars {
158            break;
159        }
160        out.push_str(&format!("{b:02x}"));
161    }
162    out.truncate(chars);
163    out
164}
165
166/// Provider id component of a cache key (bare provider or scoped key).
167fn key_provider(key: &str) -> Option<String> {
168    let storage_key = key.split_once('|').map(|(k, _)| k).unwrap_or(key);
169    CredentialRef::parse_storage_key(storage_key)
170        .map(|c| c.provider.as_str().to_string())
171        .or_else(|| Some(storage_key.to_string()))
172}
173
174/// Redacting Debug — print only the cached provider names, never the tokens.
175impl std::fmt::Debug for TokenCache {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        let providers: Vec<String> = self
178            .inner
179            .read()
180            .map(|m| m.keys().cloned().collect())
181            .unwrap_or_default();
182        f.debug_struct("TokenCache")
183            .field("providers", &providers)
184            .finish()
185    }
186}
187
188impl TokenCache {
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// Return the cached token for `provider` only if present AND not within
194    /// `margin_ms` of expiry. Otherwise `None` (caller should fetch).
195    pub fn get_fresh(&self, provider: &str, margin_ms: u64) -> Option<BrokerToken> {
196        let map = self.inner.read().ok()?;
197        let tok = map.get(provider)?;
198        if is_expired_with_margin(tok.expires, margin_ms) {
199            None
200        } else {
201            Some(tok.clone())
202        }
203    }
204
205    pub fn put(&self, provider: &str, token: BrokerToken) {
206        if let Ok(mut map) = self.inner.write() {
207            map.insert(provider.to_string(), token);
208        }
209    }
210
211    /// Drop every cached token for `provider` — the legacy bare key and every
212    /// scoped `(account, source)` key of that provider — so a 401 or a source
213    /// switch can never leave an account's stale token behind.
214    pub fn invalidate(&self, provider: &str) {
215        if let Ok(mut map) = self.inner.write() {
216            map.retain(|key, _| key_provider(key).as_deref() != Some(provider));
217        }
218    }
219
220    /// Cache key for one credential fetched from one source scope.
221    pub fn scoped_key(scope: &str, cred: &CredentialRef) -> String {
222        format!("{}|{scope}", cred.storage_key())
223    }
224
225    /// Drop the cached token for exactly one scoped credential.
226    pub fn invalidate_credential(&self, scope: &str, cred: &CredentialRef) {
227        if let Ok(mut map) = self.inner.write() {
228            map.remove(&Self::scoped_key(scope, cred));
229        }
230    }
231
232    /// Cached token if present and not PAST hard expiry (ignores the refetch
233    /// margin). Used as a degraded-mode fallback when the broker is unreachable:
234    /// a token slightly inside its refetch window is still better than failing.
235    pub fn get_unexpired(&self, provider: &str) -> Option<BrokerToken> {
236        let map = self.inner.read().ok()?;
237        let tok = map.get(provider)?;
238        if is_expired_with_margin(tok.expires, 0) {
239            None
240        } else {
241            Some(tok.clone())
242        }
243    }
244}
245
246// ── Fetcher abstraction + resolver ───────────────────────────────────────────
247
248/// Typed failure of an account-addressed token fetch. Display is secret-free.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum TokenFetchError {
251    /// Broker rejected machine auth (401).
252    Unauthorized,
253    /// Broker reports the explicit account slot does not exist (404).
254    UnknownAccount,
255    /// Broker rejected the account label (400).
256    InvalidAccount,
257    /// Fetcher/broker cannot address named slots.
258    UnsupportedAccount,
259    /// Broker served a token for a different slot than the one requested
260    /// (or, for a named slot, did not say which). Never cached.
261    AccountMismatch,
262    /// Any other HTTP status.
263    Http(u16),
264    /// Transport or response-shape failure (message already secret-free).
265    Other(String),
266}
267
268impl std::fmt::Display for TokenFetchError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Unauthorized => f.write_str(&super::broker_token::unauthorized_hint()),
272            Self::UnknownAccount => write!(f, "broker reports unknown account (404)"),
273            Self::InvalidAccount => write!(f, "broker rejected account label (400)"),
274            Self::UnsupportedAccount => write!(f, "named accounts are not supported by this fetcher"),
275            Self::AccountMismatch => write!(
276                f,
277                "broker served a token for a different account than requested (broker too old for named accounts?)"
278            ),
279            Self::Http(status) => write!(f, "broker returned HTTP {status}"),
280            Self::Other(msg) => f.write_str(msg),
281        }
282    }
283}
284
285impl TokenFetchError {
286    pub fn into_broker_error(self, cred: &CredentialRef) -> super::broker::BrokerError {
287        use super::broker::BrokerError;
288        match self {
289            Self::Unauthorized => BrokerError::Unauthorized,
290            Self::UnknownAccount => BrokerError::UnknownAccount {
291                provider: cred.provider.as_str().to_string(),
292                label: cred.account.label_str().to_string(),
293            },
294            Self::InvalidAccount => {
295                BrokerError::InvalidAccount(format!("broker rejected '{}'", cred.account))
296            }
297            Self::UnsupportedAccount => BrokerError::UnsupportedAccount {
298                provider: cred.provider.as_str().to_string(),
299                label: cred.account.label_str().to_string(),
300            },
301            Self::AccountMismatch => BrokerError::Transport(format!(
302                "broker served a token for a different account than '{}' (no fallback)",
303                cred
304            )),
305            Self::Http(status) => BrokerError::Transport(format!("broker returned HTTP {status}")),
306            Self::Other(msg) => BrokerError::Transport(msg),
307        }
308    }
309}
310
311/// "Get a fresh access token from somewhere." Abstracted so the cache/resolve
312/// logic is unit-testable without real HTTP. The real impl is `BrokerClient`.
313#[allow(async_fn_in_trait)]
314pub trait TokenFetcher {
315    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
316
317    /// Account-addressed fetch. Default: the default slot goes through
318    /// [`fetch_token`](Self::fetch_token); a named slot is unsupported (never
319    /// silently served from the default slot).
320    async fn fetch_credential(&self, cred: &CredentialRef) -> Result<BrokerToken, TokenFetchError> {
321        match &cred.account {
322            Account::Default => self
323                .fetch_token(cred.provider.as_str())
324                .await
325                .map_err(TokenFetchError::Other),
326            Account::Named(_) => Err(TokenFetchError::UnsupportedAccount),
327        }
328    }
329}
330
331/// Resolve a provider token via cache-or-fetch, returning the full
332/// `BrokerToken` (access + expiry). The runtime needs the expiry to drive its
333/// in-memory refresh trigger. NEVER touches a refresh token or `auth.json`.
334pub async fn resolve_remote<F: TokenFetcher>(
335    fetcher: &F,
336    cache: &TokenCache,
337    provider: &str,
338    margin_ms: u64,
339) -> Result<BrokerToken, String> {
340    if let Some(tok) = cache.get_fresh(provider, margin_ms) {
341        return Ok(tok);
342    }
343    match fetcher.fetch_token(provider).await {
344        Ok(tok) => {
345            cache.put(provider, tok.clone());
346            Ok(tok)
347        }
348        Err(e) => {
349            // Degraded mode: the broker is unreachable, but if we still hold a
350            // token that hasn't hit hard expiry, serve it rather than failing
351            // the turn. Self-heals on the next call once the broker is back.
352            if let Some(tok) = cache.get_unexpired(provider) {
353                return Ok(tok);
354            }
355            Err(e)
356        }
357    }
358}
359
360/// Account- and source-scoped variant of [`resolve_remote`]: the cache key
361/// carries the storage key plus the source scope, so tokens for different
362/// accounts, endpoints or machine principals never alias. Same degraded-mode
363/// rule (serve an unexpired cached token when the broker is unreachable),
364/// except that typed rejections (401/404/400) are never masked by the cache.
365pub async fn resolve_remote_credential<F: TokenFetcher>(
366    fetcher: &F,
367    cache: &TokenCache,
368    scope: &str,
369    cred: &CredentialRef,
370    margin_ms: u64,
371) -> Result<BrokerToken, TokenFetchError> {
372    let key = TokenCache::scoped_key(scope, cred);
373    if let Some(tok) = cache.get_fresh(&key, margin_ms) {
374        return Ok(tok);
375    }
376    match fetcher.fetch_credential(cred).await {
377        Ok(tok) => {
378            cache.put(&key, tok.clone());
379            Ok(tok)
380        }
381        Err(e @ TokenFetchError::Other(_)) | Err(e @ TokenFetchError::Http(_)) => {
382            if let Some(tok) = cache.get_unexpired(&key) {
383                return Ok(tok);
384            }
385            Err(e)
386        }
387        Err(rejected @ TokenFetchError::AccountMismatch) => {
388            // Wrong slot served: never serve a cached token for a slot the
389            // broker no longer honours; the caller sees the mismatch.
390            cache.invalidate_credential(scope, cred);
391            Err(rejected)
392        }
393        Err(rejected) => {
394            // The broker positively rejected this credential/principal: a
395            // cached token must not paper over it.
396            cache.invalidate_credential(scope, cred);
397            Err(rejected)
398        }
399    }
400}
401
402/// Like [`resolve_remote`] but returns only the access token string.
403pub async fn resolve_remote_token<F: TokenFetcher>(
404    fetcher: &F,
405    cache: &TokenCache,
406    provider: &str,
407    margin_ms: u64,
408) -> Result<String, String> {
409    Ok(resolve_remote(fetcher, cache, provider, margin_ms)
410        .await?
411        .access_token)
412}
413
414// ── Broker HTTP client ───────────────────────────────────────────────────────
415
416/// HTTP client for a credential broker. Presents the machine's own bearer token
417/// (NOT the provider credential) and receives a short-lived access token.
418pub struct BrokerClient {
419    http: reqwest::Client,
420    endpoint: String,
421    /// Reloaded from the local broker token file after a 401 when the
422    /// endpoint is loopback (prA I2).
423    machine_token: super::MachineToken,
424}
425
426impl BrokerClient {
427    pub fn new(endpoint: impl Into<String>, machine_token: impl Into<String>) -> Self {
428        // D1: bound the request so a hung/unreachable broker can't stall the
429        // caller's whole turn. (The runtime path uses `with_client` and inherits
430        // the runtime's configured client; this is the standalone fallback.)
431        let http = reqwest::Client::builder()
432            .connect_timeout(std::time::Duration::from_secs(5))
433            .timeout(std::time::Duration::from_secs(20))
434            .build()
435            .unwrap_or_default();
436        let endpoint = endpoint.into();
437        let machine_token = super::MachineToken::for_endpoint(&endpoint, machine_token);
438        Self {
439            http,
440            endpoint,
441            machine_token,
442        }
443    }
444
445    /// Like `new` but reuses an existing `reqwest::Client` (shared connection
446    /// pool) instead of building a fresh one per call. (#158 A5)
447    pub fn with_client(
448        endpoint: impl Into<String>,
449        machine_token: impl Into<String>,
450        http: reqwest::Client,
451    ) -> Self {
452        let endpoint = endpoint.into();
453        let machine_token = super::MachineToken::for_endpoint(&endpoint, machine_token);
454        Self {
455            http,
456            endpoint,
457            machine_token,
458        }
459    }
460
461    /// Share an existing (reloadable) machine token, e.g. the
462    /// [`super::RemoteBroker`]'s, so a reload after a 401 reaches both.
463    pub fn with_machine_token(
464        endpoint: impl Into<String>,
465        machine_token: super::MachineToken,
466        http: reqwest::Client,
467    ) -> Self {
468        Self {
469            http,
470            endpoint: endpoint.into(),
471            machine_token,
472        }
473    }
474
475    /// Build from a `CredentialSource`. `None` for `Local`.
476    pub fn from_source(source: &CredentialSource) -> Option<Self> {
477        match source {
478            CredentialSource::Remote {
479                endpoint,
480                machine_token,
481            } => Some(Self::new(endpoint.clone(), machine_token.clone())),
482            CredentialSource::Local => None,
483        }
484    }
485}
486
487/// Provider-aware token resolution honoring the credential source — kept as a
488/// compatibility adapter for CLI callers (`synaps status`). Delegates to the
489/// typed credential broker: the local in-process broker or the authenticated
490/// remote transport. Vends the access token only.
491pub async fn resolve_access_token(
492    provider: &str,
493    source: &CredentialSource,
494    cache: &TokenCache,
495    http: &reqwest::Client,
496) -> Result<String, String> {
497    let provider: super::provider::OAuthProviderId = provider.try_into()?;
498    super::broker::broker_from_source(source, cache, http.clone())
499        .access_token(provider)
500        .await
501        .map(|t| t.token)
502        .map_err(|e| e.to_string())
503}
504
505impl BrokerClient {
506    /// Source scope for cache keys (endpoint + machine-principal digest).
507    pub fn scope(&self) -> String {
508        source_scope(&self.endpoint, &self.machine_token.get())
509    }
510
511    /// `GET /token?provider=X[&account=Y]`.
512    ///
513    /// `account: None` is the legacy, policy-free call (`fetch_token`): the
514    /// broker host applies its own policy and the result is cached under the
515    /// bare provider key. `Some(slot)` is an EXPLICIT slot (including
516    /// `default`): the parameter is always sent, and the response must name
517    /// that same slot — a broker serving another slot (or an old broker that
518    /// ignores the parameter and says nothing) is rejected for named slots and
519    /// never cached under the requested key. An absent `account` is tolerated
520    /// only for `default`, because pre-multi-account brokers always served the
521    /// bare provider slot.
522    async fn fetch_token_query(
523        &self,
524        provider: &str,
525        account: Option<&str>,
526    ) -> Result<BrokerToken, TokenFetchError> {
527        let url = format!("{}/token", self.endpoint);
528        let mut query: Vec<(&str, &str)> = vec![("provider", provider)];
529        if let Some(account) = account {
530            query.push(("account", account));
531        }
532        let resp = self
533            .machine_token
534            .send(|t| self.http.get(&url).query(&query).bearer_auth(t))
535            .await
536            .map_err(|e| TokenFetchError::Other(format!("broker request failed: {e}")))?;
537        let status = resp.status();
538        match status.as_u16() {
539            401 => return Err(TokenFetchError::Unauthorized),
540            404 if account.is_some() => return Err(TokenFetchError::UnknownAccount),
541            400 if account.is_some() => return Err(TokenFetchError::InvalidAccount),
542            s if !status.is_success() => return Err(TokenFetchError::Http(s)),
543            _ => {}
544        }
545        #[derive(serde::Deserialize)]
546        struct Wire {
547            access_token: String,
548            expires: u64,
549            #[serde(default)]
550            ttl_ms: Option<u64>,
551            #[serde(default)]
552            account: Option<String>,
553        }
554        let wire = resp
555            .json::<Wire>()
556            .await
557            .map_err(|e| TokenFetchError::Other(format!("invalid broker token response: {e}")))?;
558        if let Some(requested) = account {
559            match wire.account.as_deref() {
560                Some(served) if served == requested => {}
561                None if requested == super::account::DEFAULT_ACCOUNT_NAME => {}
562                _ => return Err(TokenFetchError::AccountMismatch),
563            }
564        }
565        let mut tok = BrokerToken {
566            access_token: wire.access_token,
567            expires: wire.expires,
568            ttl_ms: wire.ttl_ms,
569        };
570        // C3: prefer the broker's relative TTL over its absolute clock.
571        if let Some(ttl) = tok.ttl_ms {
572            tok.expires = now_millis().saturating_add(ttl);
573        }
574        // C2: reject a malformed/dead token rather than caching it (which would
575        // cause a permanent refetch storm or a dud bearer).
576        if tok.access_token.is_empty() {
577            return Err(TokenFetchError::Other(
578                "broker returned an empty access_token".to_string(),
579            ));
580        }
581        if tok.expires <= now_millis() {
582            return Err(TokenFetchError::Other(
583                "broker returned an already-expired token".to_string(),
584            ));
585        }
586        Ok(tok)
587    }
588}
589
590impl TokenFetcher for BrokerClient {
591    async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String> {
592        self.fetch_token_query(provider, None)
593            .await
594            .map_err(|e| e.to_string())
595    }
596
597    async fn fetch_credential(&self, cred: &CredentialRef) -> Result<BrokerToken, TokenFetchError> {
598        // Explicit slot — ALWAYS sent, including `default`, so the broker
599        // host's own policy (which may point at a named or `auto` slot) can
600        // never be cached under this client's `default` key.
601        self.fetch_token_query(cred.provider.as_str(), Some(cred.account.label_str()))
602            .await
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn from_parts_local_when_no_endpoint() {
612        assert_eq!(
613            CredentialSource::from_parts(None, None),
614            CredentialSource::Local
615        );
616        assert_eq!(
617            CredentialSource::from_parts(Some("   ".into()), Some("m".into())),
618            CredentialSource::Local
619        );
620    }
621
622    #[test]
623    fn from_parts_remote_when_endpoint_set() {
624        let s =
625            CredentialSource::from_parts(Some("https://jade.jade:8181".into()), Some("tok".into()));
626        assert_eq!(
627            s,
628            CredentialSource::Remote {
629                endpoint: "https://jade.jade:8181".into(),
630                machine_token: "tok".into()
631            }
632        );
633        assert!(s.is_remote());
634    }
635
636    #[test]
637    fn from_parts_trims_trailing_slash_and_whitespace() {
638        let s = CredentialSource::from_parts(Some("  https://b/  ".into()), Some("  tok  ".into()));
639        assert_eq!(
640            s,
641            CredentialSource::Remote {
642                endpoint: "https://b".into(),
643                machine_token: "tok".into()
644            }
645        );
646    }
647
648    #[test]
649    fn remote_with_missing_machine_token_defaults_empty() {
650        let s = CredentialSource::from_parts(Some("https://b".into()), None);
651        assert_eq!(
652            s,
653            CredentialSource::Remote {
654                endpoint: "https://b".into(),
655                machine_token: String::new()
656            }
657        );
658    }
659
660    #[test]
661    fn local_is_not_remote() {
662        assert!(!CredentialSource::Local.is_remote());
663    }
664
665    #[test]
666    fn expiry_far_future_not_expired() {
667        let far = now_millis() + 60 * 60 * 1000; // +1h
668        assert!(!is_expired_with_margin(far, DEFAULT_MARGIN_MS));
669    }
670
671    #[test]
672    fn expiry_past_is_expired() {
673        let past = now_millis().saturating_sub(1000);
674        assert!(is_expired_with_margin(past, 0));
675    }
676
677    #[test]
678    fn expiry_within_margin_is_expired() {
679        // expires in 2 minutes, margin 5 minutes -> treated as expired (refetch early)
680        let soon = now_millis() + 2 * 60 * 1000;
681        assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
682        // ...but with a 1-minute margin it is NOT yet expired
683        assert!(!is_expired_with_margin(soon, 60 * 1000));
684    }
685
686    #[test]
687    fn broker_token_deserializes_without_refresh_field() {
688        let json = r#"{"access_token":"sk-abc","expires":1750000000000}"#;
689        let t: BrokerToken = serde_json::from_str(json).unwrap();
690        assert_eq!(t.access_token, "sk-abc");
691        assert_eq!(t.expires, 1_750_000_000_000);
692    }
693
694    // ── cache ────────────────────────────────────────────────────────────
695    fn tok(expires: u64) -> BrokerToken {
696        BrokerToken {
697            access_token: "sk-live".into(),
698            expires,
699            ttl_ms: None,
700        }
701    }
702
703    #[test]
704    fn cache_get_fresh_returns_unexpired() {
705        let c = TokenCache::new();
706        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
707        assert!(c.get_fresh("anthropic", DEFAULT_MARGIN_MS).is_some());
708    }
709
710    #[test]
711    fn cache_get_fresh_none_when_expired() {
712        let c = TokenCache::new();
713        c.put("anthropic", tok(now_millis().saturating_sub(1000)));
714        assert!(c.get_fresh("anthropic", 0).is_none());
715    }
716
717    #[test]
718    fn cache_invalidate_removes() {
719        let c = TokenCache::new();
720        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
721        c.invalidate("anthropic");
722        assert!(c.get_fresh("anthropic", 0).is_none());
723    }
724
725    #[test]
726    fn cache_providers_isolated() {
727        let c = TokenCache::new();
728        c.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
729        assert!(c.get_fresh("openai", 0).is_none());
730    }
731
732    // ── resolve with a fake fetcher (counts calls) ───────────────────────
733    use std::sync::atomic::{AtomicUsize, Ordering};
734
735    struct FakeFetcher {
736        token: BrokerToken,
737        calls: AtomicUsize,
738    }
739    impl TokenFetcher for FakeFetcher {
740        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
741            self.calls.fetch_add(1, Ordering::SeqCst);
742            Ok(self.token.clone())
743        }
744    }
745
746    #[tokio::test]
747    async fn resolve_cache_hit_does_not_fetch() {
748        let cache = TokenCache::new();
749        cache.put("anthropic", tok(now_millis() + 60 * 60 * 1000));
750        let f = FakeFetcher {
751            token: tok(0),
752            calls: AtomicUsize::new(0),
753        };
754        let t = resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
755            .await
756            .unwrap();
757        assert_eq!(t, "sk-live");
758        assert_eq!(
759            f.calls.load(Ordering::SeqCst),
760            0,
761            "must not fetch on a cache hit"
762        );
763    }
764
765    #[tokio::test]
766    async fn resolve_miss_fetches_then_caches() {
767        let cache = TokenCache::new();
768        let f = FakeFetcher {
769            token: tok(now_millis() + 60 * 60 * 1000),
770            calls: AtomicUsize::new(0),
771        };
772        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
773            .await
774            .unwrap();
775        resolve_remote_token(&f, &cache, "anthropic", DEFAULT_MARGIN_MS)
776            .await
777            .unwrap();
778        assert_eq!(
779            f.calls.load(Ordering::SeqCst),
780            1,
781            "second resolve should hit the cache"
782        );
783    }
784
785    // ── broker-down degradation ───────────────────────────────────────────
786    struct FailFetcher;
787    impl TokenFetcher for FailFetcher {
788        async fn fetch_token(&self, _provider: &str) -> Result<BrokerToken, String> {
789            Err("broker unreachable".into())
790        }
791    }
792
793    #[tokio::test]
794    async fn resolve_serves_stale_cache_when_broker_down() {
795        // Token expires in 2 min; margin is 5 min -> get_fresh misses (would
796        // refetch), the broker is down, but it's not HARD-expired, so we serve it.
797        let cache = TokenCache::new();
798        cache.put("anthropic", tok(now_millis() + 2 * 60 * 1000));
799        let t = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS)
800            .await
801            .unwrap();
802        assert_eq!(t.access_token, "sk-live");
803    }
804
805    #[tokio::test]
806    async fn resolve_errors_when_broker_down_and_no_cache() {
807        let cache = TokenCache::new();
808        let err = resolve_remote(&FailFetcher, &cache, "anthropic", DEFAULT_MARGIN_MS)
809            .await
810            .unwrap_err();
811        assert!(err.contains("unreachable"), "got: {err}");
812    }
813
814    #[tokio::test]
815    async fn resolve_errors_when_broker_down_and_cache_hard_expired() {
816        let cache = TokenCache::new();
817        cache.put("anthropic", tok(now_millis().saturating_sub(1000))); // already expired
818        let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0)
819            .await
820            .unwrap_err();
821        assert!(err.contains("unreachable"), "got: {err}");
822    }
823
824    // ── BrokerClient against a tiny in-test axum server ──────────────────
825    async fn spawn_broker(token_json: &'static str, require_token: &'static str) -> String {
826        use axum::{http::HeaderMap, http::StatusCode, routing::get, Router};
827        let app = Router::new().route(
828            "/token",
829            get(move |headers: HeaderMap| async move {
830                let auth = headers
831                    .get("authorization")
832                    .and_then(|v| v.to_str().ok())
833                    .unwrap_or("");
834                if auth == format!("Bearer {require_token}") {
835                    (StatusCode::OK, token_json.to_string())
836                } else {
837                    (StatusCode::UNAUTHORIZED, String::new())
838                }
839            }),
840        );
841        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
842        let addr = listener.local_addr().unwrap();
843        tokio::spawn(async move {
844            axum::serve(listener, app).await.unwrap();
845        });
846        format!("http://{addr}")
847    }
848
849    #[tokio::test]
850    async fn broker_client_fetches_and_parses() {
851        let url = spawn_broker(
852            r#"{"access_token":"sk-from-broker","expires":9999999999999}"#,
853            "machine-xyz",
854        )
855        .await;
856        let c = BrokerClient::new(url, "machine-xyz");
857        let t = c.fetch_token("anthropic").await.unwrap();
858        assert_eq!(t.access_token, "sk-from-broker");
859        assert_eq!(t.expires, 9_999_999_999_999);
860    }
861
862    #[tokio::test]
863    async fn broker_client_401_on_bad_machine_auth() {
864        let url = spawn_broker(
865            r#"{"access_token":"x","expires":9999999999999}"#,
866            "right-token",
867        )
868        .await;
869        let c = BrokerClient::new(url, "WRONG-token");
870        let err = c.fetch_token("anthropic").await.unwrap_err();
871        assert!(err.contains("401"), "expected 401 error, got: {err}");
872    }
873
874    #[tokio::test]
875    async fn fetch_token_recomputes_expiry_from_ttl_ms() {
876        // Broker sends a STALE absolute `expires` but a fresh `ttl_ms`; the
877        // client must recompute from its own clock (clock-skew defense, C3).
878        let url = spawn_broker(r#"{"access_token":"sk","expires":1,"ttl_ms":3600000}"#, "m").await;
879        let c = BrokerClient::new(url, "m");
880        let t = c.fetch_token("anthropic").await.unwrap();
881        assert!(
882            t.expires > now_millis(),
883            "expires must come from ttl_ms, got {}",
884            t.expires
885        );
886    }
887
888    #[tokio::test]
889    async fn fetch_token_rejects_empty_access_token() {
890        let url = spawn_broker(r#"{"access_token":"","expires":9999999999999}"#, "m").await;
891        let c = BrokerClient::new(url, "m");
892        assert!(c
893            .fetch_token("anthropic")
894            .await
895            .unwrap_err()
896            .contains("empty"));
897    }
898
899    #[tokio::test]
900    async fn fetch_token_rejects_already_expired() {
901        let url = spawn_broker(r#"{"access_token":"sk","expires":1}"#, "m").await;
902        let c = BrokerClient::new(url, "m");
903        assert!(c
904            .fetch_token("anthropic")
905            .await
906            .unwrap_err()
907            .contains("expired"));
908    }
909
910    #[tokio::test]
911    async fn resolve_access_token_remote_fetches_from_broker() {
912        let url = spawn_broker(
913            r#"{"access_token":"sk-broker","expires":9999999999999,"account":"default"}"#,
914            "m",
915        )
916        .await;
917        let source = CredentialSource::Remote {
918            endpoint: url,
919            machine_token: "m".into(),
920        };
921        let cache = TokenCache::new();
922        let http = reqwest::Client::new();
923        let t = resolve_access_token("anthropic", &source, &cache, &http)
924            .await
925            .unwrap();
926        assert_eq!(t, "sk-broker");
927        // second call hits the cache (shared http client reused, no new pool)
928        let t2 = resolve_access_token("anthropic", &source, &cache, &http)
929            .await
930            .unwrap();
931        assert_eq!(t2, "sk-broker");
932    }
933
934    // ── invariant: a Remote client can NEVER hold a refresh token ─────────
935    #[test]
936    fn broker_token_structurally_drops_any_refresh_field() {
937        // Even a misbehaving/compromised broker that leaks a refresh token in
938        // the JSON cannot make a client hold one: BrokerToken has no field for
939        // it, so serde silently drops it. The "clients never hold a refresh
940        // token" invariant is structural, not a runtime check.
941        let json = r#"{"access_token":"a","expires":1,"refresh_token":"LEAK","refresh":"LEAK"}"#;
942        let t: BrokerToken = serde_json::from_str(json).unwrap();
943        assert_eq!(t.access_token, "a");
944        assert_eq!(t.expires, 1);
945        // There is no `refresh`/`refresh_token` field to even read — confirmed
946        // at compile time by the struct definition above.
947    }
948}