Skip to main content

macp_auth/auth/resolvers/
jwt_bearer.rs

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    /// Serializes JWKS refreshes (single-flight): when the TTL expires under
47    /// concurrent load, exactly one caller fetches while the rest wait and
48    /// then read the refreshed cache — no thundering herd on the endpoint.
49    refresh_lock: tokio::sync::Mutex<()>,
50    /// Built once on first use and reused across refreshes (connection
51    /// pooling; previously a new client was built per fetch).
52    http_client: std::sync::OnceLock<reqwest::Client>,
53}
54
55enum JwksSource {
56    Inline(Vec<(Option<String>, DecodingKey)>),
57    Url(String),
58}
59
60/// How long past the normal cache TTL stale JWKS keys may still be served
61/// when the endpoint is unreachable (availability vs. rotation-latency
62/// trade-off; rotated-out keys stop verifying at most TTL+grace after
63/// removal from the JWKS).
64const 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                // Single-flight: serialize refreshes, then re-check the cache
128                // — a caller that waited here usually finds the keys another
129                // caller just fetched and never hits the endpoint itself.
130                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                        // Stale-cache fallback: a JWKS endpoint outage must
150                        // not take down ALL JWT auth the moment the TTL
151                        // expires. Serve the last-known keys (bounded by the
152                        // stale window) while refresh keeps failing; key
153                        // rotation still converges on the next good fetch.
154                        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        // Explicit timeouts: a hanging JWKS endpoint must not block the auth
173        // path indefinitely (the default reqwest client has no timeout).
174        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        // Only handle JWT-shaped tokens (contain dots)
263        if !token.contains('.') {
264            return Ok(None);
265        }
266
267        let keys = self.get_keys().await?;
268
269        // Inspect the token header to pick a single algorithm to validate against.
270        // jsonwebtoken 9 requires every algorithm in validation.algorithms to match
271        // the DecodingKey's family, so a mixed list (RS256 + HS256) with one key
272        // would always fail with InvalidAlgorithm. We still gate on the configured
273        // allowlist — if the token's alg isn't configured, we reject it.
274        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        // Key selection: when the token names a `kid` and the JWKS has a
288        // matching key, verify against that key only (O(1), and a signature
289        // failure is then a real failure, not "wrong key tried first").
290        // Tokens without a kid, or with an unknown kid, fall back to trying
291        // every key of the right family (previous behavior).
292        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    /// Serve canned JWKS documents over plain HTTP/1.1 on an ephemeral local
416    /// port (same pattern as `concurrent_jwks_refresh_is_single_flight`).
417    /// Request N gets `bodies[N]` (the last body repeats once exhausted);
418    /// the returned counter tracks how many fetches the resolver made.
419    /// `Connection: close` keeps one accepted connection == one fetch even
420    /// though the resolver's reqwest client pools connections.
421    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); // default when unspecified
499        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        // Exceed the default 60s leeway applied by jsonwebtoken's Validation.
507        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        // Sign with a different key — signature won't verify.
556        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        // No dots → not JWT-shaped → defer to next resolver.
580        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    /// Single-flight: N concurrent callers hitting an empty/expired cache
595    /// must coalesce into exactly one JWKS fetch (no thundering herd on the
596    /// endpoint when the TTL expires under load).
597    #[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                    // Hold the response briefly so all 8 callers pile up
618                    // behind the in-flight refresh.
619                    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        // Reproduce the server's SecurityLayer::from_env() config: algorithms = RS256/ES256/HS256.
654        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    /// Within the cache TTL a second validation must be served from the
702    /// cached JWKS — no second fetch against the endpoint.
703    #[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    /// Key rotation: once the TTL lapses, the next validation refetches the
727    /// JWKS and a token signed with the NEW kid verifies against the rotated
728    /// key. There is no unknown-kid on-miss refresh path — refresh happens
729    /// only on TTL expiry — so a zero TTL (every call refetches) exercises
730    /// the refresh path deterministically without sleeping.
731    #[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        // Cache is already expired (TTL 0): the next validation refetches and
760        // gets the rotated JWKS, so the new-kid token verifies.
761        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    /// An unreachable JWKS endpoint with an empty cache must surface a clean
786    /// FetchFailed error on first validation — no panic, no identity.
787    #[tokio::test]
788    async fn jwks_url_unreachable_endpoint_fails_cleanly() {
789        // Bind then drop to obtain a port that refuses connections.
790        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}