1use crate::auth::resolver::{AuthError, AuthResolver, ResolvedIdentity};
2use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
3use serde::Deserialize;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6use tonic::metadata::MetadataMap;
7
8#[derive(Debug, Clone, Deserialize)]
9struct MACPClaims {
10 sub: String,
11 #[serde(default)]
12 macp_scopes: Option<MACPScopes>,
13}
14
15#[derive(Debug, Clone, Deserialize, Default)]
16struct MACPScopes {
17 #[serde(default)]
18 can_start_sessions: Option<bool>,
19 #[serde(default)]
20 can_manage_mode_registry: Option<bool>,
21 #[serde(default)]
22 is_observer: Option<bool>,
23 #[serde(default)]
24 allowed_modes: Option<Vec<String>>,
25 #[serde(default)]
26 max_open_sessions: Option<usize>,
27}
28
29#[derive(Debug, Clone)]
30pub struct JwtConfig {
31 pub issuer: String,
32 pub audience: String,
33 pub algorithms: Vec<Algorithm>,
34}
35
36struct CachedKeys {
37 keys: Vec<(Option<String>, DecodingKey)>,
38 fetched_at: std::time::Instant,
39}
40
41pub struct JwtBearerResolver {
42 config: JwtConfig,
43 jwks_source: JwksSource,
44 cached_keys: Arc<RwLock<Option<CachedKeys>>>,
45 cache_ttl: std::time::Duration,
46 refresh_lock: tokio::sync::Mutex<()>,
50 http_client: std::sync::OnceLock<reqwest::Client>,
53}
54
55enum JwksSource {
56 Inline(Vec<(Option<String>, DecodingKey)>),
57 Url(String),
58}
59
60const STALE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
65
66impl JwtBearerResolver {
67 pub fn from_inline_json(config: JwtConfig, jwks_json: &str) -> Result<Self, String> {
68 let jwks: serde_json::Value =
69 serde_json::from_str(jwks_json).map_err(|e| format!("invalid JWKS JSON: {e}"))?;
70 let keys = Self::parse_jwks(&jwks)?;
71 tracing::info!(
72 keys = keys.len(),
73 issuer = %config.issuer,
74 "JWT resolver initialized with inline JWKS"
75 );
76 Ok(Self {
77 config,
78 jwks_source: JwksSource::Inline(keys.clone()),
79 cached_keys: Arc::new(RwLock::new(Some(CachedKeys {
80 keys,
81 fetched_at: std::time::Instant::now(),
82 }))),
83 cache_ttl: std::time::Duration::from_secs(u64::MAX),
84 refresh_lock: tokio::sync::Mutex::new(()),
85 http_client: std::sync::OnceLock::new(),
86 })
87 }
88
89 pub fn from_url(config: JwtConfig, url: String, cache_ttl_secs: u64) -> Self {
90 tracing::info!(
91 url = %url,
92 issuer = %config.issuer,
93 cache_ttl_secs,
94 "JWT resolver initialized with JWKS URL"
95 );
96 Self {
97 config,
98 jwks_source: JwksSource::Url(url),
99 cached_keys: Arc::new(RwLock::new(None)),
100 cache_ttl: std::time::Duration::from_secs(cache_ttl_secs),
101 refresh_lock: tokio::sync::Mutex::new(()),
102 http_client: std::sync::OnceLock::new(),
103 }
104 }
105
106 fn extract_bearer(metadata: &MetadataMap) -> Option<String> {
107 metadata
108 .get("authorization")
109 .and_then(|v| v.to_str().ok())
110 .and_then(|v| v.strip_prefix("Bearer "))
111 .map(str::to_string)
112 }
113
114 async fn get_keys(&self) -> Result<Vec<(Option<String>, DecodingKey)>, AuthError> {
115 {
116 let guard = self.cached_keys.read().await;
117 if let Some(cached) = guard.as_ref() {
118 if cached.fetched_at.elapsed() < self.cache_ttl {
119 return Ok(cached.keys.clone());
120 }
121 }
122 }
123
124 match &self.jwks_source {
125 JwksSource::Inline(keys) => Ok(keys.clone()),
126 JwksSource::Url(url) => {
127 let _refresh = self.refresh_lock.lock().await;
131 {
132 let guard = self.cached_keys.read().await;
133 if let Some(cached) = guard.as_ref() {
134 if cached.fetched_at.elapsed() < self.cache_ttl {
135 return Ok(cached.keys.clone());
136 }
137 }
138 }
139 match self.fetch_jwks(url).await {
140 Ok(keys) => {
141 let mut guard = self.cached_keys.write().await;
142 *guard = Some(CachedKeys {
143 keys: keys.clone(),
144 fetched_at: std::time::Instant::now(),
145 });
146 Ok(keys)
147 }
148 Err(fetch_err) => {
149 let guard = self.cached_keys.read().await;
155 if let Some(cached) = guard.as_ref() {
156 if cached.fetched_at.elapsed() < self.cache_ttl + STALE_GRACE {
157 tracing::warn!(
158 error = %fetch_err,
159 "JWKS refresh failed; serving stale cached keys within grace window"
160 );
161 return Ok(cached.keys.clone());
162 }
163 }
164 Err(fetch_err)
165 }
166 }
167 }
168 }
169 }
170
171 async fn fetch_jwks(&self, url: &str) -> Result<Vec<(Option<String>, DecodingKey)>, AuthError> {
172 let client = match self.http_client.get() {
175 Some(c) => c,
176 None => {
177 let built = reqwest::Client::builder()
178 .connect_timeout(std::time::Duration::from_secs(3))
179 .timeout(std::time::Duration::from_secs(5))
180 .build()
181 .map_err(|e| {
182 AuthError::FetchFailed(format!("JWKS client build failed: {e}"))
183 })?;
184 self.http_client.get_or_init(|| built)
185 }
186 };
187 let resp = client
188 .get(url)
189 .send()
190 .await
191 .map_err(|e| AuthError::FetchFailed(format!("JWKS fetch failed: {e}")))?;
192 let jwks: serde_json::Value = resp
193 .json()
194 .await
195 .map_err(|e| AuthError::FetchFailed(format!("JWKS parse failed: {e}")))?;
196 Self::parse_jwks(&jwks).map_err(AuthError::FetchFailed)
197 }
198
199 fn parse_jwks(jwks: &serde_json::Value) -> Result<Vec<(Option<String>, DecodingKey)>, String> {
200 let keys_arr = jwks
201 .get("keys")
202 .and_then(|k| k.as_array())
203 .ok_or_else(|| "JWKS missing 'keys' array".to_string())?;
204
205 let mut decoding_keys = Vec::new();
206 for key in keys_arr {
207 let kty = key.get("kty").and_then(|v| v.as_str()).unwrap_or("");
208 let kid = key.get("kid").and_then(|v| v.as_str()).map(str::to_string);
209 match kty {
210 "RSA" => {
211 let n = key.get("n").and_then(|v| v.as_str()).unwrap_or("");
212 let e = key.get("e").and_then(|v| v.as_str()).unwrap_or("");
213 if !n.is_empty() && !e.is_empty() {
214 if let Ok(dk) = DecodingKey::from_rsa_components(n, e) {
215 decoding_keys.push((kid, dk));
216 }
217 }
218 }
219 "EC" => {
220 let x = key.get("x").and_then(|v| v.as_str()).unwrap_or("");
221 let y = key.get("y").and_then(|v| v.as_str()).unwrap_or("");
222 let crv = key.get("crv").and_then(|v| v.as_str()).unwrap_or("P-256");
223 if !x.is_empty() && !y.is_empty() {
224 if let Ok(dk) = DecodingKey::from_ec_components(x, y) {
225 let _ = crv;
226 decoding_keys.push((kid, dk));
227 }
228 }
229 }
230 "oct" => {
231 if let Some(k_val) = key.get("k").and_then(|v| v.as_str()) {
232 decoding_keys.push((
233 kid,
234 DecodingKey::from_base64_secret(k_val)
235 .unwrap_or_else(|_| DecodingKey::from_secret(k_val.as_bytes())),
236 ));
237 }
238 }
239 _ => {}
240 }
241 }
242
243 if decoding_keys.is_empty() {
244 return Err("no usable keys found in JWKS".to_string());
245 }
246 Ok(decoding_keys)
247 }
248}
249
250#[async_trait::async_trait]
251impl AuthResolver for JwtBearerResolver {
252 fn name(&self) -> &str {
253 "jwt_bearer"
254 }
255
256 async fn resolve(&self, metadata: &MetadataMap) -> Result<Option<ResolvedIdentity>, AuthError> {
257 let token = match Self::extract_bearer(metadata) {
258 Some(t) => t,
259 None => return Ok(None),
260 };
261
262 if !token.contains('.') {
264 return Ok(None);
265 }
266
267 let keys = self.get_keys().await?;
268
269 let header = decode_header(&token)
275 .map_err(|e| AuthError::InvalidCredential(format!("malformed JWT header: {e}")))?;
276 if !self.config.algorithms.contains(&header.alg) {
277 return Err(AuthError::InvalidCredential(format!(
278 "JWT algorithm {:?} is not in the configured allowlist",
279 header.alg
280 )));
281 }
282 let mut validation = Validation::new(header.alg);
283 validation.set_issuer(&[&self.config.issuer]);
284 validation.set_audience(&[&self.config.audience]);
285 validation.algorithms = vec![header.alg];
286
287 let selected: Vec<&DecodingKey> = match header.kid.as_deref() {
293 Some(kid) if keys.iter().any(|(k, _)| k.as_deref() == Some(kid)) => keys
294 .iter()
295 .filter(|(k, _)| k.as_deref() == Some(kid))
296 .map(|(_, dk)| dk)
297 .collect(),
298 _ => keys.iter().map(|(_, dk)| dk).collect(),
299 };
300
301 let mut last_err = None;
302 for key in selected {
303 match decode::<MACPClaims>(&token, key, &validation) {
304 Ok(token_data) => {
305 let claims = token_data.claims;
306 let scopes = claims.macp_scopes.unwrap_or_default();
307
308 return Ok(Some(ResolvedIdentity {
309 sender: claims.sub,
310 allowed_modes: scopes.allowed_modes.map(|m| m.into_iter().collect()),
311 can_start_sessions: scopes.can_start_sessions.unwrap_or(true),
312 max_open_sessions: scopes.max_open_sessions,
313 can_manage_mode_registry: scopes.can_manage_mode_registry.unwrap_or(false),
314 is_observer: scopes.is_observer.unwrap_or(false),
315 resolver: "jwt_bearer".to_string(),
316 }));
317 }
318 Err(e) => {
319 last_err = Some(e);
320 continue;
321 }
322 }
323 }
324
325 match last_err {
326 Some(e) => {
327 use jsonwebtoken::errors::ErrorKind;
328 match e.kind() {
329 ErrorKind::ExpiredSignature => Err(AuthError::Expired),
330 ErrorKind::InvalidIssuer => {
331 Err(AuthError::InvalidCredential("invalid issuer".to_string()))
332 }
333 ErrorKind::InvalidAudience => {
334 Err(AuthError::InvalidCredential("invalid audience".to_string()))
335 }
336 _ => Err(AuthError::InvalidCredential(format!(
337 "JWT validation failed: {e}"
338 ))),
339 }
340 }
341 None => Err(AuthError::InvalidCredential(
342 "no keys available to validate JWT".to_string(),
343 )),
344 }
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use base64::Engine;
352 use jsonwebtoken::{encode, EncodingKey, Header};
353 use serde::Serialize;
354
355 const ISSUER: &str = "https://issuer.test";
356 const AUDIENCE: &str = "macp-runtime";
357 const SECRET: &[u8] = b"super-secret-symmetric-key-32-by";
358
359 #[derive(Serialize)]
360 struct TestClaims<'a> {
361 sub: &'a str,
362 iss: &'a str,
363 aud: &'a str,
364 exp: i64,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 macp_scopes: Option<serde_json::Value>,
367 }
368
369 fn jwks_inline() -> String {
370 let k = base64::engine::general_purpose::STANDARD.encode(SECRET);
371 serde_json::json!({
372 "keys": [
373 { "kty": "oct", "alg": "HS256", "k": k }
374 ]
375 })
376 .to_string()
377 }
378
379 fn config() -> JwtConfig {
380 JwtConfig {
381 issuer: ISSUER.to_string(),
382 audience: AUDIENCE.to_string(),
383 algorithms: vec![Algorithm::HS256],
384 }
385 }
386
387 fn sign(claims: &TestClaims) -> String {
388 let mut header = Header::new(Algorithm::HS256);
389 header.kid = Some("test-key".into());
390 encode(&header, claims, &EncodingKey::from_secret(SECRET)).unwrap()
391 }
392
393 fn bearer(token: &str) -> MetadataMap {
394 let mut m = MetadataMap::new();
395 m.insert("authorization", format!("Bearer {token}").parse().unwrap());
396 m
397 }
398
399 fn jwks_with(kid: &str, secret: &[u8]) -> String {
400 let k = base64::engine::general_purpose::STANDARD.encode(secret);
401 serde_json::json!({
402 "keys": [
403 { "kty": "oct", "alg": "HS256", "kid": kid, "k": k }
404 ]
405 })
406 .to_string()
407 }
408
409 fn sign_with(kid: &str, secret: &[u8], claims: &TestClaims) -> String {
410 let mut header = Header::new(Algorithm::HS256);
411 header.kid = Some(kid.to_string());
412 encode(&header, claims, &EncodingKey::from_secret(secret)).unwrap()
413 }
414
415 async fn spawn_jwks_server(
422 bodies: Vec<String>,
423 ) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
424 use std::sync::atomic::{AtomicUsize, Ordering};
425 use tokio::io::{AsyncReadExt, AsyncWriteExt};
426
427 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
428 let addr = listener.local_addr().unwrap();
429 let requests = Arc::new(AtomicUsize::new(0));
430 let counter = requests.clone();
431 tokio::spawn(async move {
432 loop {
433 let (mut sock, _) = match listener.accept().await {
434 Ok(c) => c,
435 Err(_) => return,
436 };
437 let n = counter.fetch_add(1, Ordering::SeqCst);
438 let body = bodies[n.min(bodies.len() - 1)].clone();
439 tokio::spawn(async move {
440 let mut buf = [0u8; 2048];
441 let _ = sock.read(&mut buf).await;
442 let resp = format!(
443 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
444 body.len(),
445 body
446 );
447 let _ = sock.write_all(resp.as_bytes()).await;
448 });
449 }
450 });
451 (format!("http://{addr}/jwks"), requests)
452 }
453
454 #[tokio::test]
455 async fn valid_jwt_resolves_to_identity_with_scopes() {
456 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
457 let token = sign(&TestClaims {
458 sub: "agent://alice",
459 iss: ISSUER,
460 aud: AUDIENCE,
461 exp: (chrono::Utc::now().timestamp() + 300),
462 macp_scopes: Some(serde_json::json!({
463 "allowed_modes": ["macp.mode.decision.v1"],
464 "can_start_sessions": true,
465 "max_open_sessions": 5,
466 "can_manage_mode_registry": false,
467 "is_observer": false,
468 })),
469 });
470
471 let id = resolver
472 .resolve(&bearer(&token))
473 .await
474 .expect("ok")
475 .expect("some");
476 assert_eq!(id.sender, "agent://alice");
477 assert_eq!(id.resolver, "jwt_bearer");
478 assert!(id.can_start_sessions);
479 assert_eq!(id.max_open_sessions, Some(5));
480 assert!(!id.can_manage_mode_registry);
481 assert!(!id.is_observer);
482 let modes = id.allowed_modes.unwrap();
483 assert!(modes.contains("macp.mode.decision.v1"));
484 }
485
486 #[tokio::test]
487 async fn jwt_without_scopes_defaults_to_permissive_sender() {
488 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
489 let token = sign(&TestClaims {
490 sub: "agent://bob",
491 iss: ISSUER,
492 aud: AUDIENCE,
493 exp: (chrono::Utc::now().timestamp() + 300),
494 macp_scopes: None,
495 });
496 let id = resolver.resolve(&bearer(&token)).await.unwrap().unwrap();
497 assert_eq!(id.sender, "agent://bob");
498 assert!(id.can_start_sessions); assert!(id.allowed_modes.is_none());
500 assert!(!id.is_observer);
501 }
502
503 #[tokio::test]
504 async fn expired_jwt_returns_expired_error() {
505 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
506 let token = sign(&TestClaims {
508 sub: "agent://alice",
509 iss: ISSUER,
510 aud: AUDIENCE,
511 exp: (chrono::Utc::now().timestamp() - 600),
512 macp_scopes: None,
513 });
514 let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
515 assert!(matches!(err, AuthError::Expired), "got {err:?}");
516 }
517
518 #[tokio::test]
519 async fn wrong_issuer_rejected() {
520 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
521 let token = sign(&TestClaims {
522 sub: "agent://alice",
523 iss: "https://other.example",
524 aud: AUDIENCE,
525 exp: (chrono::Utc::now().timestamp() + 300),
526 macp_scopes: None,
527 });
528 let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
529 assert!(
530 matches!(err, AuthError::InvalidCredential(ref m) if m.contains("issuer")),
531 "got {err:?}"
532 );
533 }
534
535 #[tokio::test]
536 async fn wrong_audience_rejected() {
537 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
538 let token = sign(&TestClaims {
539 sub: "agent://alice",
540 iss: ISSUER,
541 aud: "other-audience",
542 exp: (chrono::Utc::now().timestamp() + 300),
543 macp_scopes: None,
544 });
545 let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
546 assert!(
547 matches!(err, AuthError::InvalidCredential(ref m) if m.contains("audience")),
548 "got {err:?}"
549 );
550 }
551
552 #[tokio::test]
553 async fn bad_signature_rejected() {
554 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
555 let claims = TestClaims {
557 sub: "agent://alice",
558 iss: ISSUER,
559 aud: AUDIENCE,
560 exp: (chrono::Utc::now().timestamp() + 300),
561 macp_scopes: None,
562 };
563 let bad_token = encode(
564 &Header::new(Algorithm::HS256),
565 &claims,
566 &EncodingKey::from_secret(b"different-key-bytes-0123456789!!"),
567 )
568 .unwrap();
569 let err = resolver.resolve(&bearer(&bad_token)).await.unwrap_err();
570 assert!(
571 matches!(err, AuthError::InvalidCredential(_)),
572 "got {err:?}"
573 );
574 }
575
576 #[tokio::test]
577 async fn opaque_bearer_token_is_not_claimed() {
578 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
579 let outcome = resolver
581 .resolve(&bearer("static-opaque-token"))
582 .await
583 .unwrap();
584 assert!(outcome.is_none());
585 }
586
587 #[tokio::test]
588 async fn missing_authorization_header_is_not_claimed() {
589 let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
590 let outcome = resolver.resolve(&MetadataMap::new()).await.unwrap();
591 assert!(outcome.is_none());
592 }
593
594 #[tokio::test]
598 async fn concurrent_jwks_refresh_is_single_flight() {
599 use std::sync::atomic::{AtomicUsize, Ordering};
600 use tokio::io::{AsyncReadExt, AsyncWriteExt};
601
602 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
603 let addr = listener.local_addr().unwrap();
604 let connections = Arc::new(AtomicUsize::new(0));
605 let counter = connections.clone();
606 tokio::spawn(async move {
607 loop {
608 let (mut sock, _) = match listener.accept().await {
609 Ok(c) => c,
610 Err(_) => return,
611 };
612 counter.fetch_add(1, Ordering::SeqCst);
613 let body = jwks_inline();
614 tokio::spawn(async move {
615 let mut buf = [0u8; 2048];
616 let _ = sock.read(&mut buf).await;
617 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
620 let resp = format!(
621 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
622 body.len(),
623 body
624 );
625 let _ = sock.write_all(resp.as_bytes()).await;
626 });
627 }
628 });
629
630 let resolver = Arc::new(JwtBearerResolver::from_url(
631 config(),
632 format!("http://{addr}/jwks"),
633 300,
634 ));
635 let mut handles = Vec::new();
636 for _ in 0..8 {
637 let r = resolver.clone();
638 handles.push(tokio::spawn(async move { r.get_keys().await }));
639 }
640 for h in handles {
641 let keys = h.await.unwrap().expect("all callers get keys");
642 assert!(!keys.is_empty());
643 }
644 assert_eq!(
645 connections.load(Ordering::SeqCst),
646 1,
647 "8 concurrent refreshes must coalesce into one JWKS fetch"
648 );
649 }
650
651 #[tokio::test]
652 async fn server_env_algorithms_accept_hs256_tokens() {
653 let cfg = JwtConfig {
655 issuer: ISSUER.to_string(),
656 audience: AUDIENCE.to_string(),
657 algorithms: vec![Algorithm::RS256, Algorithm::ES256, Algorithm::HS256],
658 };
659 let resolver = JwtBearerResolver::from_inline_json(cfg, &jwks_inline()).unwrap();
660 let token = sign(&TestClaims {
661 sub: "agent://alice",
662 iss: ISSUER,
663 aud: AUDIENCE,
664 exp: (chrono::Utc::now().timestamp() + 300),
665 macp_scopes: None,
666 });
667 let id = resolver
668 .resolve(&bearer(&token))
669 .await
670 .expect("ok")
671 .expect("some");
672 assert_eq!(id.sender, "agent://alice");
673 }
674
675 #[tokio::test]
676 async fn jwks_url_happy_path_token_validates() {
677 let (url, requests) = spawn_jwks_server(vec![jwks_inline()]).await;
678 let resolver = JwtBearerResolver::from_url(config(), url, 300);
679 let token = sign(&TestClaims {
680 sub: "agent://alice",
681 iss: ISSUER,
682 aud: AUDIENCE,
683 exp: (chrono::Utc::now().timestamp() + 300),
684 macp_scopes: None,
685 });
686
687 let id = resolver
688 .resolve(&bearer(&token))
689 .await
690 .expect("ok")
691 .expect("some");
692 assert_eq!(id.sender, "agent://alice");
693 assert_eq!(id.resolver, "jwt_bearer");
694 assert_eq!(
695 requests.load(std::sync::atomic::Ordering::SeqCst),
696 1,
697 "exactly one JWKS fetch for the first validation"
698 );
699 }
700
701 #[tokio::test]
704 async fn jwks_url_second_validation_within_ttl_does_not_refetch() {
705 let (url, requests) = spawn_jwks_server(vec![jwks_inline()]).await;
706 let resolver = JwtBearerResolver::from_url(config(), url, 300);
707 let token = sign(&TestClaims {
708 sub: "agent://alice",
709 iss: ISSUER,
710 aud: AUDIENCE,
711 exp: (chrono::Utc::now().timestamp() + 300),
712 macp_scopes: None,
713 });
714
715 for _ in 0..2 {
716 let id = resolver.resolve(&bearer(&token)).await.unwrap().unwrap();
717 assert_eq!(id.sender, "agent://alice");
718 }
719 assert_eq!(
720 requests.load(std::sync::atomic::Ordering::SeqCst),
721 1,
722 "second validation within TTL must be served from cache"
723 );
724 }
725
726 #[tokio::test]
732 async fn jwks_url_refresh_after_ttl_picks_up_rotated_key() {
733 const NEW_SECRET: &[u8] = b"rotated-secret-symmetric-32-byte";
734 let (url, requests) = spawn_jwks_server(vec![
735 jwks_with("old-key", SECRET),
736 jwks_with("new-key", NEW_SECRET),
737 ])
738 .await;
739 let resolver = JwtBearerResolver::from_url(config(), url, 0);
740
741 let old_token = sign_with(
742 "old-key",
743 SECRET,
744 &TestClaims {
745 sub: "agent://alice",
746 iss: ISSUER,
747 aud: AUDIENCE,
748 exp: (chrono::Utc::now().timestamp() + 300),
749 macp_scopes: None,
750 },
751 );
752 let id = resolver
753 .resolve(&bearer(&old_token))
754 .await
755 .unwrap()
756 .unwrap();
757 assert_eq!(id.sender, "agent://alice");
758
759 let new_token = sign_with(
762 "new-key",
763 NEW_SECRET,
764 &TestClaims {
765 sub: "agent://rotated",
766 iss: ISSUER,
767 aud: AUDIENCE,
768 exp: (chrono::Utc::now().timestamp() + 300),
769 macp_scopes: None,
770 },
771 );
772 let id = resolver
773 .resolve(&bearer(&new_token))
774 .await
775 .unwrap()
776 .unwrap();
777 assert_eq!(id.sender, "agent://rotated");
778 assert_eq!(
779 requests.load(std::sync::atomic::Ordering::SeqCst),
780 2,
781 "rotation requires exactly one refetch after TTL expiry"
782 );
783 }
784
785 #[tokio::test]
788 async fn jwks_url_unreachable_endpoint_fails_cleanly() {
789 let addr = {
791 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
792 listener.local_addr().unwrap()
793 };
794 let resolver = JwtBearerResolver::from_url(config(), format!("http://{addr}/jwks"), 300);
795 let token = sign(&TestClaims {
796 sub: "agent://alice",
797 iss: ISSUER,
798 aud: AUDIENCE,
799 exp: (chrono::Utc::now().timestamp() + 300),
800 macp_scopes: None,
801 });
802
803 let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
804 assert!(matches!(err, AuthError::FetchFailed(_)), "got {err:?}");
805 }
806}