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 #[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); 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 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 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 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 #[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 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 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}