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    #[tokio::test]
400    async fn valid_jwt_resolves_to_identity_with_scopes() {
401        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
402        let token = sign(&TestClaims {
403            sub: "agent://alice",
404            iss: ISSUER,
405            aud: AUDIENCE,
406            exp: (chrono::Utc::now().timestamp() + 300),
407            macp_scopes: Some(serde_json::json!({
408                "allowed_modes": ["macp.mode.decision.v1"],
409                "can_start_sessions": true,
410                "max_open_sessions": 5,
411                "can_manage_mode_registry": false,
412                "is_observer": false,
413            })),
414        });
415
416        let id = resolver
417            .resolve(&bearer(&token))
418            .await
419            .expect("ok")
420            .expect("some");
421        assert_eq!(id.sender, "agent://alice");
422        assert_eq!(id.resolver, "jwt_bearer");
423        assert!(id.can_start_sessions);
424        assert_eq!(id.max_open_sessions, Some(5));
425        assert!(!id.can_manage_mode_registry);
426        assert!(!id.is_observer);
427        let modes = id.allowed_modes.unwrap();
428        assert!(modes.contains("macp.mode.decision.v1"));
429    }
430
431    #[tokio::test]
432    async fn jwt_without_scopes_defaults_to_permissive_sender() {
433        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
434        let token = sign(&TestClaims {
435            sub: "agent://bob",
436            iss: ISSUER,
437            aud: AUDIENCE,
438            exp: (chrono::Utc::now().timestamp() + 300),
439            macp_scopes: None,
440        });
441        let id = resolver.resolve(&bearer(&token)).await.unwrap().unwrap();
442        assert_eq!(id.sender, "agent://bob");
443        assert!(id.can_start_sessions); // default when unspecified
444        assert!(id.allowed_modes.is_none());
445        assert!(!id.is_observer);
446    }
447
448    #[tokio::test]
449    async fn expired_jwt_returns_expired_error() {
450        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
451        // Exceed the default 60s leeway applied by jsonwebtoken's Validation.
452        let token = sign(&TestClaims {
453            sub: "agent://alice",
454            iss: ISSUER,
455            aud: AUDIENCE,
456            exp: (chrono::Utc::now().timestamp() - 600),
457            macp_scopes: None,
458        });
459        let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
460        assert!(matches!(err, AuthError::Expired), "got {err:?}");
461    }
462
463    #[tokio::test]
464    async fn wrong_issuer_rejected() {
465        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
466        let token = sign(&TestClaims {
467            sub: "agent://alice",
468            iss: "https://other.example",
469            aud: AUDIENCE,
470            exp: (chrono::Utc::now().timestamp() + 300),
471            macp_scopes: None,
472        });
473        let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
474        assert!(
475            matches!(err, AuthError::InvalidCredential(ref m) if m.contains("issuer")),
476            "got {err:?}"
477        );
478    }
479
480    #[tokio::test]
481    async fn wrong_audience_rejected() {
482        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
483        let token = sign(&TestClaims {
484            sub: "agent://alice",
485            iss: ISSUER,
486            aud: "other-audience",
487            exp: (chrono::Utc::now().timestamp() + 300),
488            macp_scopes: None,
489        });
490        let err = resolver.resolve(&bearer(&token)).await.unwrap_err();
491        assert!(
492            matches!(err, AuthError::InvalidCredential(ref m) if m.contains("audience")),
493            "got {err:?}"
494        );
495    }
496
497    #[tokio::test]
498    async fn bad_signature_rejected() {
499        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
500        // Sign with a different key — signature won't verify.
501        let claims = TestClaims {
502            sub: "agent://alice",
503            iss: ISSUER,
504            aud: AUDIENCE,
505            exp: (chrono::Utc::now().timestamp() + 300),
506            macp_scopes: None,
507        };
508        let bad_token = encode(
509            &Header::new(Algorithm::HS256),
510            &claims,
511            &EncodingKey::from_secret(b"different-key-bytes-0123456789!!"),
512        )
513        .unwrap();
514        let err = resolver.resolve(&bearer(&bad_token)).await.unwrap_err();
515        assert!(
516            matches!(err, AuthError::InvalidCredential(_)),
517            "got {err:?}"
518        );
519    }
520
521    #[tokio::test]
522    async fn opaque_bearer_token_is_not_claimed() {
523        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
524        // No dots → not JWT-shaped → defer to next resolver.
525        let outcome = resolver
526            .resolve(&bearer("static-opaque-token"))
527            .await
528            .unwrap();
529        assert!(outcome.is_none());
530    }
531
532    #[tokio::test]
533    async fn missing_authorization_header_is_not_claimed() {
534        let resolver = JwtBearerResolver::from_inline_json(config(), &jwks_inline()).unwrap();
535        let outcome = resolver.resolve(&MetadataMap::new()).await.unwrap();
536        assert!(outcome.is_none());
537    }
538
539    /// Single-flight: N concurrent callers hitting an empty/expired cache
540    /// must coalesce into exactly one JWKS fetch (no thundering herd on the
541    /// endpoint when the TTL expires under load).
542    #[tokio::test]
543    async fn concurrent_jwks_refresh_is_single_flight() {
544        use std::sync::atomic::{AtomicUsize, Ordering};
545        use tokio::io::{AsyncReadExt, AsyncWriteExt};
546
547        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
548        let addr = listener.local_addr().unwrap();
549        let connections = Arc::new(AtomicUsize::new(0));
550        let counter = connections.clone();
551        tokio::spawn(async move {
552            loop {
553                let (mut sock, _) = match listener.accept().await {
554                    Ok(c) => c,
555                    Err(_) => return,
556                };
557                counter.fetch_add(1, Ordering::SeqCst);
558                let body = jwks_inline();
559                tokio::spawn(async move {
560                    let mut buf = [0u8; 2048];
561                    let _ = sock.read(&mut buf).await;
562                    // Hold the response briefly so all 8 callers pile up
563                    // behind the in-flight refresh.
564                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
565                    let resp = format!(
566                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
567                        body.len(),
568                        body
569                    );
570                    let _ = sock.write_all(resp.as_bytes()).await;
571                });
572            }
573        });
574
575        let resolver = Arc::new(JwtBearerResolver::from_url(
576            config(),
577            format!("http://{addr}/jwks"),
578            300,
579        ));
580        let mut handles = Vec::new();
581        for _ in 0..8 {
582            let r = resolver.clone();
583            handles.push(tokio::spawn(async move { r.get_keys().await }));
584        }
585        for h in handles {
586            let keys = h.await.unwrap().expect("all callers get keys");
587            assert!(!keys.is_empty());
588        }
589        assert_eq!(
590            connections.load(Ordering::SeqCst),
591            1,
592            "8 concurrent refreshes must coalesce into one JWKS fetch"
593        );
594    }
595
596    #[tokio::test]
597    async fn server_env_algorithms_accept_hs256_tokens() {
598        // Reproduce the server's SecurityLayer::from_env() config: algorithms = RS256/ES256/HS256.
599        let cfg = JwtConfig {
600            issuer: ISSUER.to_string(),
601            audience: AUDIENCE.to_string(),
602            algorithms: vec![Algorithm::RS256, Algorithm::ES256, Algorithm::HS256],
603        };
604        let resolver = JwtBearerResolver::from_inline_json(cfg, &jwks_inline()).unwrap();
605        let token = sign(&TestClaims {
606            sub: "agent://alice",
607            iss: ISSUER,
608            aud: AUDIENCE,
609            exp: (chrono::Utc::now().timestamp() + 300),
610            macp_scopes: None,
611        });
612        let id = resolver
613            .resolve(&bearer(&token))
614            .await
615            .expect("ok")
616            .expect("some");
617        assert_eq!(id.sender, "agent://alice");
618    }
619}