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(Debug, 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
86fn 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
94pub fn is_expired_with_margin(expires_ms: u64, margin_ms: u64) -> bool {
99 now_millis().saturating_add(margin_ms) >= expires_ms
100}
101
102pub const DEFAULT_MARGIN_MS: u64 = 5 * 60 * 1000;
104
105#[derive(Clone, Default)]
111pub struct TokenCache {
112 inner: Arc<RwLock<HashMap<String, BrokerToken>>>,
113}
114
115impl 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 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 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#[allow(async_fn_in_trait)]
175pub trait TokenFetcher {
176 async fn fetch_token(&self, provider: &str) -> Result<BrokerToken, String>;
177}
178
179pub 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 if let Some(tok) = cache.get_unexpired(provider) {
201 return Ok(tok);
202 }
203 Err(e)
204 }
205 }
206}
207
208pub 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
218pub 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 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 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 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
262pub 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 if let Some(ttl) = tok.ttl_ms {
315 tok.expires = now_millis().saturating_add(ttl);
316 }
317 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; 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 let soon = now_millis() + 2 * 60 * 1000;
394 assert!(is_expired_with_margin(soon, DEFAULT_MARGIN_MS));
395 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 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 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 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 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))); let err = resolve_remote(&FailFetcher, &cache, "anthropic", 0).await.unwrap_err();
504 assert!(err.contains("unreachable"), "got: {err}");
505 }
506
507 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 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 let t2 = resolve_access_token("anthropic", &source, &cache, &http).await.unwrap();
595 assert_eq!(t2, "sk-broker");
596 }
597
598 #[test]
600 fn broker_token_structurally_drops_any_refresh_field() {
601 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 }
612}