1use std::collections::HashMap;
15use std::sync::{Arc, RwLock};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18#[derive(Clone, PartialEq, Eq, Default)]
20pub enum CredentialSource {
21 #[default]
23 Local,
24 Remote {
26 endpoint: String,
28 machine_token: String,
31 },
32}
33
34impl CredentialSource {
35 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
53impl 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#[derive(Clone, serde::Deserialize)]
73pub struct BrokerToken {
74 pub access_token: String,
75 pub expires: u64,
79 #[serde(default)]
83 pub ttl_ms: Option<u64>,
84}
85
86impl 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
97fn 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
105pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
110 now_millis().saturating_add(margin_ms) >= expires_ms
111}
112
113pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
115
116#[derive(Clone, Default)]
122pub struct TokenCache {
123 inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
124}
125
126impl 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 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 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#[allow(async_fn_in_trait)]
188pub trait TokenFetcher {
189 async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
190}
191
192pub 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 if let Some(tok) = cache.get_unexpired(provider) {
214 return Ok(tok);
215 }
216 Err(e)
217 }
218 }
219}
220
221pub 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
233pub 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 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 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 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
286pub 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 if let Some(ttl) = tok.ttl_ms {
328 tok.expires = now_millis().saturating_add(ttl);
329 }
330 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; 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 let soon = now_millis() + 2 * 60 * 1000;
417 assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
418 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 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 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 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 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))); let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0)
555 .await
556 .unwrap_err();
557 assert!(err.contains("unreachable"), "got: {err}");
558 }
559
560 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 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 let t2 = resolve_access_token("anthropic", &source, &cache, &http)
665 .await
666 .unwrap();
667 assert_eq!(t2, "sk-broker");
668 }
669
670 #[test]
672 fn broker_token_structurally_drops_any_refresh_field() {
673 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 }
684}