1use std::collections::HashMap;
15use std::sync::{Arc, RwLock};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use super::account::{Account, CredentialRef};
19
20#[derive(Clone, PartialEq, Eq, Default)]
22pub enum CredentialSource {
23 #[default]
25 Local,
26 Remote {
28 endpoint: String,
30 machine_token: String,
33 },
34}
35
36impl CredentialSource {
37 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
55impl 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#[derive(Clone, serde::Deserialize)]
75pub struct BrokerToken {
76 pub access_token: String,
77 pub expires: u64,
81 #[serde(default)]
85 pub ttl_ms: Option<u64>,
86}
87
88impl 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
99fn 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
107pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
112 now_millis().saturating_add(margin_ms) >= expires_ms
113}
114
115pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
117
118type LocalBrokerMap = std::collections::BTreeMap<
121 (std::path::PathBuf, std::path::PathBuf),
122 Arc<super::broker::LocalBroker>,
123>;
124
125#[derive(Clone, Default)]
135pub struct TokenCache {
136 inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
137 pub(crate) local_brokers: Arc<std::sync::Mutex<LocalBrokerMap>>,
140}
141
142pub 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
166fn 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
174impl 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 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 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 pub fn scoped_key(scope: &str, cred: &CredentialRef) -> String {
222 format!("{}|{scope}", cred.storage_key())
223 }
224
225 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 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#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum TokenFetchError {
251 Unauthorized,
253 UnknownAccount,
255 InvalidAccount,
257 UnsupportedAccount,
259 AccountMismatch,
262 Http(u16),
264 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#[allow(async_fn_in_trait)]
314pub trait TokenFetcher {
315 async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
316
317 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
331pub 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 if let Some(tok) = cache.get_unexpired(provider) {
353 return Ok(tok);
354 }
355 Err(e)
356 }
357 }
358}
359
360pub 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 cache.invalidate_credential(scope, cred);
391 Err(rejected)
392 }
393 Err(rejected) => {
394 cache.invalidate_credential(scope, cred);
397 Err(rejected)
398 }
399 }
400}
401
402pub 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
414pub struct BrokerClient {
419 http: reqwest::Client,
420 endpoint: String,
421 machine_token: super::MachineToken,
424}
425
426impl BrokerClient {
427 pub fn new(endpoint: impl Into<String>, machine_token: impl Into<String>) -> Self {
428 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 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 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 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
487pub 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 pub fn scope(&self) -> String {
508 source_scope(&self.endpoint, &self.machine_token.get())
509 }
510
511 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 if let Some(ttl) = tok.ttl_ms {
572 tok.expires = now_millis().saturating_add(ttl);
573 }
574 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 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; 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 let soon = now_millis() + 2 * 60 * 1000;
681 assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
682 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 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 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 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 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))); let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0)
819 .await
820 .unwrap_err();
821 assert!(err.contains("unreachable"), "got: {err}");
822 }
823
824 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 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 let t2 = resolve_access_token("anthropic", &source, &cache, &http)
929 .await
930 .unwrap();
931 assert_eq!(t2, "sk-broker");
932 }
933
934 #[test]
936 fn broker_token_structurally_drops_any_refresh_field() {
937 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 }
948}